Palo Alto Networks AIOps

Palo Alto Networks Best Practice Assessment (BPA) analyzes NGFW and Panorama configurations and compares them to the best practices.

Utilities · Palo Alto Networks AIOps

Details

IDPalo Alto Networks AIOps
ProviderPalo Alto Networks
CategoryUtilities
From Version6.9.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix Cloud Runtime Security XSIAM EDR Cortex Cloud

README

Palo Alto Networks Best Practice Assessment (BPA) analyzes NGFW and Panorama configurations and compares them to the best practices.
This integration was integrated and tested with version from March 2024 of PaloAltoNetworksAIOps.

Configure Palo Alto Networks AIOps in Cortex

Parameter Required
Pan-OS/Panorama Server URL True
Pan-OS/Panorama API Key True
TSG ID True
Client ID True
Client Secret True
Trust any certificate (not secure) False

Commands

You can execute these commands from the CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.

aiops-bpa-report-generate


Generates a bpa report. Steps -

  • Get information about Pan-OS/Panorama device.
  • Get configuration file of Pan-OS/Panorama. If the user provided an entry_id to a config file this step is skipped.
  • Use the information retrieved above to generate a BPA report.
  • During this process the API also generates a report_id for internal use.

Base Command

aiops-bpa-report-generate

Input

Argument Name Description Required
entry_id - Optional: Use this argument if you prefer to upload a configuration file instead of generating the report from Panorama/PAN-OS.
- Entry_id from Cortex XSOAR War Room after uploading a file - should be a config file in xml format.
- If you used this argument and the process failed or reached a timeout, make sure the config file is in xml format.
Optional
requester_email Requester email. Required
requester_name Requester name. Required
interval_in_seconds Interval for polling mechanism. Default is 30. Optional
timeout Timeout for downloading the file. Default is 600. Optional
export_as_file Whether to export the generated report as a file. Possible values are: true, false. Default is True. Optional
show_in_context Whether to show the report data inside the context. Possible values are: true, false. Default is False. Optional

Context Output

By default, there is no context output for this command.
When using show_in_context = True flag the generated report will be inserted to the context data.

Command example

!aiops-bpa-report-generate requester_email=testl@gmail.com requester_name=test

Human Readable Output

- Initiated

The report with id 7fec3669-c7bc-4113-b8b9-cae6a2aeb066 was sent successfully. Download in progress…

- If generation was successful

Generated a file with the relevant data and insert into context data if requested.

- If generation was unsuccessful

The report with id 7fec3669-c7bc-4113-b8b9-cae6a2aeb066 could not be generated- finished with an error.

- If timed out

Scheduled entry timed out.

This indicates that the configuration file is not in the correct format or that the timeout period is insufficient for generating the report

Configuration parameters

  • url — Pan-OS/Panorama Server URL (required)
  • credentials — (required)
  • tsg_id — TSG ID (required)
  • credentials-aiops — Client ID (required)
  • insecure — Trust any certificate (not secure)

Commands (2)

  • aiops-bpa-report-generate

    Generates a bpa report. Steps: - Get configuration file of Pan-OS/Panorama. If the user provided an entry_id to a config file this step is skipped. - Use the information retrieved above to generate a BPA report. - During this process the API also generates a report_id.

  • aiops-polling-upload-report

    Polling mechanism to upload report.

import io
from typing import Any

import demistomock as demisto  # noqa: F401
import urllib3
from CommonServerPython import *  # noqa: F401

from CommonServerUserPython import *  # noqa

urllib3.disable_warnings()

""" GLOBAL VARIABLES """
INTERVAL_FOR_POLLING_DEFAULT = 30
TIMEOUT_FOR_POLLING_DEFAULT = 600
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"  # ISO8601 format with UTC, default in XSOAR

""" CLIENT CLASS """


