Skip to content

AWS GuardDuty & CloudWatch

This integration forwards AWS GuardDuty findings and CloudWatch alarm state changes into GRC-ITSM as Alert tickets. You deploy a small CloudFormation stack into your own AWS account: EventBridge rules capture the events, and a Lambda transforms each one into an alert and posts it to the platform's Upsert Alert webhook. The alert lands as a properly typed, categorized ticket that carries its detection context and maps to your compliance framework automatically.

Deploy the stack once per AWS account and region. Each stack maps to a single GRC-ITSM Site.

Quick Summary

  • The stack ships two EventBridge rules -- one for GuardDuty findings, one for CloudWatch alarm state changes -- a transform Lambda, and an SQS dead-letter queue. Each rule toggles independently.
  • Point the stack at your Upsert Alert webhook URL and the Site ID this AWS account maps to. One stack equals one Site.
  • Use oauth authentication in production, backed by a Halo API application and a secret in AWS Secrets Manager. See Webhook Authentication.
  • Treat the webhook URL as a credential: anyone who holds it can create tickets on your Site, so keep it out of source control, logs, and screenshots.
  • Tag your CloudWatch alarms with grc:category and grc:severity so each alarm maps to the right control and priority. GuardDuty findings are categorized automatically.
  • The webhook always returns 202 with an empty body, including on rejection. Delivery is not success -- confirm each alert by reading the ticket, not by the HTTP response.

Navigation

Configuration > Integrations on the GRC-ITSM website navigation, then Custom Integrations > Integration Runbooks > Upsert Alert for the delivery log.


How it works

Every event stays inside your AWS account until the Lambda posts the finished alert across your authorization boundary to the platform:

flowchart LR
    subgraph aws[Your AWS account and region]
        GD[GuardDuty findings]
        CW[CloudWatch alarm state changes]
        EB[EventBridge rules]
        L[Transform Lambda]
        DLQ[(SQS dead-letter queue)]
    end
    GD --> EB
    CW --> EB
    EB --> L
    L -.->|async retries exhausted| DLQ
    L ==>|POST to Upsert Alert webhook| H[GRC-ITSM Alert ticket]

What the stack creates:

  • An EventBridge rule for GuardDuty findings and one for CloudWatch alarm state changes, both on the account's default event bus. Each rule toggles independently.
  • A Python 3.12 Lambda that transforms each event into the flat alert payload and posts it to the Upsert Alert webhook.
  • An SQS dead-letter queue that captures async invocations the Lambda could not deliver.

Before you start

You need the following in place before deploying:

  • AWS CLI v2, with credentials for the target account and region.
  • An S3 bucket in that account, to hold the packaged Lambda artifact.
  • The Upsert Alert webhook URL for your GRC-ITSM tenant. See Sending Alerts for the webhook contract.
  • The Site ID this AWS account maps to.
  • A resolved status ID for alerts (default 9, Closed -- see Parameters).
  • For authenticated delivery in production: a Halo API application (Configuration > Integrations > Halo API, auth method Client ID and Secret), with its client ID and secret stored in AWS Secrets Manager. See Webhook Authentication.

Choose an authentication mode

The stack supports three delivery authentication modes through the AuthMode parameter:

AuthMode Use for Secret in Secrets Manager
none Local testing only. Requires the runbook set to No Authentication, which the Authentication page advises against on a live tenant Not used
bearer A static bearer token {"bearer_token":"..."}
oauth Production. Halo client-credentials {"client_id":"...","client_secret":"..."}

For oauth, set HaloAuthUrl to https://<tenant>/auth/token. The Lambda requests a token with grant_type=client_credentials and scope=all (override the scope with HaloScope), caches it for roughly an hour, and refreshes on a 401.

Treat the webhook URL as a credential

Anyone who holds the automation webhook URL can create tickets on your Site, so treat it like a secret and keep it out of source control, logs, and screenshots.

