AWS-SNS-Listener

Amazon Simple Notification Service (SNS) is a managed service that provides message delivery from publishers to subscribers.

Messaging and Conferencing · AWS-SNS-Listener

Details

IDAWS-SNS-Listener
ProviderAmazon
CategoryMessaging and Conferencing
From Version6.10.0
Docker Imagedemisto/fastapi:0.125.0.10158186
Supported ModulesAgentix XSIAM

README

Amazon Simple Notification Service (SNS) is a managed service that provides message delivery from publishers to subscribers.
This integration was integrated and tested with version January 2024 of AWS-SNS-Listener.

Configure AWS-SNS-Listener in Cortex

Parameter Description Required
Long running instance Integration is long running by default  
Listen Port Runs the service on this port from within Cortex XSOAR. Requires a unique port for each long-running integration instance. Do not use the same port for multiple instances. Note: If you click the test button more than once, a failure may occur mistakenly indicating that the port is already in use. False
Username Uses basic authentication for accessing the list. If empty, no authentication is enforced. (For Cortex XSOAR 8 and Cortex XSIAM) Optional for engines, otherwise mandatory. False
Password   False
Endpoint Set the endpoint of your listener. example: /snsv2 False
Certificate (Required for HTTPS) (For Cortex XSOAR 6.x) For use with HTTPS - the certificate that the service should use. (For Cortex XSOAR 8 and Cortex XSIAM) Custom certificates are not supported. False
Private Key (Required for HTTPS) (For Cortex XSOAR 6.x) For use with HTTPS - the private key that the service should use. (For Cortex XSOAR 8 and Cortex XSIAM) When using an engine, configure a private API key. Not supported on the Cortex XSOAR​​ or Cortex XSIAM server. False
Store sample events for mapping Because this is a push-based integration, it cannot fetch sample events in the mapping wizard. After you finish mapping, it is recommended to turn off the sample events storage to reduce performance overhead. False
Use system proxy settings   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.

Configuration parameters

  • longRunning — Long running instance
  • credentials — Username
  • endpoint — Endpoint
  • certificate — Certificate (Required for HTTPS)
  • key — Private Key (Required for HTTPS)
  • store_samples — Store sample events for mapping
  • proxy — Use system proxy settings
  • longRunningPort — Listen Port (required)

Commands (0)

This integration defines no commands.

import base64
import re
from collections import deque
from secrets import compare_digest
from tempfile import NamedTemporaryFile
from traceback import format_exc
from urllib.parse import urlparse

import uvicorn
from CommonServerPython import *  # noqa: F401
from fastapi import Depends, FastAPI, Request, Response, status
from fastapi.openapi.models import APIKey
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.security.api_key import APIKeyHeader
from M2Crypto import X509

from CommonServerUserPython import *

PARAMS: dict = demisto.params()
sample_events_to_store = deque(maxlen=20)  # type: ignore[var-annotated]

app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
basic_auth = HTTPBasic(auto_error=False)
token_auth = APIKeyHeader(auto_error=False, name="Authorization")

PROXIES, USE_SSL = handle_proxy_for_long_running()


class AWS_SNS_CLIENT(BaseClient):  # pragma: no cover
    def __init__(self, base_url=None):
        if PROXIES:
            self.proxies = PROXIES
        elif PARAMS.get("proxy"):
            self.proxies = handle_proxy()
        headers = {"Accept": "application/json"}
        super().__init__(base_url=base_url, proxy=bool(PROXIES), verify=USE_SSL, headers=headers)

    def get(self, full_url, resp_type="json"):
        return self._http_request(method="GET", full_url=full_url, proxies=PROXIES, resp_type=resp_type)


client = AWS_SNS_CLIENT()


def _validate_sns_url(url: str, field_name: str) -> None:
    """Validate that a URL points to a legitimate AWS SNS endpoint.

    Args:
        url: The URL to validate.
        field_name: Name of the field (for error messages).

    Raises:
        DemistoException: If the URL is not a valid AWS SNS endpoint.
    """
    parsed = urlparse(url)
    if parsed.scheme != "https":
        raise DemistoException(f"{field_name} must use HTTPS, got: {parsed.scheme}")
    if not parsed.hostname or not re.fullmatch(r"sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?", parsed.hostname):
        raise DemistoException(f"{field_name} host not an AWS SNS endpoint: {parsed.hostname}")


