April 2, 2026

Part 3 — GuardDuty Threat Detection & Automated Triage

AgentSOAR can now see your AWS threat landscape. This post covers the GuardDuty integration: four agent-callable tools, an EventBridge ingestion pipeline, and the design decisions along the way.

AWS prerequisites

The Blanxlait org has four accounts: management (myMainAWSAccount), Security, AI (where AgentSOAR is deployed), and Log Archive. All of the setup below was performed interactively via the AWS CLI — nothing is baked into the CDK stack because these are one-time org-level configurations that live outside any single application’s lifecycle.

1 — Enable GuardDuty org-wide

GuardDuty must be enabled in every account/region you want findings from. The recommended pattern for an AWS Organization is a delegated administrator account — a dedicated Security account that aggregates findings from all members.

The Blanxlait org already had the Security account designated as the GuardDuty delegated admin and GuardDuty enabled in the Security account with a detector. What was missing was member enrollment and auto-enable for future accounts.

If you’re starting from scratch, run these in the management account first:

aws organizations enable-aws-service-access \
  --service-principal guardduty.amazonaws.com \
  --profile management-admin

aws guardduty enable-organization-admin-account \
  --admin-account-id <SECURITY_ACCOUNT_ID> \
  --profile management-admin

Then in the Security (delegated admin) account, enable auto-enrollment so every current and future member account gets GuardDuty enabled automatically:

DETECTOR=$(aws guardduty list-detectors --query 'DetectorIds[0]' --output text)

# Auto-enroll future accounts
aws guardduty update-organization-configuration \
  --detector-id $DETECTOR \
  --auto-enable-organization-members ALL

# Enroll existing accounts (one-time; auto-enable only covers future accounts)
aws guardduty create-members \
  --detector-id $DETECTOR \
  --account-details \
    AccountId=<AI_ACCOUNT_ID>,Email=<email> \
    AccountId=<LOG_ACCOUNT_ID>,Email=<email> \
    AccountId=<MGMT_ACCOUNT_ID>,Email=<email>

With this in place, findings from every member account aggregate to the Security account. The get_guardduty_findings Lambda must run there (or use cross-account API calls) to see the full picture — see the section below on cross-account EventBridge if AgentSOAR lives in a different account.

2 — Finding aggregation across regions

GuardDuty findings are regional. If you run workloads in multiple regions you have two options:

3 — Cross-account EventBridge forwarding (delegated admin pattern)

This is the most important setup step if AgentSOAR is deployed in a different account than your GuardDuty delegated admin (Security) account.

Why it matters: GuardDuty publishes GuardDuty Finding events to EventBridge in the account where findings are aggregated — the Security account. The GuardDutyFindingsRule created by AgentSOAR’s CDK stack lives in the AgentSOAR (AI) account and will never fire unless findings are forwarded across accounts first.

The pattern:

Security account                          AgentSOAR (AI) account
────────────────────────────────          ──────────────────────────────
GuardDuty Finding event                   Default event bus
  │                                         ↑  resource policy grants
  ▼                                         │  Security account PutEvents
EventBridge rule                            │
  forward-guardduty-to-agentsoar ──────────►│
  via EventBridgeCrossAccountToAgentSOAR    │

                                       GuardDutyFindingsRule  (CDK-managed)


                                       GuardDuty tool Lambda

These three resources were created once via CLI — they live outside the CDK stack because they span account boundaries.

Step 1 — Resource policy on the AgentSOAR event bus (run in the AI account):

aws events put-permission \
  --event-bus-name default \
  --action events:PutEvents \
  --principal <SECURITY_ACCOUNT_ID> \
  --statement-id AllowGuardDutyForwardingFromSecurity \
  --region us-east-1 \
  --profile blanxlait-ai

Step 2 — IAM role in the Security account for EventBridge to assume when forwarding:

# Trust policy
aws iam create-role \
  --role-name EventBridgeCrossAccountToAgentSOAR \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"Service": "events.amazonaws.com"},
      "Action": "sts:AssumeRole"
    }]
  }'

# Permissions policy
aws iam put-role-policy \
  --role-name EventBridgeCrossAccountToAgentSOAR \
  --policy-name PutEventsToAgentSOAR \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": "events:PutEvents",
      "Resource": "arn:aws:events:us-east-1:<AI_ACCOUNT_ID>:event-bus/default"
    }]
  }'