class Client(BaseClient):
    def __init__(self, base_url, api_key, tsg_id, client_id, client_secret, verify=True, proxy=False, headers=None):
        super().__init__(base_url=base_url, verify=verify, proxy=proxy, headers=headers)
        self._api_key = api_key
        self._tsg_id = tsg_id
        self._client_id = client_id
        self._client_secret = client_secret
        self._access_token = None

    def generate_access_token_request(self):
        integration_context = get_integration_context()
        tsg_access_token = f"{self._tsg_id}.access_token"
        tsg_expiry_time = f"{self._tsg_id}.expiry_time"
        previous_token = integration_context.get(tsg_access_token)
        previous_token_expiry_time = integration_context.get(tsg_expiry_time)

        # type: ignore
        if previous_token and previous_token_expiry_time and previous_token_expiry_time > date_to_timestamp(datetime.now()):
            self._access_token = previous_token
        else:
            data = {"grant_type": "client_credentials", "scope": f"tsg_id:{self._tsg_id}"}
            headers = {
                "Content-Type": "application/x-www-form-urlencoded",
                "Accept": "application/json",
            }
            # Trying to be as accurate as possible with the time of the request
            expiry_time = date_to_timestamp(datetime.now(), date_format=DATE_FORMAT)
            try:
                res = self._http_request(
                    method="POST",
                    full_url="https://auth.apps.paloaltonetworks.com/auth/v1/oauth2/access_token",
                    auth=(self._client_id, self._client_secret),
                    resp_type="response",
                    headers=headers,
                    data=data,
                )
            except DemistoException as e:
                raise DemistoException(
                    f"Error occurred while creating an access token. Please check the instance configuration.\n\n{e}"
                )
            try:
                res = res.json()
            except ValueError as exception:
                raise DemistoException(f"Failed to parse json object from response: {res.text}.\nError: {exception}")

            if access_token := res.get("access_token"):
                expiry_time += res.get("expires_in", 0) * 1000
                new_token = {tsg_access_token: access_token, tsg_expiry_time: expiry_time}
                # stores received token and expiration time in the integration context
                set_integration_context(new_token)
                self._access_token = new_token.get(tsg_access_token)
            else:
                raise DemistoException(
                    "Error occurred while creating an access token. Access token field has not"
                    " found in the response data. Please check the instance configuration.\n"
                )

    def get_info_about_device_request(self):
        headers = {"Content-Type": "application/xml"}
        params = assign_params(type="op", cmd="<show><system><info></info></system></show>", key=self._api_key)
        try:
            response = self._http_request("GET", "/api", params=params, headers=headers, resp_type="xml")
        except DemistoException as e:
            raise DemistoException(f"Could not get info about device. Request finished with an error {e}.")
        formated_xml = adjust_xml_format(response.text, "system")
        return formated_xml

    def get_config_file_request(self):
        headers = {"Content-Type": "application/xml"}
        params = assign_params(type="config", action="show", key=self._api_key)
        try:
            response = self._http_request("GET", "/api", params=params, headers=headers, resp_type="xml")
        except DemistoException as e:
            raise DemistoException(f"Could not get config file. Request finished with an error {e}.")
        formated_xml = adjust_xml_format(response.text, "config")
        return formated_xml

    def generate_bpa_report_request(self, requester_email, requester_name, system_info):
        body = {
            "requester-email": requester_email,
            "requester-name": requester_name,
            "serial": system_info.get("serial", None),
            "version": system_info.get("sw-version", None),
            "model": system_info.get("model", None),
            "family": system_info.get("family", None),
        }

        headers = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            "Authorization": f"Bearer {self._access_token}",
        }
        res = self._http_request(
            method="POST",
            full_url="https://api.stratacloud.paloaltonetworks.com/aiops/bpa/v1/requests",
            headers=headers,
            json_data=body,
        )
        upload_url = res.get("upload-url", None)
        report_id = res.get("id", None)
        if upload_url and report_id:
            return upload_url, report_id
        raise DemistoException(f"Response not in format, can not find uploaded-url or report id. With response {res}.")

    def config_file_to_report_request(self, upload_url, config_in_binary):
        headers = {"Content-Type": "application/octet-stream", "Accept": "*/*", "Authorization": f"Bearer {self._access_token}"}
        res = self._http_request(
            method="PUT",
            full_url=upload_url,
            headers=headers,
            data=config_in_binary,
            empty_valid_codes=[200],
            return_empty_response=True,
        )
        return res

    def check_upload_status_request(self, report_id):
        headers = {"Accept": "*/*", "Authorization": f"Bearer {self._access_token}"}
        res = self._http_request(
            method="GET", full_url=f"https://api.stratacloud.paloaltonetworks.com/aiops/bpa/v1/jobs/{report_id}", headers=headers
        )
        status = res.get("status")
        if not status:
            raise DemistoException("Missing upload status, Error: parse Error.")
        return status

    def download_bpa_request(self, report_id):
        headers = {"Accept": "application/json", "Authorization": f"Bearer {self._access_token}"}
        res = self._http_request(
            method="GET",
            full_url=f"https://api.stratacloud.paloaltonetworks.com/aiops/bpa/v1/reports/{report_id}",
            headers=headers,
        )
        url = res.get("download-url")
        if not url:
            raise DemistoException("Missing download-url, Error: parse Error.")
        return url

    def data_of_download_bpa_request(self, downloaded_BPA_url):
        headers = {"Authorization": f"Bearer {self._access_token}"}
        res = self._http_request(method="GET", full_url=downloaded_BPA_url, headers=headers)
        return res


