Guide · AWS GuardDuty · Automated Remediation

Automated GuardDuty Remediation via EventBridge for MCP Infrastructure

GuardDuty emits every finding as an EventBridge event the moment it is generated — you do not need to poll the GuardDuty API to get real-time findings. The EventBridge event fires on first detection and again on every update to the finding (every 15 minutes if the threat is ongoing, when the finding's count increments). This means your remediation Lambda receives repeated events for the same finding — and must be idempotent to avoid applying the same isolation action multiple times. The two most important auto-remediation patterns for MCP server infrastructure are: (1) credential revocation via an IAM inline deny policy — the fastest way to neutralize a compromised IAM role, effective within seconds, without deleting the role; and (2) security group isolation — replacing an EC2 instance's security groups with an all-deny group to cut its network access while preserving the instance for forensics. Neither action terminates the instance, which is critical for preserving evidence.

TL;DR

Create an EventBridge rule matching source: ["aws.guardduty"] + detail-type: ["GuardDuty Finding"] + detail.severity: [{numeric: [">=", 7]}]. Route to SNS (human alert) and Lambda (auto-remediation). Remediation Lambda must be idempotent — check DynamoDB for findingId before acting. For credential exfiltration: iam:PutRolePolicy with explicit Deny all — faster and reversible versus deleting the role. For C2/backdoor on EC2: replace security groups with isolation group (not terminate — preserves forensic evidence). See the main GuardDuty guide for detector setup and the findings API guide for finding structure.

EventBridge rule for GuardDuty findings

GuardDuty findings appear on the default EventBridge event bus in the same region as the detector. The event source is always aws.guardduty and detail-type is always GuardDuty Finding. Severity filtering in the event pattern prevents low-severity findings from triggering remediation actions.

# Create EventBridge rule for High-severity GuardDuty findings
aws events put-rule \
  --name "guardduty-high-severity-findings" \
  --event-pattern '{
    "source": ["aws.guardduty"],
    "detail-type": ["GuardDuty Finding"],
    "detail": {
      "severity": [{"numeric": [">=", 7]}]
    }
  }' \
  --state ENABLED \
  --description "Route High severity GuardDuty findings to SNS and remediation Lambda"

# Add SNS target for human notification (PagerDuty/OpsGenie via SNS subscription)
aws events put-targets \
  --rule "guardduty-high-severity-findings" \
  --targets '[
    {
      "Id": "sns-alert",
      "Arn": "arn:aws:sns:us-east-1:123456789012:security-alerts"
    },
    {
      "Id": "lambda-remediation",
      "Arn": "arn:aws:lambda:us-east-1:123456789012:function:guardduty-auto-remediation"
    }
  ]'

# Separate rule for credential-exfiltration findings (any severity — always auto-remediate)
aws events put-rule \
  --name "guardduty-credential-exfiltration" \
  --event-pattern '{
    "source": ["aws.guardduty"],
    "detail-type": ["GuardDuty Finding"],
    "detail": {
      "type": [
        "InstanceCredentialExfiltration:EC2/NoInstanceProfile",
        "InstanceCredentialExfiltration:EC2/ScheduledEvent",
        "UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration"
      ]
    }
  }' \
  --state ENABLED

# Lambda must allow EventBridge to invoke it
aws lambda add-permission \
  --function-name guardduty-auto-remediation \
  --statement-id "allow-eventbridge-guardduty" \
  --action lambda:InvokeFunction \
  --principal events.amazonaws.com \
  --source-arn "arn:aws:events:us-east-1:123456789012:rule/guardduty-high-severity-findings"

The full GuardDuty finding is included in the EventBridge event's detail field — you do not need to call GetFindings from the Lambda. The event contains the same structure as the GetFindings response, including detail.resource, detail.service.action, detail.type, and detail.severity.

Idempotent remediation Lambda

EventBridge fires for both new findings and updated findings (same findingId, incremented count). The remediation Lambda must check whether it has already acted on a finding before executing. Use DynamoDB with a conditional put as the deduplication mechanism.