Step 3 — Forwarding rule in the Security account:

aws events put-rule \
  --name forward-guardduty-to-agentsoar \
  --event-pattern '{"source":["aws.guardduty"],"detail-type":["GuardDuty Finding"]}' \
  --state ENABLED \
  --region us-east-1

aws events put-targets \
  --rule forward-guardduty-to-agentsoar \
  --region us-east-1 \
  --targets '[{
    "Id": "AgentSOARDefaultBus",
    "Arn": "arn:aws:events:us-east-1:<AI_ACCOUNT_ID>:event-bus/default",
    "RoleArn": "arn:aws:iam::<SECURITY_ACCOUNT_ID>:role/EventBridgeCrossAccountToAgentSOAR"
  }]'

Once all three are in place, findings land on the AgentSOAR default bus within seconds. The GuardDutyFindingsRule CDK construct needs no changes.

4 — IAM for cross-account API calls

The get_guardduty_findings Lambda calls the GuardDuty API. If findings are aggregated in the Security account, the Lambda must either:

Both patterns are supported. See the cross-account role assumption section below for the implementation used in Blanxlait’s setup.

What was added

Five gateway tools (gateway/tools/guardduty_tool/)

ToolWhat it does
get_guardduty_findingsList active (non-archived) findings filtered by severity, region, and account
describe_guardduty_findingHuman-readable explanation: type, severity, affected resource, actor geo
archive_guardduty_findingSuppress confirmed false positives or remediated findings
triage_guardduty_finding4-step automated playbook: classify → enrich → risk score → remediation steps
list_guardduty_findings_sinceQuery the local DynamoDB store for findings ingested in the last N hours

All five are backed by a single Lambda (guardduty_lambda.py) that the AgentCore Gateway routes to via a CfnGatewayTarget.

EventBridge ingestion and autonomous triage

An EventBridge rule on aws.guardduty / GuardDuty Finding feeds new findings directly into the Lambda in near-real time. The handler detects EventBridge invocations by checking event["source"] == "aws.guardduty" and routes them to a separate _handle_eventbridge() path — EventBridge does not set client_context, so it must be dispatched before the Gateway tool path is attempted.

When a finding arrives, _handle_eventbridge does three things:

  1. Stores the finding summary in a DynamoDB table (AgentSOAR-guardduty-findings) with a 30-day TTL and a time-based GSI for range queries.
  2. Invokes the AgentCore Runtime asynchronously with a triage prompt, so the agent runs the 4-step playbook without any human trigger.
  3. Returns 200 immediately — the agent invocation runs in a daemon thread within the Lambda’s execution window.

The DynamoDB store powers list_guardduty_findings_since, which lets the agent answer “what came in while I was away?” without hitting the live GuardDuty API.

Design decisions

Single Lambda, multiple tools. GuardDuty’s APIs are cohesive. Splitting them across four Lambdas would multiply cold-start overhead and IAM complexity for no gain.

Only active findings. get_guardduty_findings filters service.archived = false so the agent never acts on already-suppressed findings.

