AWS - GuardDuty Event Collector

Amazon Web Services Guard Duty Service (gd) event collector integration for Cortex XSIAM.

Analytics & SIEM · AWS - GuardDuty

Details

IDAWS - GuardDuty Event Collector
ProviderAmazon
CategoryAnalytics & SIEM
From Version6.8.0
Docker Imagedemisto/boto3py3:1.0.0.10221838
Supported ModulesAgentix XSIAM

README

Amazon Web Services Guard Duty Service Event Collector integration for Cortex XSIAM.

This is the default integration for this content pack when configured by the Data Onboarder in Cortex XSIAM.

Configure AWS - GuardDuty Event Collector in Cortex

Parameter Description Required
AWS Default Region The AWS Region for this instance of the integration. For example, us-west-2 True
Role ARN The Amazon Resource Name (ARN) role used for EC2 instance authentication. If this is used, an access key and secret key are not required. False
Role Session Name A descriptive name for the assumed role session. For example, xsiam-IAM.integration-Role_SESSION False
Role Session Duration The maximum length of each session in seconds. Default: 900 seconds. The Cortex XSOAR integration will have the permissions assigned only when the session is initiated and for the defined duration. False
Access Key The access key ID used for authentication, that was configured during IAM user configuration. If this is used, Role ARN is not required. False
Secret Key The secret key used for authentication, that was configured during IAM user configuration. If this is used, Role ARN is not required. False
Timeout The time in seconds until a timeout exception is reached. You can specify just the read timeout (for example 60) or also the connect timeout preceded by a comma (for example 60,10). If a connect timeout is not specified, a default of 10 seconds will be used. False
Retries The maximum number of retry attempts when connection or throttling errors are encountered. Set to 0 to disable retries. The default value is 5 and the limit is 10. Note: Increasing the number of retries will increase the execution time. False
First fetch time First fetch query <number> <time unit>, e.g., 7 days. Default 3 days. False
Number of events to fetch per fetch. Default is 10. False
Guard Duty Severity level The severity level or higher of findings to be fetched: Low, Medium, or High. For example, if you set the severity level to Medium, only findings with severity level Medium or High will be fetched. True
Trust any certificate (not secure)   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.

aws-gd-get-events


Manual command to fetch events and display them.

Base Command

aws-gd-get-events

Input

Argument Name Description Required
should_push_events Set this argument to True to create events, otherwise the command will only display them. Possible values are: true, false. Default is false. Required
severity The minimum severity of the events to fetch (inclusive). Possible values are: Low, Medium, High. Default is Low. Required
collect_from The date to start collecting the events from. Optional
limit The maximum amount of events to return. Optional

Context Output

There is no context output for this command.

Command example

!aws-gd-get-events severity=Low should_push_events=false limit=1 collect_from="60 days ago"

Human Readable Output