// Node.js Lambda — idempotent GuardDuty auto-remediation
const { DynamoDBClient, PutItemCommand } = require('@aws-sdk/client-dynamodb');
const { EC2Client, ModifyInstanceAttributeCommand, DescribeInstanceAttributeCommand } = require('@aws-sdk/client-ec2');
const { IAMClient, PutRolePolicyCommand } = require('@aws-sdk/client-iam');
const { SNSClient, PublishCommand } = require('@aws-sdk/client-sns');

const dynamo = new DynamoDBClient({});
const ec2 = new EC2Client({});
const iam = new IAMClient({});
const sns = new SNSClient({});

exports.handler = async (event) => {
  const finding = event.detail;
  const findingId = finding.id;
  const findingType = finding.type;
  const severity = finding.severity;
  const accountId = finding.accountId;

  // Idempotency check: try to insert findingId into DynamoDB
  // ConditionalCheckFailedException = already handled, skip
  try {
    await dynamo.send(new PutItemCommand({
      TableName: process.env.RESPONSES_TABLE,
      Item: {
        findingId: { S: findingId },
        findingType: { S: findingType },
        severity: { N: String(severity) },
        respondedAt: { S: new Date().toISOString() },
        accountId: { S: accountId },
        ttl: { N: String(Math.floor(Date.now() / 1000) + 90 * 24 * 3600) }  // 90-day TTL
      },
      ConditionExpression: 'attribute_not_exists(findingId)'
    }));
  } catch (e) {
    if (e.name === 'ConditionalCheckFailedException') {
      console.log(`Already responded to finding ${findingId}, skipping`);
      return;
    }
    throw e;
  }

  // Route to appropriate remediation based on finding type
  if (findingType.includes('InstanceCredentialExfiltration') ||
      findingType.includes('UnauthorizedAccess:IAMUser/InstanceCredentialExfiltration')) {
    await revokeIAMCredentials(finding);
  } else if (findingType.includes('Backdoor:EC2') || findingType.includes('C&CActivity')) {
    await isolateEC2Instance(finding);
  } else if (findingType.includes('CryptoCurrency:Lambda') || findingType.includes('Backdoor:Lambda')) {
    await disableLambdaFunction(finding);
  }

  // Always send SNS notification with action taken
  await sns.send(new PublishCommand({
    TopicArn: process.env.ALERT_TOPIC_ARN,
    Subject: `GuardDuty Auto-Remediation: ${findingType}`,
    Message: JSON.stringify({
      findingId,
      findingType,
      severity,
      accountId,
      action: 'auto-remediated',
      timestamp: new Date().toISOString()
    }, null, 2)
  }));
};

IAM credential revocation pattern

When GuardDuty detects InstanceCredentialExfiltration:EC2/NoInstanceProfile, the EC2 instance's IAM role credentials are being used outside of AWS — likely stolen from environment variables, code, or logs. The fastest mitigation is an IAM inline policy with an explicit Deny that overrides all Allow statements and immediately invalidates all active sessions for that role.

// Revoke all sessions for a compromised IAM role
// This is faster and safer than deleting the role:
// - Takes effect within ~1-2 seconds
// - Does not break role creation/attachment (other resources that reference the role still work)
// - Reversible: delete the deny policy to restore access when investigation completes

async function revokeIAMCredentials(finding) {
  const roleName = extractRoleName(finding.resource?.instanceDetails);
  if (!roleName) {
    console.error('Could not extract role name from finding', finding.id);
    return;
  }

  // Deny all actions for all sessions issued before NOW
  // AWSRevokeOlderSessions condition revokes all existing sessions immediately
  const denyPolicy = {
    Version: '2012-10-17',
    Statement: [{
      Effect: 'Deny',
      Action: '*',
      Resource: '*',
      Condition: {
        DateLessThan: {
          'aws:TokenIssueTime': new Date().toISOString()
        }
      }
    }]
  };

  await iam.send(new PutRolePolicyCommand({
    RoleName: roleName,
    PolicyName: `GuardDutyRevoke-${finding.id.substring(0, 8)}`,
    PolicyDocument: JSON.stringify(denyPolicy)
  }));

  console.log(`Revoked all sessions for role ${roleName} (finding: ${finding.id})`);
  // To restore: aws iam delete-role-policy --role-name  --policy-name GuardDutyRevoke-
}