ArchiveFindings on a separate IAM statement. The read actions (ListDetectors, ListFindings, GetFindings) and the write action (ArchiveFindings) are in separate IAM policy statements scoped to arn:aws:guardduty:*:<account>:detector/*. This makes it easy to remove write access independently if needed, and avoids the overly-broad resources: ["*"] pattern.

Tool description warns before archiving. The archive_guardduty_finding tool description explicitly marks the action as destructive so the agent’s reasoning loop treats it with appropriate caution.

Triage output example

# Automated Triage Report — SSH brute force attack detected
Finding ID: abc123  |  Account: 123456789012  |  Region: us-east-1

## Step 1 — Classification
Finding type : UnauthorizedAccess:EC2/SSHBruteForce
Severity     : MEDIUM (5.0)

## Step 2 — Resource Enrichment
Resource type: Instance
  Instance ID : i-0abc123 (t3.micro, running)
Actor IP : 1.2.3.4 (Amsterdam, Netherlands / Example ISP)
Occurrences  : 3

## Step 3 — Risk Assessment
Adjusted risk score : 5.5 / 10
Risk factors        : repeated occurrences (>5)

## Step 4 — Recommended Actions
  1. Review the affected resource configuration and recent CloudTrail events.
  2. Verify the activity is authorized; escalate if it cannot be confirmed.
  3. Apply least-privilege principles to the implicated IAM entity.

End-to-end validation

The full pipeline was tested against live AWS infrastructure:

Lambda handler — directly invoked with an EventBridge-shaped payload; correctly detected source: aws.guardduty and routed to _handle_eventbridge rather than attempting to read client_context.

Cross-account EventBridge forwarding — verified independently by firing a custom-source event on the Security account’s default bus and confirming it arrived on the AgentSOAR account’s default bus within ~5 seconds, with account: 429971481640 (Security) preserved in the envelope.

Real sample findingscreate-sample-findings was called on the Security account detector with two finding types: UnauthorizedAccess:EC2/SSHBruteForce and Recon:EC2/PortProbeUnprotectedPort. Both appeared in CloudWatch logs confirming the full path:

GuardDuty (Security account)
  → EventBridge forward-guardduty-to-agentsoar rule (~4 min delay for sample findings)
  → AgentSOAR account default bus
  → GuardDutyFindingsRule
  → Lambda (6 ms execution, logged finding id + type + severity)

One thing to note: sample findings take ~4 minutes to generate an EventBridge event after create-sample-findings returns. Real findings are faster. Don’t mistake silence for a broken pipeline — check CloudWatch metrics before debugging the wiring.

Interactive triage via the UI — with the Gateway tools wired up and the correct runtime ARN deployed to Amplify, the agent can already answer GuardDuty questions interactively. Asking “do we have any GuardDuty findings?” returned 10 live org-wide findings — 1 CRITICAL, 4 HIGH, 5 MEDIUM — pulled from the Security account’s delegated admin detector via the cross-account role:

AgentSOAR UI showing 10 live GuardDuty findings by severity

The CRITICAL finding (AttackSequence:IAM/CompromisedCredentials) and the HIGH S3 deletion API anomaly are real behavioral detections against live org activity, not sample data.

What’s next

Cross-account role assumption pattern

A key architectural decision was where to run the GuardDuty Lambda. The options were:

We chose Option B. The Security account owns the data; AgentSOAR owns the automation. IAM bridges the gap. This is the right pattern for any AWS service where data lives in a different account than the application consuming it.

The reusable helper

_get_client(service, region, role_arn=None) in guardduty_lambda.py is designed to be copied to any future Lambda tool group:

def _get_client(service: str, region: str, role_arn: str | None = None) -> Any:
    if role_arn:
        creds = boto3.client("sts").assume_role(
            RoleArn=role_arn,
            RoleSessionName="agentsoar",
            ExternalId="agentsoar",   # confused-deputy protection
            DurationSeconds=900,
        )["Credentials"]
        return boto3.Session(
            aws_access_key_id=creds["AccessKeyId"],
            aws_secret_access_key=creds["SecretAccessKey"],
            aws_session_token=creds["SessionToken"],
        ).client(service, region_name=region)
    return boto3.client(service, region_name=region)

When SECURITY_ACCOUNT_ROLE_ARN is not set the Lambda falls back to its own execution role — same code works in single-account and multi-account deployments.

The target account role

One IAM role per target account (AgentSOARCrossAccountRole), created once via CLI:

# Trust policy — AI account with ExternalId guard
aws iam create-role \
  --role-name AgentSOARCrossAccountRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::<AI_ACCOUNT_ID>:root"},
      "Action": "sts:AssumeRole",
      "Condition": {"StringEquals": {"sts:ExternalId": "agentsoar"}}
    }]
  }'

# Add permissions for the services AgentSOAR needs to read
aws iam put-role-policy \
  --role-name AgentSOARCrossAccountRole \
  --policy-name GuardDutyRead \
  --policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": ["guardduty:ListDetectors","guardduty:ListFindings",
                 "guardduty:GetFindings","guardduty:ArchiveFindings"],
      "Resource": "arn:aws:guardduty:*:<SECURITY_ACCOUNT_ID>:detector/*"
    }]
  }'

Adding CloudTrail, Security Hub, or any other service to the pattern is two steps: add the actions to the role policy, add the corresponding _get_client call in the Lambda.

What the smoke test returned

With cross-account role assumption in place, get_guardduty_findings returned 17 org-wide findings from the Security account’s delegated admin detector — including real behavioral detections against CDK deploy roles and SSO sessions, plus the sample findings generated earlier. The full triage report ran successfully against a live finding in under 200ms end-to-end through the Gateway.