Before going live, open the Upsert Alert runbook (Configuration > Integrations > Custom Integrations > Integration Runbooks > Upsert Alert, Details tab) and confirm its Authentication method matches what your stack presents: use oauth for production, and never run AuthMode=none against a tenant that holds compliance evidence. See Webhook Authentication.

Create the oauth secret with:

aws secretsmanager create-secret --name halo-alert-oauth --secret-string '{"client_id":"<id>","client_secret":"<secret>"}'
If your secret is encrypted with a customer-managed KMS key

The stack's Lambda role is granted secretsmanager:GetSecretValue only. If TokenSecretArn points at a secret encrypted with a customer-managed KMS key (CMK), add a kms:Decrypt grant for the Lambda role on that key, or the Lambda cannot read the secret and delivery fails.


Deploy the stack

Package the Lambda artifact into your S3 bucket, then deploy:

aws cloudformation package --template-file template.yaml --s3-bucket <your-artifact-bucket> --output-template-file packaged.yaml
aws cloudformation deploy --template-file packaged.yaml --stack-name grcitsm-aws-alerts --capabilities CAPABILITY_IAM --parameter-overrides WebhookUrl='https://<tenant>/api/automation/<guid>' SiteId=<halo-site-id> AuthMode=oauth TokenSecretArn='arn:aws:secretsmanager:<region>:<acct>:secret:halo-alert-oauth-xxxx' HaloAuthUrl='https://<tenant>/auth/token'

template.yaml is the CloudFormation template delivered with the stack; packaged.yaml is the output that package produces and deploy consumes.


Parameters

Parameter Default Notes
WebhookUrl (required) Upsert Alert webhook URL
SiteId (required) GRC-ITSM Site ID for this account (one stack equals one Site)
AuthMode none none, bearer, or oauth. Use oauth in production
TokenSecretArn "" Secrets Manager ARN (see authentication modes). Blank for none
HaloAuthUrl "" oauth only: the token endpoint
HaloScope all oauth only: the requested scope
DefaultPriority 3 Fallback priority (1 Critical, 2 High, 3 Medium, 4 Low)
ResolvedStatusId 9 Status set when a CloudWatch alarm returns to OK. 9 is Closed; the Alert workflow rejects the New to Resolved (8) transition
MinSeverity 4.0 GuardDuty severity floor; lower findings are dropped (Low 1.0, Medium 4.0, High 7.0)
DropSampleFindings true Drop GuardDuty sample findings
EnableGuardDuty true Toggle the GuardDuty rule
EnableCloudWatch true Toggle the CloudWatch rule

Tag your CloudWatch alarms

GuardDuty findings carry their own severity and finding type, so the Lambda categorizes them without help. CloudWatch alarm namespaces are defined by whoever built the alarm, so the Lambda cannot always tell what an alarm means. Tag your alarms and the Lambda uses the tags directly:

Tag Value Effect
grc:category An exact category path, e.g. Alerts and Incidents>Security>Audit Log Failure Authoritative. Overrides the namespace heuristic
grc:severity 1 to 4 Maps to priority (1 Critical, 4 Low). When absent, not an integer, or outside 1 to 4, the Lambda uses DefaultPriority

Where an alarm carries no grc:category tag, the Lambda derives the category from a namespace and metric heuristic in the bundled mapping.json. Where the heuristic cannot classify the alarm, it falls back to the Alerts and Incidents>Availability parent. Tagging matters most for CloudTrail and CIS metric-filter alarms, which the heuristic cannot classify on its own.

Category paths must match the allowlist exactly

The allowed category paths are listed in the bundled mapping.json (the alert_category_allowlist), and they match the Alert ticket type's category tree. The webhook rejects an unlisted category outright (code 51104) and creates no ticket, so the Lambda checks the grc:category tag against the allowlist first: a tag value that is not on the list is ignored, a warning is written to the Lambda's CloudWatch log, and the alarm files under the namespace heuristic instead. Use the paths exactly as written, with > separators and no spaces around them. See Sending Alerts for the full list.