""" HELPER FUNCTIONS """


def adjust_xml_format(xml_string, new_root_tag):
    root = ET.fromstring(xml_string)
    sub_tags = root.find(f".//{new_root_tag}")
    if not sub_tags:
        raise DemistoException(
            f"Request Succeeded, A parse error occurred- could not find {new_root_tag} tag to adjust to AIOps API."
        )
    attributes = " ".join([f'{k}="{v}"' for k, v in sub_tags.attrib.items()])
    new_xml = f"<{new_root_tag} {attributes}>"
    for child in sub_tags:  # type: ignore
        new_xml += ET.tostring(child, encoding="unicode")
    new_xml += f"</{new_root_tag}>"
    return new_xml


def get_values_from_xml(xml_string, tags):
    result = []
    root = ET.fromstring(xml_string)
    for tag in tags:
        try:
            result.append(root.find(tag).text)  # type: ignore
        except Exception as e:
            raise DemistoException(f"Could not find the required tags in the System file. Error: {e}")
    return result


def convert_config_to_bytes(config_file, origin_flag):
    if origin_flag == "User":
        try:
            get_file_path_res = demisto.getFilePath(config_file)
            file_path = get_file_path_res.pop("path")
            file_bytes: bytes = b""
            with open(file_path, "rb") as f:
                file_bytes = f.read()
            return file_bytes
        except Exception as e:
            raise DemistoException(
                f"The config file upload was unsuccessful or the file could not be converted. With error: {e}."
            )
    else:
        try:
            # Add xml tag to xml
            xml_header = '<?xml version="1.0"?>'
            result = f"{xml_header}\n {config_file}"
            sio_xml = io.StringIO(result)
            xml_in_bytes = sio_xml.read().encode()
            return xml_in_bytes
        except Exception:
            raise DemistoException("The downloaded config file from Panorama/Pan-os could not be converted.")