AWSGuardDuty Logs
Account Id Arn Created At Description Id Partition Region Resource Schema Version Service Severity Title Type Updated At
SomeAccountId SomeArn 2022-08-16T07:22:39.877Z A container with a sensitive host path mounted inside was launched on EKS Cluster SomeFindingEKSClusterName. If this behavior is not expected, it may indicate that your credentials are compromised. aws EksClusterDetails: {“Name”: “SomeFindingEKSClusterName”, “Arn”: “SomeFindingEKSClusterArn”, “VpcId”: “SomeFindingEKSClusterVpcId”, “Status”: “ACTIVE”, “Tags”: [{“Key”: “SomeFindingEKSClusterTag1”, “Value”: “SomeFindingEKSClusterTagValue1”}, {“Key”: “SomeFindingEKSClusterTag2”, “Value”: “SomeFindingEKSClusterTagValue2”}, {“Key”: “SomeFindingEKSClusterTag3”, “Value”: “SomeFindingEKSClusterTagValue3”}], “CreatedAt”: “2021-11-11T10:15:55.218000”}
KubernetesDetails: {“KubernetesUserDetails”: {“Username”: “SomeFindingUserName”, “Uid”: “SomeFindingUID”, “Groups”: [“SomeFindingUserGroup”]}, “KubernetesWorkloadDetails”: {“Name”: “SomeFindingKubernetesWorkloadName”, “Type”: “SomeFindingKubernetesWorkloadType”, “Uid”: “SomeFindingKubernetesWorkloadUID”, “Namespace”: “SomeFindingKubernetesWorkloadNamespace”, “Containers”: [{“Name”: “SomeFindingContainerName”, “Image”: “SomeFindingContainerImage”, “ImagePrefix”: “SomeFindingContainerImagePrefix”, “VolumeMounts”: [{“Name”: “SomeFindingVolumeName”, “MountPath”: “SomeFindingVolumeMountPath”}]}], “Volumes”: [{“Name”: “SomeFindingVolumeName”, “HostPath”: {“Path”: “SomeFindingHostPath”}}]}}
ResourceType: EKSCluster
2.0 Action: {“ActionType”: “KUBERNETES_API_CALL”, “KubernetesApiCallAction”: {“RequestUri”: “SomeFindingRequestURI”, “Verb”: “create”, “UserAgent”: “”, “RemoteIpDetails”: {“City”: {“CityName”: “SomeFindingCityName”}, “Country”: {“CountryName”: “SomeFindingCountryName”}, “GeoLocation”: {“Lat”: 0, “Lon”: 0}, “IpAddressV4”: “1.1.1.1”, “Organization”: {“Asn”: “0”, “AsnOrg”: “SomeFindingASNOrg”, “Isp”: “SomeFindingISP”, “Org”: “SomeFindingORG”}}, “StatusCode”: 201}}
Archived: true
Count: 1
DetectorId: detectorid
EventFirstSeen: 2022-08-16T07:22:39.000Z
EventLastSeen: 2022-08-16T07:22:39.000Z
ResourceRole: TARGET
ServiceName: guardduty
AdditionalInfo: {“Value”: “{"sample":true}”, “Type”: “default”}
5 Container launched with a sensitive host path mounted inside. Persistence:Kubernetes/ContainerWithSensitiveMount 2022-08-16T07:22:39.877Z