How tickets behave

The two sources map onto the Alert ticket differently, because a GuardDuty finding and a CloudWatch alarm mean different things over time:

Source Dedup key (external_id) Lifecycle
GuardDuty The finding ID The first event creates the ticket. Each later update to the same finding enriches it and adds a running note
CloudWatch The alarm ARN (perpetual) ALARM opens or reopens the ticket. OK closes it (using ResolvedStatusId) and adds a note. A later ALARM reopens the same ticket

An OK for an alarm that has no prior ticket creates nothing; the runbook log records it as a 51101 rejection, which is expected.

Facts versus lifecycle

The integration sets the alert's facts (summary, details, category, priority, indicators) from the source on every post. It does not set status on GuardDuty updates, so a re-firing finding preserves whatever triage status an analyst has set.

Priority reflects the source severity and is re-asserted on every GuardDuty update, so the next update to a finding can overwrite a manual priority change. Control priority with MinSeverity or the alarm's grc:severity tag rather than by editing the ticket.

Edge cases

Scenario Behavior
An EventBridge retry re-delivers the same event The alert is re-posted safely (same external_id), but the note is additive, so a retry can add a duplicate note. The design accepts this
An out-of-order or retried ALARM arrives after the OK Because the design is stateless, it can reopen a now-healthy alarm's ticket. Close it manually if you see it
A GuardDuty finding scores below MinSeverity Dropped. No ticket
A GuardDuty sample finding, with DropSampleFindings=true Dropped. No ticket

Verify the setup

Because the webhook returns 202 with an empty body on every request, rejections included, the HTTP response tells you nothing about whether a ticket exists. Confirm each alert from the platform side:

  1. Read the Alert ticket in the platform. Search for the summary, or filter by category.
  2. If a ticket is missing or wrong, read the delivery log. Open Configuration > Integrations > Custom Integrations > Integration Runbooks > Upsert Alert > Log and read the failed step's response. The rejection codes:

    Code Rejected because
    51101 summary missing on create (on an enrichment post it is inherited from the existing ticket)
    51102 Neither site_id nor system_name supplied
    51103 No Site matched
    51104 Category not permitted on the Alert type
    51105 The create step returned no ticket ID
    51106 A note was supplied but no ticket ID was available
  3. Watch the SQS dead-letter queue. Messages there mean the Lambda exhausted its async retries -- a bad URL or token, or a 5xx from the platform. Fix the cause and redrive the queue.

Smoke test

Fire a synthetic event from each source and confirm the ticket:

aws guardduty create-sample-findings --detector-id <id> --finding-types <type>

The stack drops sample findings by default (DropSampleFindings=true). Set it to false to test the full path, then confirm the ticket, its category, and its priority.

aws cloudwatch set-alarm-state --alarm-name <name> --state-value ALARM --state-reason test
aws cloudwatch set-alarm-state --alarm-name <name> --state-value OK --state-reason test

Confirm the ticket opens on ALARM and closes on OK, with a note for each, on the same ticket.


Current limitations

  • One alert per request. The endpoint rejects JSON arrays. The stack sends one alert per event, so this does not affect normal operation.
  • Closed, not Resolved, on recovery. The Alert workflow does not accept the New to Resolved (8) transition, so a CloudWatch OK closes the ticket with status 9 (Closed) by default. If you change ResolvedStatusId, choose a status the Alert workflow accepts.

This is an external connection

This stack runs in your AWS account and posts across your authorization boundary into the platform, so it is a data flow that must be documented and assessed. Document the interconnection in the System Security Plan, including what alert content leaves AWS and enters the platform, and confirm the AWS services involved carry an authorization appropriate to your environment before enabling it in a FedRAMP or CUI boundary. See the Integrations overview for the full pre-enablement checklist.

Related documentation