AttackSurfaceManagement

Integrate with Mandiant Advantage Attack Surface Management to import "issues" as Incidents.

Vulnerability Management · Mandiant Advantage Attack Surface Management

Details

IDAttackSurfaceManagement
ProviderGoogle
CategoryVulnerability Management
From Version6.8.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

Integrate with Mandiant Advantage Attack Surface Management to import “issues” as Incidents.
This integration was integrated and tested with version 1 of AttackSurfaceManagement

Configure Mandiant Attack Surface Management in Cortex

Parameter Description Required
Your server URL The ASM API URL. Leave as `https://asm-api.advantage.mandiant.com/` if you’re unsure True
Access Key The Access and Secret Keys used for authentication True
Secret Key   True
Project ID The ASM Project ID to retrieve issues from False
Collection IDs A list of Collection IDs, separated by commas (`,`) False
Initial Lookback Days The number of days to look back when first retrieving issues. True
Maximum Issues To Fetch The maximum number of issues to pull during a single fetch-incidents command. True
Trust any certificate (not secure)   False
Use system proxy settings   False
Mirror incoming incidents   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.

attacksurfacemanagement-get-projects


Retrieve a list of all accessible ASM projects.

Base Command

attacksurfacemanagement-get-projects

Input

There are no input arguments for this command.

Context Output

Path Type Description
MandiantAdvantageASM.Projects.Name String The name of the project
MandiantAdvantageASM.Projects.ID Number The ID of the project
MandiantAdvantageASM.Projects.Owner unknown The E-Mail of the project owner

Command example


#### Context Example

```json
{
  "MandiantAdvantageASM": {
    "Projects": [
      {
        "ID": 6797,
        "Name": "ASMQA_AttackSurfaceAPP",
        "Owner": "name@attacksurface.app"
      }
    ]
  }
}

Human Readable Output

Results

ID Name Owner
6797 ASMQA_AttackSurfaceAPP name@attacksurface.app

attacksurfacemanagement-get-collections


Retrieve a list of collections for a specified project

Base Command

attacksurfacemanagement-get-collections

Input

Argument Name Description Required
project_id The ID of the project to query collections for. Optional

Context Output

Path Type Description
MandiantAdvantageASM.Collections.Name String The name of the collection
MandiantAdvantageASM.Collections.ID String The ID of the collection
MandiantAdvantageASM.Collections.Owner unknown The owner of the collection

Command example


#### Context Example

```json
{
    "MandiantAdvantageASM": {
        "Collections": [
            {
                "ID": "attacksurface_mw3tdwq",
                "Name": "Attacksurface_APP_QA",
                "Owner": "ASMQA_AttackSurfaceAPP"
            }
        ]
    }
}

Human Readable Output

Results

ID Name Owner
attacksurface_mw3tdwq Attacksurface_APP_QA ASMQA_AttackSurfaceAPP

fetch-incidents


Fetch Incidents

Base Command

fetch-incidents

Input

There are no input arguments for this command.

Context Output

There is no context output for this command.

get-remote-data


Update a specific incident

Base Command

get-remote-data

Input

Argument Name Description Required
id The ASM Incident ID. Required
lastUpdate Retrieve entries that were created after lastUpdate. Default is 0. Optional

Context Output

There is no context output for this command.

update-remote-system


Update issue in Mandiant Advantage ASM

Base Command

update-remote-system

Input

| Argument Name | Description | Required |
| — | — | — |

Context Output

There is no context output for this command.

Incident Mirroring

You can enable incident mirroring between Cortex XSOAR incidents and Mandiant Attack Surface Management corresponding events (available from Cortex XSOAR version 6.0.0).
To set up the mirroring:

  1. Enable Fetching incidents in your instance configuration.

Newly fetched incidents will be mirrored in the chosen direction. However, this selection does not affect existing incidents.
Important Note: To ensure the mirroring works as expected, mappers are required, both for incoming and outgoing, to map the expected fields in Cortex XSOAR and Mandiant Attack Surface Management.