Configuration parameters

  • defaultRegion — AWS Default Region (required)
  • roleArn — Role ARN
  • roleSessionName — Role Session Name
  • sessionDuration — Role Session Duration
  • credentials — Access Key
  • timeout — Timeout
  • retries — Retries
  • endpoint_url — PrivateLink service URL.
  • sts_endpoint_url — STS PrivateLink URL.
  • sts_regional_endpoint — AWS STS Regional Endpoints
  • first_fetch — First fetch time
  • limit — Number of events to fetch per fetch.
  • gd_severity — Guard Duty Severity level (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • exclude_archived — Exclude archived/suppressed findings
  • isFetchEvents — Fetch Events
  • eventFetchInterval — Events Fetch Interval

Commands (1)

  • aws-gd-get-events

    Manual command used to fetch events and display them.

import json
from datetime import date, datetime, timedelta, UTC
from typing import TYPE_CHECKING

import demistomock as demisto  # noqa: F401
from AWSApiModule import *  # noqa: E402
from CommonServerPython import *  # noqa: F401

# The following import are used only for type hints and autocomplete.
# It is not used at runtime, and not exist in the docker image.
if TYPE_CHECKING:
    from mypy_boto3_guardduty import GuardDutyClient


CLIENT_SERVICE = "guardduty"
MAX_IDS_PER_REQ = 50
MAX_RESULTS = 50
GD_SEVERITY_DICT = {"Low": 1, "Medium": 4, "High": 7}

# A fully-deduped cursor boundary second is only escaped once it is
# at least this far in the past relative to the fetch start time. This keeps a
# still-current second pinned (so a genuine later same-second update is still
# catchable) while breaking a boundary that has clearly gone stale.
STALE_BOUNDARY_MARGIN = timedelta(minutes=2)

PRODUCT = "guardduty"
VENDOR = "aws"


class DatetimeEncoder(json.JSONEncoder):
    """Json encoder class for encoding datetime objects. Use with json.dumps method."""

    def default(self, obj):  # pylint: disable=W9014
        if isinstance(obj, datetime | date):
            return obj.strftime("%Y-%m-%dT%H:%M:%S.%f")
        return json.JSONEncoder.default(self, obj)


def convert_events_with_datetime_to_str(events: list) -> list:
    """Convert datetime fields in events to string.

    Args:
        events (list): Events received from AWS python SDK with datetime in certain fields.

    Returns:
        events (list): Events with dates as strings only.
    """
    output_events = []
    for event in events:
        # Encode the datetime fields of the event to str using json dumps.
        output = json.dumps(event, cls=DatetimeEncoder)
        # Load the event with datetime fields converted to str.
        output_events.append(json.loads(output))
    return output_events


def _normalize_last_ids_entry(value) -> set[str]:
    """Coerce a stored ``last_ids`` value into a set of ids.

    The integration historically stored ``last_ids[detector_id]`` as a single
    string (the last finding id seen). We now track every
    finding id sharing the cursor's ``UpdatedAt`` second, which means the
    value is conceptually a set. ``demisto.setLastRun`` serializes as JSON,
    so the on-disk representation must be a ``list``. This helper normalizes
    all three legacy / current shapes into a ``set[str]``:

        * ``str`` → ``{value}``                  (legacy state from <1.3.67)
        * ``list`` / ``tuple`` → ``set(value)``  (rehydrated from setLastRun)
        * ``set`` → ``set(value)``               (in-memory)
        * ``None`` / ``""`` / falsy → ``set()``

    Anything else logs a warning and falls back to an empty set so a single
    bad cache entry never blocks a fetch cycle.
    """
    if not value:
        return set()
    if isinstance(value, str):
        return {value}
    if isinstance(value, list | tuple | set):
        return {item for item in value if isinstance(item, str)}
    demisto.debug(f"AWSGuardDutyEventCollector - Unexpected last_ids value type {type(value).__name__}; treating as empty.")
    return set()


def _build_finding_criterion(updated_at: Optional[datetime], severity: str, exclude_archived: bool) -> dict:
    """Build the ``FindingCriteria.Criterion`` dict for ``list_findings``.

    Args:
        updated_at: Inclusive lower bound on ``updatedAt``.
        severity: Minimum severity label (Low/Medium/High).
        exclude_archived: When ``True``, adds ``service.archived = false`` so suppressed/archived
            findings are not re-fetched.

    Returns:
        The criterion dict.
    """
    criterion: dict = {
        "updatedAt": {"Gte": date_to_timestamp(updated_at)},
        "severity": {"Gte": GD_SEVERITY_DICT.get(severity, 1)},
    }
    if exclude_archived:
        # GuardDuty represents the archived flag as the string "false"/"true" in FindingCriteria.
        criterion["service.archived"] = {"Eq": ["false"]}
    return criterion


def _event_updated_at(event: dict) -> Any:
    """Return the timestamp used as the fetch cursor for a single finding."""
    return event.get("UpdatedAt", event.get("CreatedAt"))


def _cursor_second(value: Any) -> datetime:
    """Return the whole-second (microseconds truncated), naive-UTC datetime for a timestamp.

    GuardDuty findings carry MILLISECOND-precision ``UpdatedAt``
    values, and several findings routinely share the same whole second while
    differing in their milliseconds. The fetch cursor and the same-second
    sibling dedup are defined at SECOND resolution,
    so all comparisons and grouping must truncate sub-second precision. Doing so
    keeps the cursor deterministic (independent of AWS ``get_findings`` ordering)
    and lets every finding sharing the cursor second be recognized as an
    already-seen sibling on the inclusive ``Gte`` re-query.

    Timezone normalization: values reach this function from two sources with
    different awareness. ``first_fetch`` (via ``arg_to_datetime``) is tz-AWARE for
    absolute dates, while finding timestamps go through ``parse_date_string``
    (``strptime`` with no ``%z``) and are always NAIVE. Comparing the two (dedup
    equality, and the stale-boundary ``<`` check against ``datetime.utcnow()``,
    which is naive) would either never match (silent re-ingestion) or raise
    ``TypeError: can't compare offset-naive and offset-aware datetimes``. To make
    every cursor value consistently comparable, a tz-aware input is first
    CONVERTED to UTC and THEN stripped of ``tzinfo`` (order matters — dropping
    tzinfo before converting would shift, e.g., a ``+05:00`` cursor by 5 hours).

    Args:
        value: A timestamp string or a ``datetime`` (naive or tz-aware). Every
            GuardDuty finding carries a real ``UpdatedAt``/``CreatedAt``, so a
            value is always present.

    Returns:
        The corresponding naive-UTC ``datetime`` truncated to whole seconds.

    Raises:
        ValueError: If ``value`` is not a parseable timestamp. This is not
            expected from real GuardDuty data and surfaces loudly rather than
            silently corrupting the fetch cursor.
    """
    dt = value if isinstance(value, datetime) else parse_date_string(value)
    if dt.tzinfo is not None:
        # Convert to UTC first, then drop tzinfo so the result is naive-UTC.
        dt = dt.astimezone(UTC).replace(tzinfo=None)
    return dt.replace(microsecond=0)


def get_events(
    aws_client: "GuardDutyClient",
    collect_from: dict,
    collect_from_default: Optional[datetime],
    last_ids: dict,
    severity: str,
    limit: int = MAX_RESULTS,
    detectors_num: int = MAX_RESULTS,
    max_ids_per_req: int = MAX_IDS_PER_REQ,
    exclude_archived: bool = False,
) -> tuple[list, dict, dict]:
    """Get events from AWSGuardDuty.

    Args:
        aws_client: AWSClient session to get events from.
        collect_from: Dict of {detector_id: datestring to start collecting from}, used when fetching.
        collect_from_default: datetime to start collecting from if detector id is not found in collect_from keys.
        last_ids: Dict of {detector_id: <ids seen at the cursor second>}, used to avoid duplicates and to
            prevent same-second sibling loss. Each value may be a ``set``, ``list``, ``tuple``, or — for
            backwards compatibility with state written by integration versions <1.3.67 — a single ``str``.
            All shapes are normalized to ``set[str]`` internally.
        severity: The minimum severity to start fetching from. (inclusive)
        limit: The maximum number of events to fetch.
        detectors_num: The maximum number of detectors to fetch.
        max_ids_per_req: The maximum number of findings to get per API request.
        exclude_archived: When ``True``, archived/suppressed findings are excluded from the fetch.

    Returns:
        (events, new_last_ids, new_collect_from)
        events (list): The events fetched.
        new_last_ids (dict): The new last_ids dict, expected to receive as last_ids input in the next run.
            Each value is a ``list[str]`` (JSON-serializable for setLastRun).
        new_collect_from (dict): The new collect_from dict, expected to receive as collect_from input in the next run.

    Note: The fetch cursor is second-resolution and the ``updatedAt`` filter is inclusive
    (``Gte``). To avoid silently skipping findings, the cursor is NEVER advanced into a second that was
    only partially consumed because ``limit`` was reached. When a fetch is truncated mid-second the cursor
    is rolled back to the last fully-drained second (and its sibling ids are persisted) so the next run
    re-queries the truncated second from its start. This guarantees forward progress without data loss.
    """

    events: list = []
    detector_ids: list = []
    next_token = "starting_token"
    new_last_ids = last_ids.copy()
    new_collect_from = collect_from.copy()

    demisto.debug(f"AWSGuardDutyEventCollector Starting get_events. {collect_from=}, {collect_from_default=}, {last_ids=}")

    # List all detectors
    list_detectors_page = 0
    while next_token:
        list_detectors_page += 1
        list_detectors_args: dict = {"MaxResults": detectors_num}
        if next_token != "starting_token":
            list_detectors_args.update({"NextToken": next_token})

        response = aws_client.list_detectors(**list_detectors_args)
        page_detector_ids = response.get("DetectorIds", [])
        raw_next_token = response.get("NextToken", "")
        detector_ids += page_detector_ids
        # Diagnostic: log per-page enumeration so a truncated/short-circuited pagination is visible.
        demisto.debug(
            f"AWSGuardDutyEventCollector - list_detectors page {list_detectors_page}: "
            f"MaxResults={detectors_num}, returned={len(page_detector_ids)}, "
            f"running_total={len(detector_ids)}, has_next_token={bool(raw_next_token)}, "
            f"next_token_len={len(raw_next_token) if raw_next_token else 0}, "
            f"page_detector_ids={page_detector_ids}"
        )
        next_token = raw_next_token

    demisto.debug(
        f"AWSGuardDutyEventCollector - Finished list_detectors after {list_detectors_page} page(s). "
        f"Total detectors found: {len(detector_ids)}. Found detector ids: {detector_ids}"
    )

    for detector_id in detector_ids:
        demisto.debug(
            f"AWSGuardDutyEventCollector - Getting finding ids for detector id {detector_id}. "
            f"Collecting from {collect_from.get(detector_id, collect_from_default)}"
        )
        next_token = "starting_token"
        finding_ids: list = []
        detector_events: list = []
        updated_at = parse_date_string(collect_from.get(detector_id)) if collect_from.get(detector_id) else collect_from_default
        # Dedup against all ids seen at the cursor second.
        seen_ids = _normalize_last_ids_entry(last_ids.get(detector_id))
        # List all finding ids. The ``limit`` budget is applied PER DETECTOR so that a busy detector
        # earlier in the loop can never starve detectors processed later (each detector gets its own
        # full ``limit`` per cycle instead of sharing one global budget across all detectors).
        while next_token and len(set(finding_ids)) < limit:
            demisto.debug(f"AWSGuardDutyEventCollector - Getting more finding ids with {next_token=}, {updated_at=}")
            list_finding_args = {
                "DetectorId": detector_id,
                "FindingCriteria": {"Criterion": _build_finding_criterion(updated_at, severity, exclude_archived)},
                "SortCriteria": {"AttributeName": "updatedAt", "OrderBy": "ASC"},
                "MaxResults": min(limit - len(set(finding_ids)), MAX_RESULTS),
            }
            if next_token != "starting_token":
                list_finding_args.update({"NextToken": next_token})
            list_findings = aws_client.list_findings(**list_finding_args)
            finding_ids += list_findings.get("FindingIds", [])
            next_token = list_findings.get("NextToken", "")

        # Handle duplicates in response while preserving order
        finding_ids_unique = list(dict.fromkeys(finding_ids))
        demisto.debug(f"Detector id {detector_id} unique finding ids found: {finding_ids_unique}")
        # Get all relevant findings
        chunked_finding_ids = [
            finding_ids_unique[i : i + max_ids_per_req] for i in range(0, len(finding_ids_unique), max_ids_per_req)
        ]
        for chunk_of_finding_ids in chunked_finding_ids:
            demisto.debug(f"Getting {chunk_of_finding_ids=}")
            findings_response = aws_client.get_findings(DetectorId=detector_id, FindingIds=chunk_of_finding_ids)
            detector_events += findings_response.get("Findings", [])

        # Drop already-seen findings only when their UpdatedAt still shares the cursor second.
        # Findings whose UpdatedAt advanced past the cursor second are new events and are kept.
        # Compared at whole-second resolution to catch millisecond siblings of the same second.
        cursor_second = _cursor_second(updated_at)
        if seen_ids:
            before_ids = [ev.get("Id") for ev in detector_events]
            detector_events = [
                ev
                for ev in detector_events
                if ev.get("Id") not in seen_ids or _cursor_second(_event_updated_at(ev)) != cursor_second
            ]
            after_ids = [ev.get("Id") for ev in detector_events]
            if before_ids != after_ids:
                demisto.debug(
                    f"AWSGuardDutyEventCollector - Dedup removed already-seen same-second findings "
                    f"for {detector_id=}. Before: {before_ids}, after: {after_ids}, removed via {seen_ids=} "
                    f"at cursor second {cursor_second=}."
                )
            else:
                demisto.debug(
                    f"AWSGuardDutyEventCollector - Dedup ran for {detector_id=} at cursor second "
                    f"{cursor_second=} but removed nothing ({len(before_ids)} findings kept, "
                    f"{len(seen_ids)} seen ids carried in)."
                )

        demisto.debug(f"AWSGuardDutyEventCollector - {detector_id=} findings found ({len(detector_events)}): {detector_events}")
        events += detector_events
        demisto.debug(f"AWSGuardDutyEventCollector - Number of events is {len(events)}")

        # Advance the cursor. The cursor is second-resolution and the updatedAt query is inclusive (Gte),
        # so persist every finding id at the cursor second for next-run dedup, and roll the cursor back to
        # the last fully-drained second when a limit-truncated page leaves a partial second.
        truncated_by_limit = bool(next_token)  # pending token => stopped due to limit
        if detector_events:
            # Pick the cursor at whole-second resolution: latest ingested second, max UpdatedAt within it.
            event_seconds = [(ev, _cursor_second(_event_updated_at(ev))) for ev in detector_events]
            latest_second = max(sec for _, sec in event_seconds)
            cursor_second_target = latest_second
            if truncated_by_limit:
                # Find the latest whole-second strictly older than the last (partial) second.
                distinct_seconds = {sec for _, sec in event_seconds}
                fully_drained = sorted(s for s in distinct_seconds if s != latest_second)
                if fully_drained:
                    cursor_second_target = fully_drained[-1]
                    demisto.debug(
                        f"AWSGuardDutyEventCollector - Fetch truncated by limit for {detector_id=}. "
                        f"Rolling cursor back from partial second {latest_second} to last fully-drained "
                        f"second {cursor_second_target} to avoid skipping same-second siblings."
                    )
                else:
                    # The entire page is a single second that we could not fully drain. Keep the cursor
                    # on that second and accumulate seen ids so progress happens via dedup next run.
                    demisto.debug(
                        f"AWSGuardDutyEventCollector - Fetch truncated by limit for {detector_id=} within a "
                        f"single second {latest_second}; keeping cursor and accumulating seen ids."
                    )
            # Same-second siblings of the target second.
            cursor_second_events = [ev for ev, sec in event_seconds if sec == cursor_second_target]
            cursor_ts = max(_event_updated_at(ev) for ev in cursor_second_events)
            new_collect_from[detector_id] = cursor_ts
            cursor_sibling_ids = {ev.get("Id") for ev in cursor_second_events}
            cursor_sibling_ids.discard(None)
            # Carry forward prior seen ids when the cursor second did not advance past them.
            carried_forward = seen_ids and cursor_second_target == cursor_second
            if carried_forward:
                cursor_sibling_ids |= seen_ids
            demisto.debug(
                f"AWSGuardDutyEventCollector - Cursor advance for {detector_id=}: "
                f"boundary_second={cursor_second_target}, cursor_ts={cursor_ts!r}, "
                f"same_second_sibling_count={len(cursor_sibling_ids)}, {carried_forward=}."
            )
            # Stored as list so demisto.setLastRun can JSON-serialize it; round-trips via
            # _normalize_last_ids_entry on the next call.
            new_last_ids[detector_id] = sorted(cursor_sibling_ids)
        elif finding_ids:
            # Everything listed deduped away as already-seen same-second siblings. If the fetch was not
            # limit-truncated and the boundary second is safely in the past, step the cursor one second
            # past it and clear seen ids to avoid a permanent stall; a recent boundary is left pinned.
            now = datetime.utcnow()
            boundary_is_stale = cursor_second < (now - STALE_BOUNDARY_MARGIN)
            if not truncated_by_limit and boundary_is_stale:
                next_second = cursor_second + timedelta(seconds=1)
                new_collect_from[detector_id] = next_second.isoformat()
                new_last_ids[detector_id] = []
                demisto.debug(
                    f"AWSGuardDutyEventCollector - Fully-deduped STALE boundary for {detector_id=}: "
                    f"{len(finding_ids)} finding id(s) listed but all deduped, nothing newer, and the "
                    f"boundary second {cursor_second} is older than {STALE_BOUNDARY_MARGIN} before "
                    f"now ({now}). Advancing cursor to {next_second.isoformat()} and clearing seen ids "
                    f"to break the stall."
                )
            else:
                # Bounded pause: keep prior seen ids for a still-current or limit-truncated boundary.
                new_last_ids[detector_id] = sorted(seen_ids) if seen_ids else []
                demisto.debug(
                    f"AWSGuardDutyEventCollector - No new events for {detector_id=}: {len(finding_ids)} finding "
                    f"id(s) were listed but all were deduped as already-seen. Cursor left unchanged "
                    f"({truncated_by_limit=}, {boundary_is_stale=}); {len(seen_ids)} seen id(s) carried forward."
                )
        else:
            # Nothing matched the query this cycle; cursor unchanged.
            demisto.debug(
                f"AWSGuardDutyEventCollector - No findings returned for {detector_id=} this cycle. " f"Cursor left unchanged."
            )

    demisto.debug(f"AWSGuardDutyEventCollector - Total number of events is {len(events)}")
    # Log the outgoing cursor value/type and per-detector last_ids counts before persisting.
    demisto.debug(
        "AWSGuardDutyEventCollector - Persisting state. "
        f"new_collect_from={ {k: f'{v!r} ({type(v).__name__})' for k, v in new_collect_from.items()} }, "
        f"new_last_ids_counts={ {k: len(v) for k, v in new_last_ids.items()} }"
    )
    events = convert_events_with_datetime_to_str(events)
    return events, new_last_ids, new_collect_from


def main():  # pragma: no cover
    params = demisto.params()
    aws_default_region = params.get("defaultRegion")
    aws_role_arn = params.get("roleArn")
    aws_role_session_name = params.get("roleSessionName")
    aws_role_session_duration = params.get("sessionDuration")
    aws_role_policy = None
    aws_access_key_id = params.get("credentials", {}).get("identifier")
    aws_secret_access_key = params.get("credentials", {}).get("password")
    verify_certificate = not params.get("insecure", True)
    timeout = params.get("timeout") or 1
    retries = params.get("retries") or 5
    aws_gd_severity = params.get("gd_severity", "")
    first_fetch = arg_to_datetime(params.get("first_fetch"))
    limit = arg_to_number(params.get("limit"))
    sts_endpoint_url = params.get("sts_endpoint_url") or None
    endpoint_url = params.get("endpoint_url") or None
    exclude_archived = argToBoolean(params.get("exclude_archived", False))

    try:
        validate_params(aws_default_region, aws_role_arn, aws_role_session_name, aws_access_key_id, aws_secret_access_key)

        # proxy is being handled in AWSClient.
        aws_client = AWSClient(
            aws_default_region,
            aws_role_arn,
            aws_role_session_name,
            aws_role_session_duration,
            aws_role_policy,
            aws_access_key_id,
            aws_secret_access_key,
            verify_certificate,
            timeout,
            retries,
            sts_endpoint_url=sts_endpoint_url,
            endpoint_url=endpoint_url,
        )

        client: GuardDutyClient = aws_client.aws_session(service=CLIENT_SERVICE, region=aws_default_region)

        command = demisto.command()
        if command == "test-module":
            get_events(
                aws_client=client,
                collect_from={},
                collect_from_default=first_fetch,
                last_ids={},
                severity=aws_gd_severity,
                limit=1,
                detectors_num=1,
            )
            return_results("ok")

        elif command == "aws-gd-get-events":
            collect_from = arg_to_datetime(demisto.args().get("collect_from", params.get("first_fetch")))
            severity = demisto.args().get("severity", aws_gd_severity)
            command_limit = arg_to_number(demisto.args().get("limit", limit))
            events, new_last_ids, _ = get_events(
                aws_client=client,
                collect_from={},
                collect_from_default=collect_from,
                last_ids={},
                severity=severity,
                limit=command_limit if command_limit else MAX_RESULTS,
                exclude_archived=exclude_archived,
            )

            command_results = CommandResults(
                readable_output=tableToMarkdown("AWSGuardDuty Logs", events, headerTransform=pascalToSpace),
                raw_response=events,
            )
            return_results(command_results)

            if argToBoolean(demisto.args().get("should_push_events", "true")):
                send_events_to_xsiam(events, VENDOR, PRODUCT)

        elif command == "fetch-events":
            last_run = demisto.getLastRun()
            collect_from_dict = last_run.get("collect_from", {})
            last_ids = last_run.get("last_ids", {})

            events, new_last_ids, new_collect_from_dict = get_events(
                aws_client=client,
                collect_from=collect_from_dict,
                collect_from_default=first_fetch,
                last_ids=last_ids,
                severity=aws_gd_severity,
                limit=limit if limit else MAX_RESULTS,
                exclude_archived=exclude_archived,
            )

            send_events_to_xsiam(events, VENDOR, PRODUCT)
            demisto.setLastRun({"collect_from": new_collect_from_dict, "last_ids": new_last_ids})

        else:
            raise NotImplementedError(f"Command {command} is not implemented.")

    except Exception as e:
        return_error(f"Failed to execute {demisto.command()} command in AWSGuardDutyEventCollector.\nError:\n{e!s}")


if __name__ in ("__main__", "__builtin__", "builtins"):  # pragma: no cover
    main()