def convert_response_for_hr(response_json):
    converted_array = []
    check_category_options = ["device", "service_health", "objects", "network", "policies"]
    # Get best_practices elements (only warnings and notes)
    best_practices = response_json.get("best_practices", {})
    for category in check_category_options:
        category_objects = best_practices.get(category, None)
        if category_objects:
            for key, value in category_objects.items():
                if value:
                    warnings = value[0].get("warnings")
                    notes = value[0].get("notes")
                    for warning in warnings:
                        warning["check_type"] = "warning"
                        warning["check_feature"] = key
                        warning["check_category"] = category
                        converted_array.append(warning)
                    for note in notes:
                        note["check_type"] = "note"
                        note["check_feature"] = key
                        note["check_category"] = category
                        converted_array.append(note)
    return converted_array


def create_response(client, report_id, show_in_context, export_as_file, upload_status):
    downloaded_BPA_url = client.download_bpa_request(report_id)
    downloaded_BPA_json = client.data_of_download_bpa_request(downloaded_BPA_url)
    converted_json = convert_response_for_hr(downloaded_BPA_json)
    human_readable_markdown = create_markdown(converted_json)
    response = []
    # CommandResults depends on show_in_context arg
    if show_in_context:
        context_json = [{"report_id": report_id, "report_status": upload_status, "data": converted_json}]
        response.append(
            CommandResults(
                outputs_prefix="AiOps.BPAReport",
                outputs_key_field="report_id",
                outputs=context_json,
                raw_response=downloaded_BPA_json,
                readable_output=human_readable_markdown,
            )
        )
    else:
        response.append(CommandResults(raw_response=downloaded_BPA_json, readable_output=human_readable_markdown))
    # Insert the markdown into a file
    if export_as_file:
        response.append(fileResult(f"report-id-{report_id}.md", human_readable_markdown))
    return response


def create_markdown(original_dict):
    headers = [
        "check_id",
        "check_category",
        "check_feature",
        "check_message",
        "check_name",
        "check_passed",
        "check_type",
        "check_severity",
    ]
    return tableToMarkdown(
        "BPA results:", original_dict, headers=headers, removeNull=True, headerTransform=string_to_table_header
    )


""" COMMAND FUNCTIONS """


def test_module(client: Client) -> str:
    message: str = ""
    try:
        client.generate_access_token_request()
    except DemistoException as e:
        if "access token" in e.message or "Forbidden" in e.message or "Authorization" in e.message:
            raise DemistoException(
                f"Authorization Error: make sure your tsg_id, client_id, client_secret are correctly set. With error {e}"
            )
        else:
            raise
    try:
        client.get_info_about_device_request()
        message = "ok"
    except Exception as e:
        raise DemistoException(f"Authorization Error: make sure your server_url and API_key are correctly set. With error {e}")
    return message


def generate_report_command(client: Client, args: dict[str, Any]):
    client.generate_access_token_request()
    config_file_from_user = args.get("entry_id")
    requester_email = args.get("requester_email", "NoEmail")
    requester_name = args.get("requester_name")
    export_as_file = argToBoolean(args.get("export_as_file", True))
    show_in_context = argToBoolean(args.get("show_in_context", False))
    # Get info about device - system info
    if "@" not in requester_email:
        raise DemistoException(f"Invalid email {requester_email}, please make sure it is a valid email.")
    system_info_xml = client.get_info_about_device_request()
    tags = ["family", "model", "serial", "sw-version"]
    xml_tags_values = get_values_from_xml(system_info_xml, tags)
    if config_file_from_user:
        config_in_binary = convert_config_to_bytes(config_file_from_user, "User")
    # Get info configurations and convert to format if user didn't upload a config file
    elif config_file := client.get_config_file_request():
        config_in_binary = convert_config_to_bytes(config_file, "Download")
    else:
        raise DemistoException("Can not upload a config file since it was not provided.")
    upload_url, report_id = client.generate_bpa_report_request(requester_email, requester_name, dict(zip(tags, xml_tags_values)))
    client.config_file_to_report_request(upload_url, config_in_binary)
    return_results(
        polling_until_upload_report_command(
            {
                "report_id": report_id,
                "export_as_file": export_as_file,
                "show_in_context": show_in_context,
                "hide_polling_output": True,
                "first_round": True,
            },
            client,
        )
    )