class ServerConfig:  # pragma: no cover
    def __init__(self, certificate_path, private_key_path, log_config, ssl_args):
        self.certificate_path = certificate_path
        self.private_key_path = private_key_path
        self.log_config = log_config
        self.ssl_args = ssl_args


class SNSCertificateManager:
    def __init__(self):
        self.cached_cert_url: str | None = None
        self.cached_cert: X509.X509 | None = None

    def is_valid_sns_message(self, sns_payload):
        """
        Validates an incoming Amazon Simple Notification Service (SNS) message.

        Args:
            sns_payload (dict): The SNS payload containing relevant fields.

        Returns:
            bool: True if the message is valid, False otherwise.
        """
        # taken from https://github.com/boto/boto3/issues/2508
        demisto.debug("In is_valid_sns_message")
        # Can only be one of these types.
        if sns_payload["Type"] not in ["SubscriptionConfirmation", "Notification", "UnsubscribeConfirmation"]:
            demisto.error("Not a valid SNS message")
            return False

        # Amazon SNS currently supports signature version 1 or 2.
        if sns_payload.get("SignatureVersion") not in ["1", "2"]:
            demisto.error("Not using the supported AWS-SNS SignatureVersion 1 or 2")
            return False
        demisto.debug(f'Handling Signature Version: {sns_payload.get("SignatureVersion")}')
        # Fields for a standard notification.
        fields = ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"]

        # Determine the required fields based on message type
        if sns_payload["Type"] in ["SubscriptionConfirmation", "UnsubscribeConfirmation"]:
            fields = ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"]

        # Build the string to be signed.
        string_to_sign = ""
        for field in fields:
            string_to_sign += field + "\n" + sns_payload[field] + "\n"

        # Verify the signature
        decoded_signature = base64.b64decode(sns_payload["Signature"])

        # Cache the certificate object, not the validation decision.
        # Always verify the signature even on cache hit.
        if self.cached_cert_url == sns_payload["SigningCertURL"] and self.cached_cert:
            demisto.debug(f'Using cached certificate for SigningCertURL: {sns_payload["SigningCertURL"]}')
            certificate = self.cached_cert
        else:
            try:
                # Validate SigningCertURL before fetching
                _validate_sns_url(sns_payload["SigningCertURL"], "SigningCertURL")

                demisto.debug(f'sns_payload["SigningCertURL"] = {sns_payload["SigningCertURL"]}')
                response: requests.models.Response = client.get(full_url=sns_payload["SigningCertURL"], resp_type="response")
                response.raise_for_status()
                certificate = X509.load_cert_string(response.text)
            except DemistoException:
                raise
            except Exception as e:
                demisto.error(f"Exception validating sign cert url: {e}")
                if "502" in str(e):
                    demisto.error(f'SigningCertURL: {sns_payload["SigningCertURL"]}')
                elif "Verify that the server URL parameter" in str(e):
                    demisto.error(f"client base url: {client._base_url}")
                elif "Proxy Error" in str(e):
                    demisto.error(f"PROXIES = {PROXIES}")
                demisto.debug("SigningCertURL failed. Clearing cached certificate.")
                self.cached_cert_url = None
                self.cached_cert = None
                return False

            # Validate certificate subject CN contains amazonaws.com
            subject_cn = ""
            try:
                subject = certificate.get_subject()
                subject_cn = subject.CN or ""
            except Exception:
                pass

            if "amazonaws.com" not in subject_cn.lower():
                demisto.error(f"Certificate subject CN not AWS: {subject_cn}")
                return False

            # Cache the certificate object for future use
            self.cached_cert = certificate
            self.cached_cert_url = sns_payload["SigningCertURL"]

        public_key = certificate.get_pubkey()
        # Verify the signature based on SignatureVersion
        if sns_payload["SignatureVersion"] == "1":
            public_key.reset_context(md="sha1")
        else:  # version2
            public_key.reset_context(md="sha256")

        public_key.verify_init()
        public_key.verify_update(string_to_sign.encode())
        verification_result = public_key.verify_final(decoded_signature)

        if verification_result != 1:
            demisto.debug("Signature verification failed. Clearing cached certificate.")
            self.cached_cert_url = None
            self.cached_cert = None
            return False

        demisto.debug("Signature verification succeeded.")
        return True