function extractRoleName(instanceDetails) {
  // Role ARN from instance profile:
  // "arn:aws:iam::123456789012:role/mcp-server-role"
  const iamProfile = instanceDetails?.iamInstanceProfile;
  if (!iamProfile?.arn) return null;
  const match = iamProfile.arn.match(/role\/(.+)$/);
  return match ? match[1] : null;
}

The DateLessThan aws:TokenIssueTime condition revokes all STS sessions that were issued before the timestamp — effectively invalidating all credentials that were in use when the finding was detected. New credentials issued after the policy is applied will also be denied (since the timestamp condition denies anything issued before now, and future tokens will have a newer issue time — wait, this is a common confusion point: the condition says "deny if token was issued BEFORE now," so credentials issued AFTER this policy is applied will NOT be denied since their issue time is newer than the timestamp). This means the policy correctly blocks all existing leaked sessions while allowing new legitimate sessions to work normally — important for not breaking your MCP server's normal operation while containing the breach.

EC2 instance isolation pattern

For findings indicating a backdoor or C2 communication on an EC2-based MCP server host, the correct response is network isolation — not termination. Terminating the instance destroys forensic evidence. Isolation via security group replacement cuts all inbound and outbound network access while preserving the instance's memory, file system, and process state for investigation.

// Isolate EC2 instance by replacing security groups with all-deny group
async function isolateEC2Instance(finding) {
  const instanceId = finding.resource?.instanceDetails?.instanceId;
  if (!instanceId) return;

  // Use pre-created isolation security group (all-deny: no inbound, no outbound rules)
  // Create this once per account/VPC, not at remediation time (too slow)
  const isolationSgId = process.env.ISOLATION_SECURITY_GROUP_ID;

  // Check if already isolated (idempotency at the AWS level)
  const { Groups } = await ec2.send(new DescribeInstanceAttributeCommand({
    InstanceId: instanceId,
    Attribute: 'groupSet'
  }));

  if (Groups.some(g => g.GroupId === isolationSgId)) {
    console.log(`Instance ${instanceId} already isolated`);
    return;
  }

  // Replace all security groups with isolation group
  // NOTE: this replaces ALL security groups — instance loses all normal network access
  // Preserve original SGs in DynamoDB for restoration
  await dynamo.send(new PutItemCommand({
    TableName: process.env.RESPONSES_TABLE,
    Item: {
      findingId: { S: `isolation-${instanceId}` },
      originalSecurityGroups: { S: JSON.stringify(Groups.map(g => g.GroupId)) },
      instanceId: { S: instanceId },
      isolatedAt: { S: new Date().toISOString() }
    }
  }));

  // Replace security groups
  await ec2.send(new ModifyInstanceAttributeCommand({
    InstanceId: instanceId,
    Groups: [isolationSgId]
  }));

  console.log(`Isolated instance ${instanceId} — replaced ${Groups.length} SG(s) with isolation group`);
  // To restore: aws ec2 modify-instance-attribute --instance-id  --groups 
}
# Create the isolation security group once per VPC (no inbound or outbound rules)
# The lack of rules means all traffic is implicitly denied
aws ec2 create-security-group \
  --group-name "guardduty-isolation" \
  --description "GuardDuty isolation group — no inbound or outbound rules — do not add rules" \
  --vpc-id vpc-abc123 \
  --tag-specifications 'ResourceType=security-group,Tags=[{Key=Purpose,Value=GuardDutyIsolation}]'

# Verify no rules exist (both inbound and outbound should be empty)
aws ec2 describe-security-groups \
  --group-ids sg-isolation-id \
  --query 'SecurityGroups[0].{Inbound:IpPermissions,Outbound:IpPermissionsEgress}'

