Guide · AWS GuardDuty · Findings API
GuardDuty Findings API for MCP Servers — Querying, Filtering, Archiving
GuardDuty deduplicates findings: if the same threat type targets the same resource from the same source, GuardDuty updates the existing finding (incrementing service.count and updating service.eventLastSeen) rather than creating a new finding. This means a finding that appears once in ListFindings may represent hundreds of individual events — the count field is the true incident volume. For MCP server security automation, this deduplication has a critical implication for EventBridge-triggered remediation: the same finding ID fires an EventBridge event on every update cycle, not just on first detection. Your remediation Lambda must be idempotent — checking whether the isolation action has already been applied before re-applying it — because a brute-force attack that runs for hours will re-trigger the finding event every 15 minutes.
TL;DR
Use ListFindings with finding-criteria to filter by severity, type, and resource. Fetch details via GetFindings (max 50 per call). GuardDuty deduplicates: same type+resource+source = update existing finding, not new finding. Archive findings when actioned (not when reviewing). Use CreateFilter with ARCHIVE action for systematic suppression — filters run at generation time, preventing findings from ever appearing active. See the main GuardDuty setup guide for detector configuration and the remediation guide for automated response patterns.
ListFindings with filter criteria
ListFindings returns finding IDs (not finding details) matching the specified criteria. You must call GetFindings with the returned IDs to get the actual finding content. The maximum page size is 50 IDs per ListFindings call and 50 findings per GetFindings call.
# List all unarchived High-severity findings, newest first
aws guardduty list-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-criteria '{
"Criterion": {
"severity": {"Gte": 7},
"service.archived": {"Eq": ["false"]}
}
}' \
--sort-criteria '{"AttributeName": "updatedAt", "OrderBy": "DESC"}' \
--max-results 50
# List findings of a specific type (EC2 credential exfiltration)
aws guardduty list-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-criteria '{
"Criterion": {
"type": {
"Equals": [
"InstanceCredentialExfiltration:EC2/NoInstanceProfile",
"InstanceCredentialExfiltration:EC2/ScheduledEvent"
]
}
}
}'
# List findings affecting a specific ECS cluster (by tag)
aws guardduty list-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-criteria '{
"Criterion": {
"resource.instanceDetails.tags.key": {"Equals": ["aws:ecs:clusterName"]},
"resource.instanceDetails.tags.value": {"Equals": ["mcp-production-cluster"]}
}
}'
# Paginate through all findings (NextToken pattern)
TOKEN=""
while true; do
RESULT=$(aws guardduty list-findings \
--detector-id abc1234567890abcdef1234567890 \
--max-results 50 \
${TOKEN:+--next-token "$TOKEN"} \
--output json)
echo "$RESULT" | jq -r '.FindingIds[]'
TOKEN=$(echo "$RESULT" | jq -r '.NextToken // empty')
[ -z "$TOKEN" ] && break
done
| Filter criterion key | Values | Use case |
|---|---|---|
severity | Gte/Lte/Gt/Lt/Eq | Filter by numeric severity (7 = High, 4 = Medium) |
type | Equals/Prefix/NotEquals | Filter by finding type string or prefix (e.g., "CryptoCurrency:") |
service.archived | Eq: ["false"] or ["true"] | Show only active (false) or archived (true) findings |
updatedAt | Gte/Lte (epoch ms) | Findings updated in a time range |
accountId | Equals | In admin account: filter by member account |
region | Equals | In admin account: filter by member account region |
resource.instanceDetails.instanceId | Equals | All findings for a specific EC2/ECS instance |
resource.lambdaDetails.functionName | Equals | All findings for a specific Lambda function |
GetFindings response structure
The GetFindings response includes the full finding JSON for each requested ID. The key fields for MCP server incident response:
# Fetch details for specific finding IDs (max 50 per call)
aws guardduty get-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-ids "abc123" "def456" \
--query 'Findings[*].{
Id:Id,
Type:Type,
Severity:Severity,
Count:Service.Count,
FirstSeen:Service.EventFirstSeen,
LastSeen:Service.EventLastSeen,
Resource:Resource
}'
# Full finding structure (key fields):
# {
# "Id": "abc123def456...", ← deterministic hash: same threat = same ID
# "Type": "UnauthorizedAccess:EC2/SSHBruteForce",
# "Severity": 5.0, ← float 0.1-8.9
# "CreatedAt": "2026-10-08T10:00:00Z",
# "UpdatedAt": "2026-10-08T14:00:00Z", ← updated when count increments
# "Title": "EC2 instance is being probed on port 22.",
# "Description": "EC2 instance i-1234567890abcdef0 is being probed...",
#
# "Service": {
# "Count": 847, ← 847 individual SSH attempts, one finding
# "EventFirstSeen": "2026-10-07T08:00:00Z",
# "EventLastSeen": "2026-10-08T14:00:00Z",
# "Archived": false,
# "UserFeedback": null, ← "USEFUL" or "NOT_USEFUL" after UpdateFindingsFeedback
# "Action": { ← what threat actor did
# "ActionType": "PORT_PROBE",
# "PortProbeAction": {
# "PortProbeDetails": [{
# "LocalPortDetails": {"Port": 22, "PortName": "SSH"},
# "RemoteIpDetails": {
# "IpAddressV4": "198.51.100.5",
# "Country": {"CountryName": "China"},
# "Organization": {"Asn": "4134", "AsnOrg": "CHINANET-BACKBONE"}
# }
# }]
# }
# }
# },
#
# "Resource": {
# "ResourceType": "Instance",
# "InstanceDetails": {
# "InstanceId": "i-1234567890abcdef0",
# "InstanceType": "t3.medium",
# "InstanceState": "running",
# "Tags": [{"Key": "Service", "Value": "mcp-monitoring"}],
# "NetworkInterfaces": [{
# "PublicIp": "54.1.2.3",
# "SubnetId": "subnet-abc123",
# "VpcId": "vpc-def456"
# }]
# }
# }
# }
The Service.Action field structure varies by finding type: NetworkConnectionAction for connection-based findings, PortProbeAction for port scans, AwsApiCallAction for API-based threats (unauthorized API calls, unusual CloudTrail activity), and DnsRequestAction for DNS-based findings (C2 communication, crypto mining). Always check ActionType first to know which subfield to parse.
Deduplication behavior and implications for automation
GuardDuty generates one finding per unique combination of: finding type + affected resource + source details. Subsequent occurrences of the same threat increment the existing finding's service.count and update service.eventLastSeen rather than creating new findings. The finding ID is deterministic — you can predict it will be the same for repeated events from the same source to the same target.
# Check if a finding is new vs. updated
aws guardduty get-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-ids "abc123" \
--query 'Findings[0].{
Created:CreatedAt,
Updated:UpdatedAt,
Count:Service.Count,
IsNew:Service.IsNew
}'
# Service.IsNew = true → first time this finding appeared in current publishing cycle
# Service.IsNew = false → this is an update to an existing finding
# Count > 1 and CreatedAt != UpdatedAt → repeated threat, not a first occurrence
# For EventBridge-triggered automation: check DynamoDB to see if you already responded
# Using finding ID as the deduplication key in DynamoDB
# Node.js Lambda example (idempotent remediation check):
# const { DynamoDBClient, PutItemCommand } = require('@aws-sdk/client-dynamodb');
# const dynamo = new DynamoDBClient({});
#
# async function handleFinding(findingId, findingType) {
# // Conditional put: only insert if findingId doesn't exist
# // Returns ConditionalCheckFailedException if already handled
# try {
# await dynamo.send(new PutItemCommand({
# TableName: 'guardduty-responses',
# Item: {
# findingId: { S: findingId },
# respondedAt: { S: new Date().toISOString() },
# type: { S: findingType }
# },
# ConditionExpression: 'attribute_not_exists(findingId)'
# }));
# return true; // First time responding to this finding
# } catch (e) {
# if (e.name === 'ConditionalCheckFailedException') return false; // Already handled
# throw e;
# }
# }
The deduplication window is 90 days — after 90 days, GuardDuty expires the finding and a subsequent occurrence creates a new finding with a new ID. For ongoing threats (like an EC2 instance under sustained brute-force), you may need to check service.eventLastSeen to determine if the threat is still active, not just whether the finding exists.
Archiving and feedback
Archiving a finding removes it from the active finding list but retains it in the detector for 90 days. Archived findings still appear in ListFindings when you include service.archived: Eq ["true"] in the filter criteria. Use archiving when you have confirmed the finding is either a false positive or has been fully remediated.
# Archive specific findings (mark as reviewed/resolved)
aws guardduty archive-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-ids "finding-id-1" "finding-id-2" "finding-id-3"
# Unarchive if you need to re-open a finding
aws guardduty unarchive-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-ids "finding-id-1"
# Provide feedback to improve GuardDuty's ML model
# USEFUL = this was a real threat, correct detection
# NOT_USEFUL = this was a false positive
aws guardduty update-findings-feedback \
--detector-id abc1234567890abcdef1234567890 \
--finding-ids "finding-id-1" \
--feedback USEFUL \
--comments "Confirmed credential exfiltration from compromised MCP worker"
aws guardduty update-findings-feedback \
--detector-id abc1234567890abcdef1234567890 \
--finding-ids "finding-id-2" \
--feedback NOT_USEFUL \
--comments "Expected port scan from internal monitoring tool"
# Bulk archive all Low findings older than 30 days (cleanup script pattern)
# 1. List findings with severity < 4 updated before 30 days ago
# 2. Archive in batches of 50 (API limit per call)
CUTOFF=$(date -u -d '30 days ago' +%s)000 # epoch milliseconds
aws guardduty list-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-criteria "{
\"Criterion\": {
\"severity\": {\"Lt\": 4},
\"updatedAt\": {\"Lte\": $CUTOFF},
\"service.archived\": {\"Eq\": [\"false\"]}
}
}" \
--query 'FindingIds' --output json | \
jq -r '.[]' | \
xargs -n 50 aws guardduty archive-findings \
--detector-id abc1234567890abcdef1234567890 \
--finding-ids
Common finding patterns for MCP server infrastructure
| Finding type | Severity | What it means for MCP | Immediate action |
|---|---|---|---|
InstanceCredentialExfiltration:EC2/NoInstanceProfile | High (8.0) | EC2 instance role credentials used from outside AWS — credentials leaked from MCP server code, environment variables, or logs | Revoke all sessions via IAM deny policy; rotate secret; quarantine instance |
UnauthorizedAccess:EC2/SSHBruteForce | Medium (5.0) | Brute-force SSH against MCP server host — common for internet-facing instances | Verify SSH key-only auth is enforced (no password); add security group rule limiting SSH to bastion |
CryptoCurrency:Lambda/BitcoinTool | High (7.5) | Lambda MCP function making DNS queries to mining pools — likely code injection via user-controlled tool call payload | Disable function; review recent invocation logs; check if input sanitization passes user-controlled strings to shell/eval |
Backdoor:EC2/C&CActivity.B!DNS | High (8.0) | MCP server host making DNS queries to known C2 domains — malware or backdoor installed | Isolate instance via security group; capture memory dump; snapshot EBS; terminate and replace |
Recon:EC2/PortProbeUnprotectedPort | Low–Medium (2.0–5.0) | External entity scanning open ports on MCP server — often a scanning bot, not targeted attack | Review security groups for unnecessary open ports; if expected (monitoring tool), add suppression filter |
UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration | High (8.0) | IAM user credentials (not role) used from AWS instance — API keys checked into MCP server code | Deactivate IAM user access key; audit code for hardcoded credentials; rotate key |
Policy:IAMUser/RootCredentialUsage | High (8.0) | Root account credentials used — should never happen in production | Immediately review root activity in CloudTrail; lock root account with MFA; audit what root did |
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| ListFindings returns 0 results despite known threats | No findings in console or API | Check service.archived filter — default omits archived findings; also verify detector is ENABLED in the correct region |
| GetFindings returns empty Findings array | ListFindings returned IDs but GetFindings empty | Finding IDs expire after 90 days and return empty (not an error); also verify you're querying the correct detector ID and region |
| UpdateFindingsFeedback has no effect on future similar findings | Same false-positive pattern keeps generating findings | Feedback trains the ML model over time but doesn't suppress immediately; create a CreateFilter with ARCHIVE action for instant suppression |
| ArchiveFindings fails with ResourceNotFoundException | Cannot archive specific finding IDs | Finding may have already been archived (check service.archived), or finding ID is from a different detector/region |
| Deduplication causes missed escalation | Ongoing attack (count=1000) looks like a single finding | Check service.eventLastSeen in your remediation Lambda — if within the last 15 minutes, the threat is ongoing regardless of count |