@polling_function(
    name="aiops-polling-upload-report",
    interval=arg_to_number(demisto.args().get("interval_in_seconds", INTERVAL_FOR_POLLING_DEFAULT)),  # type: ignore
    timeout=arg_to_number(demisto.args().get("timeout", TIMEOUT_FOR_POLLING_DEFAULT)),  # type: ignore
    requires_polling_arg=False,
    poll_message="",
)
def polling_until_upload_report_command(args: dict[str, Any], client: Client) -> PollResult:
    client.generate_access_token_request()
    report_id = args.get("report_id")
    export_as_file = argToBoolean(args.get("export_as_file", True))
    show_in_context = argToBoolean(args.get("show_in_context", False))
    first_round = argToBoolean(args.get("first_round", False))
    upload_status = client.check_upload_status_request(report_id)
    if upload_status == "COMPLETED_WITH_SUCCESS":
        response = create_response(client, report_id, show_in_context, export_as_file, upload_status)
        return PollResult(
            response=response,
            continue_to_poll=False,
        )
    elif upload_status == "UPLOAD_INITIATED":
        results = CommandResults(readable_output="Polling job failed.")
        if first_round:
            return PollResult(
                response=results,
                continue_to_poll=True,
                args_for_next_run={
                    "report_id": report_id,
                    "export_as_file": export_as_file,
                    "show_in_context": show_in_context,
                    "hide_polling_output": True,
                },
                partial_result=CommandResults(
                    readable_output=f"The report with id {report_id} was sent successfully. Download in progress..."
                ),
            )
        else:
            return PollResult(
                response=results,
                continue_to_poll=True,
                args_for_next_run={
                    "report_id": report_id,
                    "export_as_file": export_as_file,
                    "show_in_context": show_in_context,
                    "hide_polling_output": True,
                },
            )
    elif upload_status == "COMPLETED_WITH_ERROR":
        fail_output = [{"report_id": report_id, "report_status": upload_status}]
        return PollResult(
            response=CommandResults(
                outputs_prefix="AiOps.BPAReport",
                outputs_key_field="report_id",
                outputs=fail_output,
                raw_response=fail_output,
                readable_output=f"The report with id {report_id} could not be generated- finished with an error.",
            ),
            continue_to_poll=False,
        )

    else:
        return PollResult(
            continue_to_poll=True,
            args_for_next_run={
                "report_id": report_id,
                "hide_polling_output": True,
                "export_as_file": export_as_file,
                "show_in_context": show_in_context,
            },
            response=None,
        )


""" MAIN FUNCTION """


def main() -> None:
    command = demisto.command()
    args = demisto.args()
    params = demisto.params()
    verify_certificate = not params.get("insecure", False)
    base_url = params.get("url")
    api_key = params.get("credentials", {}).get("password")
    tsg_id = params.get("tsg_id")
    client_id = params.get("credentials-aiops", {}).get("identifier")
    client_secret = params.get("credentials-aiops", {}).get("password")
    proxy = params.get("proxy", False)
    demisto.debug(f"Command being called is {command}")

    try:
        client = Client(
            base_url=base_url,
            api_key=api_key,
            tsg_id=tsg_id,
            client_id=client_id,
            client_secret=client_secret,
            verify=verify_certificate,
            proxy=proxy,
        )

        if command == "test-module":
            return_results(test_module(client))
        elif command == "aiops-bpa-report-generate":
            generate_report_command(client, args)
        elif command == "aiops-polling-upload-report":
            return_results(polling_until_upload_report_command(args, client))
        else:
            raise NotImplementedError(f"command {command} is not implemented.")
    except Exception as e:
        return_error(f"Failed to execute {command} command.\nError:\n{e!s}")


""" ENTRY POINT """

if __name__ in ("__main__", "__builtin__", "builtins"):
    main()