Unlike VPC NACLs (which operate at the subnet level and affect all instances), security group replacement is instance-specific. The isolated instance can still be accessed via AWS Systems Manager Session Manager (SSM) even with no security groups — SSM uses the SSM endpoint over HTTPS, which does not require open security group rules. This preserves your forensic access path. See the CloudWatch guide for capturing instance metrics during the incident window.

Multi-account remediation with Organizations

When GuardDuty is configured with a delegated administrator account, all member account findings appear in the admin account's EventBridge. The finding's accountId field identifies which member account the resource belongs to. The remediation Lambda must assume a cross-account role in the affected member account to execute EC2 or IAM actions there.

// Multi-account remediation: assume role in affected account
const { STSClient, AssumeRoleCommand } = require('@aws-sdk/client-sts');
const sts = new STSClient({});

async function getClientForAccount(accountId, region, serviceClient) {
  const roleArn = `arn:aws:iam::${accountId}:role/GuardDutyRemediationRole`;

  const { Credentials } = await sts.send(new AssumeRoleCommand({
    RoleArn: roleArn,
    RoleSessionName: `guardduty-remediation-${Date.now()}`,
    DurationSeconds: 900  // 15 minutes — enough for one remediation action
  }));

  return new serviceClient({
    region,
    credentials: {
      accessKeyId: Credentials.AccessKeyId,
      secretAccessKey: Credentials.SecretAccessKey,
      sessionToken: Credentials.SessionToken
    }
  });
}

// Usage in handler:
// const memberEC2 = await getClientForAccount(finding.accountId, finding.region, EC2Client);
// await memberEC2.send(new ModifyInstanceAttributeCommand({ ... }));

// Required: GuardDutyRemediationRole in every member account with trust policy:
// {
//   "Principal": {"AWS": "arn:aws:iam::SECURITY-ACCOUNT-ID:role/guardduty-auto-remediation"},
//   "Action": "sts:AssumeRole",
//   "Condition": {"StringEquals": {"sts:ExternalId": "SHARED-SECRET"}}
// }
// Permissions: ec2:ModifyInstanceAttribute, ec2:DescribeInstanceAttribute,
//              iam:PutRolePolicy, lambda:UpdateFunctionConfiguration

Deploy the GuardDutyRemediationRole to all member accounts via CloudFormation StackSets from the management account — do not manually create the role in each account. The External ID condition in the trust policy prevents confused-deputy attacks (a third party tricking your remediation Lambda into assuming the role for a different account's incident).

Failure modes reference

FailureSymptomFix
Remediation Lambda invoked multiple times for same findingIsolation security group applied, then immediately reverted and reappliedEventBridge fires on every finding update (every 15 minutes for ongoing threats); Lambda must check DynamoDB for findingId before acting; without idempotency check, each update cycle retriggers the action
IAM deny policy not taking effect immediatelyCompromised credentials still working 5+ minutes after PutRolePolicyIAM policy propagation is typically under 1 second globally; if seeing delay, verify the policy was actually applied (get-role-policy); check that the deny policy name is unique (not overwriting an existing policy with a different name)
EC2 isolation breaks SSM accessCannot connect to isolated instance via Session ManagerSSM agent connects to SSM endpoint via NAT gateway or SSM VPC endpoint — if VPC has no NAT and no SSM endpoint, SSM access breaks under isolation; create SSM, EC2Messages, and SSMMessages VPC endpoints before relying on this access path
Cross-account assume-role failsAccessDenied when remediation Lambda tries to act in member accountGuardDutyRemediationRole not created in member account, or trust policy has wrong security account ID; verify with aws sts get-caller-identity from remediation Lambda and check trust policy in member account
EventBridge rule not receiving findingsGuardDuty finds findings but EventBridge rule never firesVerify rule is on the default event bus (GuardDuty does not publish to custom buses); verify rule state is ENABLED; check rule target has proper permissions (Lambda needs resource policy, SNS needs access policy)