The Detection Layer That Was Quietly Off: GuardDuty Disabled on an AWS Account
✓ Human-authored analysis; AI used for formatting and proofreading. A researcher looked at an AWS account and asked a question most don't run as part of a configuration audit: is the threat-detection layer actually on?
✓ Human-authored analysis; AI used for formatting and proofreading.
A researcher looked at an AWS account and asked a question most don't run as part of a configuration audit: is the threat-detection layer actually on? CloudTrail was logging. CloudWatch metric filters were watching for unauthorised API calls. The SNS topic was wired up. But GuardDuty was disabled. This layer correlates those logs into "this looks like account compromise".
This is report HackerOne #3022516. The cost of the fix is one CLI call. The cost of operating without it is invisible until the day something goes wrong, at that point the absence of GuardDuty is the difference between an alert that arrives and an alert that never arrives.
What the configuration looked like
The misconfiguration is the kind a scanner finds boring because nothing is wrong with the resources you have. The wrong thing is the resource you don't have.
{
"audit_trail": {
"kind": "trail",
"multi_region_enabled": true
},
"monitoring": {
"kind": "account",
"metric_filters": {
"unauthorized_api_calls": { "exists": true }
},
"alarms": {
"unauthorized_api_calls": {
"exists": true,
"sns_topic_arn": "arn:aws:sns:us-east-1:123456789012:security-alerts"
}
}
},
"threat_detection": {
"enabled": false
}
}
CloudTrail: multi-region, ✓
Metric filter: present, ✓
Alarm with SNS topic: configured, ✓
GuardDuty: off
The first three lull every checklist scanner into reporting a good account. The fourth is invisible to dashboards that only check the resources you've already created.
Why the layers matter together
The detection stack on AWS is composed, not redundant. CloudTrail records what happened. CloudWatch metric filters surface specific patterns in those records. GuardDuty is the one that asks "do the records, together, look like reconnaissance, instance compromise, or credential abuse?" The three layers each do something the others can't:
- CloudTrail alone gives you a haystack. Every API call across every service. Storage and retention, but no signal.
- CloudWatch metric filters are pattern matches. They catch the purpose of the filter you wrote. A new attack technique that fires a different API call passes through.
- GuardDuty is the behavioural layer. It correlates VPC Flow Logs + CloudTrail + DNS query logs against known-bad indicators and statistical baselines. It detects the patterns the metric filter author didn't think to write.
When GuardDuty is off, the first two layers are recording without anyone watching. The audit trail will help you write the post-mortem many months after the breach, when you finally know to look. It will not help you catch the breach in progress.
Why a scanner misses this
Checklist scanners check resources. They iterate through your S3 buckets, IAM roles, security groups and CloudTrails. Each resource gets evaluated. Each setting on each resource gets flagged.
GuardDuty's disabled state is not a setting on a resource. It's the absence of a resource. In AWS terms, you have not created a aws_guardduty_detector. There is nothing for the scanner to iterate over. The scan completes successfully. The dashboard turns green. The detection layer is off.
This is the same shape as the no logging gap and the no backups gap: a scanner that only looks at what exists cannot tell you what should exist but doesn't. The correct question is "does the account have a detector?" A presence check, not an attribute check. Most CSPM tools added detector-presence rules eventually, but they are special-case logic, not the default scanner shape.
The system invariant
The invariant:
An AWS account must have GuardDuty enabled with at least one detector.
This is the entire control. The cost of enforcing it is one boolean check per account per snapshot. The cost of not enforcing it is invisible until something fires that GuardDuty would have caught.
What Stave models
Stave projects the account asset with three sub-blocks: audit_trail (CloudTrail), monitoring (CloudWatch metric filters + alarms), and threat_detection (GuardDuty). Three independent presence checks, three independent controls.
The GuardDuty control is small:
id: CTL.GUARDDUTY.ENABLED.001
name: Amazon GuardDuty Must Be Enabled
severity: high
compliance:
soc2: "CC7.1"
pci_dss_v4.0: "5.2"
nist_800_53_r5: "SI-3"
iso_27001_2022: "A.8.16"
unsafe_predicate:
all:
- field: properties.threat_detection.enabled
op: eq
value: false
attack_stage: detection_evasion in the params block tells the chain engine that this control's failure is in the "blinding the defender" lane. The same lane as CTL.CLOUDTRAIL.ENABLED.001 and CTL.CLOUDWATCH.MONITOR.UNAUTH.001. When two or three of these fire together, the chain engine produces a compound finding: the account has multiple detection layers off at the same time, which is the precondition for "we got breached and nobody saw it."
The finding
Run the fixture:
cd stave && make build
# The fixture ships two snapshots (T1 unsafe, T2 remediated). Evaluate the T1
# snapshot in isolation to see the finding the report describes; remove T1
# from the temp dir below to see the remediated state produce zero findings.
tmp=$(mktemp -d)
cp testdata/e2e/e2e-h1-aws-3022516/observations/2025-03-04T000000Z.json "$tmp/"
./stave apply \
--controls testdata/e2e/e2e-h1-aws-3022516/controls \
--observations "$tmp" \
--max-unsafe 168h \
--eval-time 2025-03-15T00:00:00Z \
--allow-unknown-input \
--format json | jq '.findings[0]'
{
"control_id": "CTL.GUARDDUTY.ENABLED.001",
"control_severity": "high",
"asset_id": "acct-123456789012",
"evidence": {
"misconfigurations": [
{
"property": "threat_detection.enabled",
"actual_value": false,
"operator": "eq",
"unsafe_value": false
}
]
},
"remediation": {
"description": "GuardDuty is not enabled. Threats are not being detected.",
"action": "Enable GuardDuty: aws guardduty create-detector --enable"
}
}
actual_value: false against unsafe_value: false — a one-field finding that names the property and the command to flip it. No ambiguity, severity-vs-likelihood debate or human-in-the-loop interpretation required.
The remediation
aws guardduty create-detector --enable
For an Organization-wide rollout, delegate GuardDuty administration once and enable for every member account through the delegated admin. The cost of enabling GuardDuty is the cost of the events it processes. Typically tens of dollars per account per month which is also the cost of having visibility into account compromise. Most organisations have already paid more than that in CloudTrail storage they aren't actively reading.
The preventive control is a Config Rule (or SCP, or AWS Organizations central-policy) that asserts every member account in the organisation has at least one active detector. The same rule, expressed as a Stave invariant, runs across every snapshot and produces a deterministic verdict at every snapshot timestamp.
The verification
After enabling:
aws guardduty list-detectors --query 'DetectorIds[0]' --output text
The detector ID is the proof of presence. Snapshot the account again; re-run stave apply; the finding disappears. The verification is the same shape as the detection. Both run against the same fact base, both produce the same deterministic verdict.
Why this case generalises
Most cloud security tooling is shaped around "what's wrong with the things you have." That bias misses an entire class of findings shaped around "what's missing from what you should have." GuardDuty disabled is one example. CloudWatch metric filter for unauthorised API calls absent is another. CloudTrail trail not multi-region is a third.
The unifying invariant is presence:
For every detective control your security program assumes, the resource that implements it must exist.
Stave checks this with three controls that share the same attack_stage: detection_evasion tag, so the chain engine surfaces the compound finding when more than one is true. The HackerOne report named one disabled layer. The compound case where two or three disabled at once is the breach scenario that costs $300M and takes nine months to detect.
The fixture for this report is testdata/e2e/e2e-h1-aws-3022516/ in the Stave repo. The control library that catches it is at stave/controls/guardduty/. The cost of running both, on every snapshot, in CI, with deterministic output, is almost zero.
How this relates to existing compliance mods
Framework-coverage tools like turbot/steampipe-mod-aws-compliance check GuardDuty's configuration thoroughly when it's enabled such as detector retention, finding-publishing frequency, S3 protection, EKS audit-log protection, all enumerated against CIS and AWS Foundational Security Best Practices benchmarks. What no per-resource framework check models well is the presence invariant: across every member account in the organization, does at least one active detector exist? Stave's three attack_stage: detection_evasion controls compose via the chain engine into the compound finding the H1 report describes. The moment two or three detection layers go dark together is when the breach window opens. Different shape, different tool; both run in Powerpipe side by side. The comparison is at
aws-compliance-mod.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.