sns_cert_manager = SNSCertificateManager()


def is_valid_integration_credentials(credentials, request_headers, token):
    credentials_param = PARAMS.get("credentials")
    auth_failed = False
    header_name = None
    if credentials_param and (username := credentials_param.get("identifier")):
        password = credentials_param.get("password", "")
        if username.startswith("_header"):
            header_name = username.split(":")[1]
            token_auth.model.name = header_name
            if not token or not compare_digest(token, password):
                auth_failed = True
        elif (not credentials) or (
            not (compare_digest(credentials.username, username) and compare_digest(credentials.password, password))
        ):
            auth_failed = True
        if auth_failed:
            secret_header = (header_name or "Authorization").lower()
            if secret_header in request_headers:
                request_headers[secret_header] = "***"
            demisto.debug(f"Authorization failed - request headers {request_headers}")
    if auth_failed:  # auth failed not valid credentials
        return False, header_name
    else:
        return True, header_name


def handle_subscription_confirmation(subscribe_url) -> requests.Response:  # pragma: no cover
    demisto.debug("SubscriptionConfirmation request")
    # Validate SubscribeURL before following it
    _validate_sns_url(subscribe_url, "SubscribeURL")
    response: requests.models.Response = client.get(full_url=subscribe_url, resp_type="response")
    response.raise_for_status()
    return response


def handle_notification(payload, raw_json):
    message = payload["Message"]
    demisto.debug(f"Notification request msg: {message}")
    return {
        "name": payload["Subject"],
        "labels": [],
        "rawJSON": raw_json,
        "occurred": payload["Timestamp"],
        "details": f'ExternalID:{payload["MessageId"]} TopicArn:{payload["TopicArn"]} Message:{message}',
        "type": "AWS-SNS Notification",
    }


def store_samples(incident):  # pragma: no cover
    try:
        sample_events_to_store.append(incident)
        integration_context = get_integration_context()
        sample_events = deque(json.loads(integration_context.get("sample_events", "[]")), maxlen=20)
        sample_events += sample_events_to_store
        integration_context["sample_events"] = list(sample_events)
        set_to_integration_context_with_retries(integration_context)
    except Exception as e:
        demisto.error(f"Failed storing sample events - {e}")


@app.post(f'/{PARAMS.get("endpoint","")}')
async def handle_post(
    request: Request, credentials: HTTPBasicCredentials = Depends(basic_auth), token: APIKey = Depends(token_auth)
):  # pragma: no cover
    """
    Handles incoming AWS-SNS POST requests.
    Supports SubscriptionConfirmation, Notification and UnsubscribeConfirmation.

    Args:
        request (Request): The incoming HTTP request.
        credentials (HTTPBasicCredentials): Basic authentication credentials.
        token (APIKey): API key for authentication.

    Returns:
        Union[Response, str]: Response data or error message.
    """
    data = ""
    request_headers = dict(request.headers)
    is_valid_credentials = False
    try:
        is_valid_credentials, header_name = is_valid_integration_credentials(credentials, request_headers, token)
    except Exception as e:
        demisto.error(f"Error handling auth failure: {e}")
    if not is_valid_credentials:
        return Response(status_code=status.HTTP_401_UNAUTHORIZED, content="Authorization failed.")

    secret_header = (header_name or "Authorization").lower()
    request_headers.pop(secret_header, None)

    try:
        type = request_headers["x-amz-sns-message-type"]
        payload = await request.json()
        raw_json = json.dumps(payload)
    except Exception as e:
        demisto.error(f"Error in request parsing: {e}")
        return Response(status_code=status.HTTP_400_BAD_REQUEST, content="Failed parsing request.")
    if not sns_cert_manager.is_valid_sns_message(payload):
        return Response(status_code=status.HTTP_401_UNAUTHORIZED, content="Validation of SNS message failed.")

    if type == "SubscriptionConfirmation":
        demisto.debug("SubscriptionConfirmation request")
        subscribe_url = payload["SubscribeURL"]
        try:
            response = handle_subscription_confirmation(subscribe_url=subscribe_url)
        except Exception as e:
            demisto.error(f"Failed handling SubscriptionConfirmation: {e}")
            return "Failed handling SubscriptionConfirmation"
        demisto.debug(f"Response from subscribe url: {response}")
        return response
    elif type == "Notification":
        incident = handle_notification(payload, raw_json)
        data = demisto.createIncidents(incidents=[incident])
        demisto.debug(f"Created incident: {incident}")
        if PARAMS.get("store_samples"):
            store_samples(incident)
        if not data:
            demisto.error("Failed creating incident")
            data = "Failed creating incident"
        return data
    elif type == "UnsubscribeConfirmation":
        message = payload["Message"]
        demisto.debug(f"UnsubscribeConfirmation request msg: {message}")
        return f"UnsubscribeConfirmation request msg: {message}"
    else:
        demisto.error(f'Failed handling AWS SNS request, unknown type: {payload["Type"]}')
        return f'Failed handling AWS SNS request, unknown type: {payload["Type"]}'