Configuration parameters

  • url — Your server URL (required)
  • credentials — Access Key (required)
  • project_id — Project ID
  • collection_ids — Collection IDs
  • first_fetch — First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)
  • max_fetch — Maximum Issues To Fetch (required)
  • minimum_severity — Minimum Severity (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • incoming_mirror — Mirror incoming incidents
  • incidentType — Incident type
  • incidentFetchInterval — Incidents Fetch Interval
  • isFetch — Fetch incidents

Commands (5)

  • attacksurfacemanagement-get-collections

    Retrieve a list of collections for a specified project

  • attacksurfacemanagement-get-projects

    Retrieve a list of all accessible ASM projects.

  • fetch-incidents

    Fetch Incidents

  • get-remote-data

    Update a specific incident.

  • update-remote-system

    Update issue in Mandiant Advantage ASM

import typing
import urllib.parse
from datetime import datetime

import dateutil
import urllib3

# import demistomock as demisto
from CommonServerPython import *  # noqa # pylint: disable=unused-wildcard-import
from dateutil import parser

from CommonServerUserPython import *  # noqa

# Disable insecure warnings
urllib3.disable_warnings()

""" CONSTANTS """

DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"  # ISO8601 format with UTC, default in XSOAR
XSOAR_TO_ASM_STATUS_MAP = {0: "open_new", 1: "open_in_progress", 2: "closed", 3: "closed"}


""" CLIENT CLASS """


class Client(BaseClient):
    """Client class to interact with the service API

    This Client implements API calls, and does not contain any XSOAR logic.
    Should only do requests and return data.
    It inherits from BaseClient defined in CommonServer Python.
    Most calls use _http_request() that handles proxy, SSL verification, etc.
    For this  implementation, no special attributes defined
    """

    def __init__(
        self,
        access_key: str,
        secret_key: str,
        base_url: str,
        verify: bool,
        proxy: bool,
        timeout: int,
        limit: int,
        project_id: int | None,
        collection_ids: list[str] | None,
    ):
        self._access_key: str = access_key
        self._secret_key: str = secret_key
        self.base_url: str = base_url

        super().__init__(base_url=base_url, verify=verify, proxy=proxy, ok_codes=(200,))

        self.timeout: int = timeout
        self.limit: int = limit

        self.project_id: Optional[int] = project_id
        self.collection_ids: list[str] = collection_ids or []

    def get_projects_list(self) -> list:
        endpoint = "projects"
        response = self._http_request("GET", endpoint, headers=self.get_headers(include_project=False))

        if not isinstance(response, dict) or not response.get("success"):
            raise DemistoException("The ASM API was unable to returna list of projects")

        return response.get("result") or []

    def get_collections_list(self, project_id: int) -> list:
        endpoint = "user_collections"

        response = self._http_request("GET", endpoint, headers=self.get_headers(project_id=project_id))

        if not isinstance(response, dict) or not response.get("success"):
            raise DemistoException("The ASM API was unable to returna list of collections")

        return response.get("result") or []

    def get_issues_since_time(
        self, since: Optional[datetime], until: Optional[datetime] = None, severity: int = 1
    ) -> typing.Generator[dict, None, None]:
        if not until:
            until = datetime.utcnow()

        endpoint = "search/issues/"
        collections_query = "collection:" + " collection:".join(self.collection_ids)
        severity_query = f" severity_gte:{severity}"

        # Datetime format - 2022-10-07T17:36:37.000Z
        datetime_query = f' last_seen_before:{until.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}'
        if since:
            datetime_query = datetime_query + f' last_seen_after:{since.strftime("%Y-%m-%dT%H:%M:%S.%fZ")}'

        search_query = collections_query + datetime_query + severity_query
        endpoint = endpoint + urllib.parse.quote(search_query)

        params = {"page": 0}

        while True:
            response = self._http_request("GET", endpoint, params=params, headers=self.get_headers())

            if not isinstance(response, dict) or not response.get("success"):
                raise RuntimeError(f"Failed to retrieve issues from ASM {response} {search_query}")

            yield from response.get("result", {}).get("hits")

            if response.get("result", {}).get("more"):
                params["page"] = params["page"] + 1
            else:
                break

    def get_issue_details(self, issue_id: str) -> dict:
        endpoint = f"issues/{issue_id}"

        response = self._http_request("GET", endpoint, headers=self.get_headers())

        if not isinstance(response, dict) or not response.get("success"):
            raise DemistoException(f"The ASM API was unable to return details forissue {issue_id} in project {self.project_id}")

        return response.get("result") or {}

    def get_notes(self, resource_type: str, resource_id: str) -> list:
        endpoint = f"notes/{resource_type}/{resource_id}"

        response = self._http_request("GET", endpoint, headers=self.get_headers())

        if not isinstance(response, dict) or not response.get("success"):
            raise DemistoException(
                f"The ASM API was unable to return notes for{resource_type} {resource_id} in project {self.project_id}"
            )

        return response.get("result") or []

    def update_issue_status(self, indicator_id: str, indicator_status: int):
        endpoint = f"/issues/{indicator_id}/status"
        request_body = {"status": XSOAR_TO_ASM_STATUS_MAP[indicator_status]}

        response = self._http_request("POST", endpoint, json_data=request_body, headers=self.get_headers())

        if not isinstance(response, dict) or not response.get("success"):
            raise DemistoException("The ASM API was unable to updatethe status of the issue")

        return response.get("result") or False

    def get_headers(self, include_project: bool = True, project_id=None):
        if not project_id:
            project_id = self.project_id
        headers = {"INTRIGUE_ACCESS_KEY": self._access_key, "INTRIGUE_SECRET_KEY": self._secret_key}
        if include_project:
            headers["PROJECT_ID"] = str(project_id)

        return headers


""" HELPER FUNCTIONS """


def helper_create_incident(issue: dict, project_id: int) -> dict:
    severity_map = {1: 4, 2: 3, 3: 2, 4: 1, 5: 0.5}

    raw_json = issue
    raw_json["mirror_direction"] = "Both"
    raw_json["mirror_instance"] = demisto.integrationInstance()

    issue = {
        "name": issue.get("summary", {}).get("pretty_name"),
        "details": json.dumps(issue, indent=4),
        "severity": severity_map.get(
            issue.get("summary", {}).get("severity"),
        ),
        "rawJSON": json.dumps(raw_json),
        "dbotMirrorId": issue.get("id", issue.get("uid")),
        "CustomFields": {
            "masmcollection": issue.get("collection"),
            "masmproject": str(project_id),
            "masmconfirmed": issue.get("summary", {}).get("confidence") == "confirmed",
            "masmentityname": issue.get("entity_name"),
            "masmcategory": issue.get("summary", {}).get("category"),
        },
    }

    occurred = raw_json.get("first_seen")
    if isinstance(occurred, str):
        issue["occurred"] = parser.parse(occurred).isoformat()

    return issue


def fetch_incident_helper(client: Client, last_run: dict) -> tuple[dict, list]:
    last_start_time: datetime

    params = demisto.params()

    if not client.project_id:
        raise DemistoException("Must configure a Project ID to fetch incidents from Mandiant ASM")

    last_run_time = last_run.get("time")

    if last_run_time and isinstance(last_run_time, str):
        last_start_time = parser.parse(last_run_time)
    else:
        parsed_time = dateparser.parse(params.get("first_fetch"), settings={"TIMEZONE": "UTC", "RETURN_AS_TIMEZONE_AWARE": True})
        if not parsed_time:
            parsed_time = datetime.now()
        last_start_time = parsed_time

    issues = client.get_issues_since_time(last_start_time, severity=params.get("minimum_severity"))
    most_recent_update = None

    parsed_issues = []

    for issue in issues:
        incident_details = client.get_issue_details(issue["id"])
        new_incident = helper_create_incident(incident_details, client.project_id)
        new_incident["dbotMirrorLastSync"] = datetime.fromtimestamp(0).strftime("%Y-%m-%dT%H:%M:%SZ")
        new_incident["lastupdatetime"] = new_incident["dbotMirrorLastSync"]

        parsed_issues.append(new_incident)

        # If removed, far more issues can be pulled than will be ingested during an ingestion run, resulting
        # in timeouts or apparent freezing
        if len(parsed_issues) >= client.limit:
            break

    parsed_issues = filter_incidents_by_duplicates_and_limit(
        incidents_res=parsed_issues, last_run=last_run, fetch_limit=client.limit, id_field="dbotMirrorId"
    )
    if parsed_issues:
        most_recent_update = parsed_issues[-1].get("last_seen")

    new_issues = [json.loads(i["rawJSON"])["uid"] for i in parsed_issues]

    last_run = {"time": most_recent_update, "found_incident_ids": list(set(new_issues + last_run.get("found_incident_ids", [])))}

    return (
        last_run,
        parsed_issues,
    )


""" COMMAND FUNCTIONS """


def test_module(client: Client) -> str:
    """Tests API connectivity and authentication'

    Returning 'ok' indicates that the integration works like it is supposed to.
    Connection to the service is successful.
    Raises exceptions if something goes wrong.

    :type client: ``Client``
    :param Client: client to use

    :return: 'ok' if test passed, anything else will fail the test.
    :rtype: ``str``
    """

    message: str = ""
    try:
        client.get_projects_list()

        if demisto.params().get("isFetch", False):
            last_run = demisto.getLastRun()
            fetch_incident_helper(client, last_run)

        message = "ok"
    except DemistoException as e:
        if "Forbidden" in str(e) or "Authorization" in str(e):  # TODO: make sure you capture authentication errors
            message = "Authorization Error: make sure Access Key and Secret Key is correctly set"
        else:
            raise e
    return message


def get_projects(client: Client, args: dict = None) -> CommandResults:
    args = args if args else {}

    response = client.get_projects_list()

    output = [{"Name": proj.get("name"), "ID": proj.get("id"), "Owner": proj.get("owner_email")} for proj in response]

    return CommandResults(outputs_prefix="MandiantAdvantageASM.Projects", outputs=output, ignore_auto_extract=True)


def get_collections(client: Client, args: dict = None) -> CommandResults:
    args = args or {}

    project_id: int = args.get("project_id", client.project_id)

    response = client.get_collections_list(project_id)

    output = [
        {"Name": collection.get("printable_name"), "ID": collection.get("name"), "Owner": collection.get("owner_name")}
        for collection in response
    ]

    return CommandResults(outputs_prefix="MandiantAdvantageASM.Collections", outputs=output, ignore_auto_extract=True)


def fetch_incidents(client: Client):
    last_run = demisto.getLastRun()

    last_run, new_issues = fetch_incident_helper(client, last_run)

    demisto.setLastRun(last_run)
    return new_issues


def get_remote_data_command(client: Client, args: dict):
    parsed_args = GetRemoteDataArgs(args)
    if parsed_args.last_update:
        parsed_date = arg_to_datetime(parsed_args.last_update)
    if not parsed_date:
        parsed_date = datetime.now()
    last_updated = parsed_date

    if not client.project_id:
        raise DemistoException("Must configure a Project ID to fetch incidents from Mandiant ASM")

    try:
        masm_id = parsed_args.remote_incident_id

        issue_details = client.get_issue_details(masm_id)

        new_incident_data = helper_create_incident(issue_details, client.project_id)

        notes = client.get_notes("issue", masm_id)
        notes_entries = []
        for note in notes:
            timestamp: datetime = dateutil.parser.parse(note.get("created_at"))
            if timestamp.timestamp() > last_updated.timestamp():
                new_note = {
                    "Type": EntryType.NOTE,
                    "Contents": f"{note['note']}\n{note['created_by_user']['printable_name']}",
                    "ContentsFormat": EntryFormat.MARKDOWN,
                    "Note": True,
                    "Tags": ["note_from_ma_asm"],
                }
                notes_entries.append(new_note)

        if issue_details.get("status", "").lower().startswith("closed"):
            notes_entries.append(
                {
                    "Type": EntryType.NOTE,
                    "Contents": {
                        "dbotIncidentClose": True,
                        "closeNotes": "Closed in Mandiant Advantage ASM",
                        "closeReason": "Closed in Mandiant Advantage ASM",
                    },
                    "ContentsFormat": EntryFormat.JSON,
                }
            )

        new_incident_data["id"] = masm_id

        return GetRemoteDataResponse(new_incident_data, notes_entries)
    except Exception as e:
        raise e


def update_remote_system_command(client: Client, args: dict):
    parsed_args = UpdateRemoteSystemArgs(args)

    remote_incident_id = parsed_args.remote_incident_id

    issue_status = parsed_args.delta.get("runStatus")
    if issue_status is not None:
        client.update_issue_status(remote_incident_id, parsed_args.inc_status)

    if not parsed_args.remote_incident_id or parsed_args.incident_changed:
        if parsed_args.remote_incident_id:
            old_incident = client.get_issue_details(remote_incident_id)

            old_incident.update(parsed_args.delta)

            parsed_args.data = old_incident
        else:
            parsed_args.data["createInvestigation"] = True

    return remote_incident_id


""" MAIN FUNCTION """


def main() -> None:
    """main function, parses params and runs command functions

    :return:
    :rtype:
    """

    params = demisto.params()
    # get the service API url
    base_url = urljoin(params["url"], "/api/v1")

    # if your Client class inherits from BaseClient, SSL verification is
    # handled out of the box by it, just pass ``verify_certificate`` to
    # the Client constructor
    verify_certificate = not params.get("insecure", False)

    # if your Client class inherits from BaseClient, system proxy is handled
    # out of the box by it, just pass ``proxy`` to the Client constructor
    proxy = params.get("proxy", False)

    access_key = params.get("credentials", {}).get("identifier")
    secret_key = params.get("credentials", {}).get("password")
    project_id = params.get("project_id")
    collections = argToList(params.get("collection_ids", []))

    limit = arg_to_number(params.get("max_fetch", 50)) or 50
    timeout = arg_to_number(params.get("timeout", 60)) or 60

    demisto.debug(f"Command being called is {demisto.command()}")
    try:
        # TODO: Make sure you add the proper headers for authentication
        # (i.e. "Authorization": {api key})
        client = Client(
            access_key=access_key,
            secret_key=secret_key,
            base_url=base_url,
            verify=verify_certificate,
            proxy=proxy,
            timeout=timeout,
            limit=limit,
            project_id=project_id,
            collection_ids=collections,
        )

        commands_with_args: dict[str, typing.Callable] = {
            "get-remote-data": get_remote_data_command,
            "update-remote-system": update_remote_system_command,
            "attacksurfacemanagement-get-projects": get_projects,
            "attacksurfacemanagement-get-collections": get_collections,
        }
        commands_no_args: dict[str, typing.Callable] = {"test-module": test_module}

        command = demisto.command()

        if command in commands_no_args:
            # This is the call made when pressing the integration Test button.
            result = commands_no_args[command](client)
            return_results(result)

        elif command in commands_with_args:
            args = demisto.args()
            results = commands_with_args[command](client, args)
            return_results(results)

        elif command == "fetch-incidents":
            results = fetch_incidents(client)
            demisto.incidents(results)

        # TODO: ADD command cases for the commands you will implement
        else:
            raise NotImplementedError("The command you tried to run doens't exist")

    # Log exceptions and return errors
    except Exception as e:
        return_error(f"Failed to execute {demisto.command()} command.\nError:\n{e!s}")


""" ENTRY POINT """

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