def unlink_certificate(certificate_path, private_key_path):  # pragma: no cover
    if certificate_path:
        os.unlink(certificate_path)
    if private_key_path:
        os.unlink(private_key_path)
    time.sleep(5)


def setup_server():  # pragma: no cover
    certificate = PARAMS.get("certificate", "")
    private_key = PARAMS.get("key", "")

    certificate_path = ""
    private_key_path = ""
    ssl_args = {}
    if certificate and private_key:
        certificate_file = NamedTemporaryFile(delete=False)
        certificate_path = certificate_file.name
        certificate_file.write(bytes(certificate, "utf-8"))
        certificate_file.close()
        ssl_args["ssl_certfile"] = certificate_path

        private_key_file = NamedTemporaryFile(delete=False)
        private_key_path = private_key_file.name
        private_key_file.write(bytes(private_key, "utf-8"))
        private_key_file.close()
        ssl_args["ssl_keyfile"] = private_key_path

        demisto.debug("Starting HTTPS Server")
    else:
        demisto.debug("Starting HTTP Server")

    integration_logger = IntegrationLogger()
    integration_logger.buffering = False
    log_config = dict(uvicorn.config.LOGGING_CONFIG)
    log_config["handlers"]["default"]["stream"] = integration_logger
    log_config["handlers"]["access"]["stream"] = integration_logger
    return ServerConfig(
        log_config=log_config, ssl_args=ssl_args, certificate_path=certificate_path, private_key_path=private_key_path
    )


def test_module():  # pragma: no cover
    """
    Assigns a temporary port for longRunningPort and returns 'ok'.
    """
    if not PARAMS.get("longRunningPort"):
        PARAMS["longRunningPort"] = "1111"
    return "ok"


""" MAIN FUNCTION """


def main():  # pragma: no cover
    demisto.debug(f"Command being called is {demisto.command()}")
    try:
        if demisto.command() == "test-module":
            return return_results(test_module())
        try:
            port = int(demisto.params().get("longRunningPort"))
        except ValueError as e:
            raise ValueError(f"Invalid listen port - {e}")
        if demisto.command() == "long-running-execution":
            demisto.debug("Started long-running-execution.")
            while True:
                server_config = setup_server()
                if not server_config:
                    raise DemistoException("Failed to configure server.")
                try:
                    uvicorn.run(
                        app,
                        host="0.0.0.0",
                        port=port,
                        log_config=server_config.log_config,  # type: ignore[arg-type]
                        **server_config.ssl_args,
                    )
                except Exception as e:
                    demisto.error(f"An error occurred in the long running loop: {e!s} - {format_exc()}")
                    demisto.updateModuleHealth(f"An error occurred: {e!s}")
                finally:
                    unlink_certificate(server_config.certificate_path, server_config.private_key_path)
        else:
            raise NotImplementedError(f"Command {demisto.command()} is not implemented.")
    except Exception as e:
        demisto.error(format_exc())
        return_error(f"Failed to execute {demisto.command()} command. Error: {e}")


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