Guide · AWS Compliance
AWS Config Remediation with SSM Automation
Config remediation actions run an SSM Automation document automatically when a resource becomes NON_COMPLIANT — enabling self-healing compliance without manual intervention. Three failure modes teams encounter: auto-remediation fires in a loop if the fix doesn't fully resolve the compliance check (SSM document runs, resource becomes COMPLIANT momentarily, then drifts back to NON_COMPLIANT triggering another run — always set MaximumAutomaticAttempts to 3-5, not unlimited, and add alerting for repeated remediation attempts), the SSM document receives the resource ARN but not the full resource configuration (the RESOURCE_ID binding passes only the resource identifier — the document must call AWS APIs to fetch current resource state before making changes), and the IAM role for remediation needs broad write permissions that differ from the Config recorder role (the recorder role needs only read access; the remediation role needs write access to the specific service — a single misconfigured role policy is the most common cause of remediation execution failures).
TL;DR
Set Automatic: true only for reversible, low-risk fixes (adding tags, enabling encryption). Use Automatic: false (manual remediation) for anything that changes resource behavior or could impact production traffic. Always set MaximumAutomaticAttempts: 3 and RetryAttemptSeconds: 60. Set ExecutionControls.SsmControls.ConcurrentExecutionRatePercentage: 25 to prevent throttling when many resources become non-compliant simultaneously. Create a dedicated IAM role for the remediation action with least-privilege write permissions.
Automatic vs manual remediation — when to use each
Automatic remediation fires the SSM document immediately when a resource transitions to NON_COMPLIANT — no human approval step. Manual remediation creates a remediation execution record that appears in the Config console and can be triggered by an operator clicking "Remediate" or by calling start-remediation-execution programmatically. The choice affects risk profile and auditability differently.
| Scenario | Mode | Reason |
|---|---|---|
| Add missing required tags to a DynamoDB table | Automatic | Tag changes don't affect data plane; safe to apply without human review |
| Enable encryption on an existing DynamoDB table | Manual | Enabling SSE-KMS on an existing table causes table to become temporarily unavailable; needs maintenance window |
| Enable VPC flow logs on a VPC | Automatic | Adding flow logs doesn't affect network traffic; new resource creation only |
| Modify ECS task definition environment variables | Manual | Requires a new task definition revision and service update — affects running containers |
| Revoke a security group rule that opens port 22 to 0.0.0.0/0 | Manual (with alert) | Revoking SSH access may break maintenance workflows; needs human awareness |
| Delete an unencrypted CloudTrail trail and recreate with encryption | Manual | Deleting a trail causes an audit gap; never auto-remediate audit infrastructure |
Configuring a remediation action with CDK
Remediation configurations reference an SSM document (either AWS-provided or custom) and map parameters from the Config finding (resource ID, resource type, account ID) to the document's input parameters. The RESOURCE_ID binding passes the ARN or ID of the non-compliant resource. AWS_ACCOUNT_ID and AWS_REGION bindings inject context values. Static values use the StaticValue type.
import * as cdk from "aws-cdk-lib";
import * as config from "aws-cdk-lib/aws-config";
import * as iam from "aws-cdk-lib/aws-iam";
// IAM role that SSM Automation executes as — must have write permissions for the fix
const remediationRole = new iam.Role(this, "ConfigRemediationRole", {
assumedBy: new iam.ServicePrincipal("ssm.amazonaws.com"),
description: "Role assumed by SSM Automation when Config triggers remediation",
});
// Grant permission to create VPC flow logs (for the flow logs remediation)
remediationRole.addToPolicy(new iam.PolicyStatement({
actions: [
"ec2:CreateFlowLogs",
"ec2:DescribeFlowLogs",
"iam:PassRole", // needed to pass the flow logs delivery role to ec2:CreateFlowLogs
],
resources: ["*"],
}));
// Grant permission to add tags (for the required-tags remediation)
remediationRole.addToPolicy(new iam.PolicyStatement({
actions: [
"dynamodb:TagResource",
"lambda:TagResource",
"ecs:TagResource",
"ec2:CreateTags",
],
resources: ["*"],
}));
// Remediation action for VPC flow logs — automatic (low risk)
const flowLogsDeliveryRoleArn = "arn:aws:iam::123456789012:role/VpcFlowLogsDelivery";
new config.CfnRemediationConfiguration(this, "VpcFlowLogsRemediation", {
configRuleName: "mcp-vpc-flow-logs-enabled", // must match the rule name exactly
targetType: "SSM_DOCUMENT",
targetId: "AWSConfigRemediation-CreateVPCFlowLogToS3Bucket",
automatic: true,
maximumAutomaticAttempts: 3, // stop after 3 failed attempts to prevent loops
retryAttemptSeconds: 60, // wait 60 seconds between retry attempts
executionControls: {
ssmControls: {
concurrentExecutionRatePercentage: 25, // at most 25% of non-compliant resources simultaneously
errorPercentage: 20, // halt if >20% of executions fail
},
},
parameters: {
// VpcId: the non-compliant resource ID (the VPC ARN or ID)
VpcId: { resourceValue: { value: "RESOURCE_ID" } },
// S3BucketArn: static value — destination for flow log delivery
S3BucketArn: {
staticValue: { values: ["arn:aws:s3:::mcp-flow-logs-123456789012"] },
},
// AutomationAssumeRole: the IAM role SSM Automation uses to make API calls
AutomationAssumeRole: {
staticValue: { values: [remediationRole.roleArn] },
},
},
});
// Remediation action for required tags — automatic (tag addition is safe)
new config.CfnRemediationConfiguration(this, "RequiredTagsRemediation", {
configRuleName: "mcp-required-tags",
targetType: "SSM_DOCUMENT",
targetId: "AWS-AddTagsToResource", // AWS-provided document for adding tags
automatic: true,
maximumAutomaticAttempts: 2,
retryAttemptSeconds: 30,
executionControls: {
ssmControls: {
concurrentExecutionRatePercentage: 50,
errorPercentage: 10,
},
},
parameters: {
ResourceARN: { resourceValue: { value: "RESOURCE_ID" } },
Tags: {
staticValue: {
values: [
// Placeholder tags — signals the resource needs manual tag review
'{"Environment":"requires-tagging","Service":"unknown","Owner":"ops@company.com"}',
],
},
},
AutomationAssumeRole: {
staticValue: { values: [remediationRole.roleArn] },
},
},
});
Custom SSM Automation document for MCP-specific remediation
When AWS-provided documents don't match your use case, write a custom SSM Automation document. For MCP servers, a common need is rotating the API key stored in a Lambda function's environment variable when the corresponding Secrets Manager secret has been updated — a fix that requires calling Lambda's UpdateFunctionConfiguration to inject the new secret value (or, better, removing the hardcoded value entirely and switching to Secrets Manager references).
# Custom SSM Automation document: remove hardcoded API keys from Lambda environment variables
# Upload to SSM with: aws ssm create-document --name McpRemediate-LambdaRemoveRawSecrets
# --document-type Automation --content file://document.yaml
schemaVersion: "0.3"
description: "Remove potentially-hardcoded secrets from Lambda function environment variables"
assumeRole: "{{ AutomationAssumeRole }}"
parameters:
FunctionName:
type: String
description: "Lambda function name or ARN to remediate"
AutomationAssumeRole:
type: String
description: "IAM role ARN for this automation to assume"
SecretVariableNames:
type: StringList
description: "Environment variable names to remove from the function config"
default:
- "API_KEY"
- "SECRET_KEY"
- "DATABASE_PASSWORD"
- "OPENAI_API_KEY"
mainSteps:
- name: GetCurrentFunctionConfig
action: aws:executeAwsApi
inputs:
Service: lambda
Api: GetFunctionConfiguration
FunctionName: "{{ FunctionName }}"
outputs:
- Name: CurrentEnvironmentVariables
Selector: "$.Environment.Variables"
Type: StringMap
- name: BuildSanitizedEnvironment
action: aws:executeScript
inputs:
Runtime: python3.11
Handler: script_handler
InputPayload:
current_env: "{{ GetCurrentFunctionConfig.CurrentEnvironmentVariables }}"
secret_keys: "{{ SecretVariableNames }}"
Script: |
def script_handler(events, context):
current = events.get("current_env", {})
keys_to_remove = events.get("secret_keys", [])
sanitized = {k: v for k, v in current.items() if k not in keys_to_remove}
removed = [k for k in keys_to_remove if k in current]
return {
"sanitized_env": sanitized,
"removed_keys": removed,
"removal_count": len(removed)
}
outputs:
- Name: SanitizedEnvironment
Selector: "$.Payload.sanitized_env"
Type: StringMap
- Name: RemovedKeys
Selector: "$.Payload.removed_keys"
Type: StringList
- Name: RemovalCount
Selector: "$.Payload.removal_count"
Type: Integer
- name: UpdateFunctionEnvironment
action: aws:executeAwsApi
inputs:
Service: lambda
Api: UpdateFunctionConfiguration
FunctionName: "{{ FunctionName }}"
Environment:
Variables: "{{ BuildSanitizedEnvironment.SanitizedEnvironment }}"
- name: WaitForUpdate
action: aws:waitForAwsResourceProperty
inputs:
Service: lambda
Api: GetFunctionConfiguration
FunctionName: "{{ FunctionName }}"
PropertySelector: "$.LastUpdateStatus"
DesiredValues:
- "Successful"
MaxAttempts: 10
DelaySeconds: 5
- name: LogRemediation
action: aws:executeAwsApi
inputs:
Service: cloudwatch
Api: PutMetricData
Namespace: MCP/ConfigRemediation
MetricData:
- MetricName: LambdaSecretsRemoved
Value: "{{ BuildSanitizedEnvironment.RemovalCount }}"
Unit: Count
Dimensions:
- Name: FunctionName
Value: "{{ FunctionName }}"
Monitoring remediation execution results
Config stores the execution history of every remediation attempt. Query it with describe-remediation-execution-status to check whether remediations succeeded, failed, or are in progress. Failed executions include the SSM Automation execution ID, which you can look up in Systems Manager to get the full step-by-step failure output.
import {
ConfigServiceClient,
DescribeRemediationExecutionStatusCommand,
} from "@aws-sdk/client-config-service";
import {
SSMClient,
GetAutomationExecutionCommand,
} from "@aws-sdk/client-ssm";
const configClient = new ConfigServiceClient({ region: "us-east-1" });
const ssmClient = new SSMClient({ region: "us-east-1" });
async function checkRemediationStatus(ruleName: string): Promise<void> {
let nextToken: string | undefined;
do {
const status = await configClient.send(
new DescribeRemediationExecutionStatusCommand({
ConfigRuleName: ruleName,
NextToken: nextToken,
})
);
for (const exec of status.RemediationExecutionStatuses ?? []) {
const resourceKey = exec.ResourceKey;
console.log(
`Resource: ${resourceKey?.ResourceType}/${resourceKey?.ResourceId}`,
`State: ${exec.State}`,
`Last updated: ${exec.LastUpdatedTime?.toISOString()}`
);
if (exec.State === "FAILED" && exec.StepDetails) {
for (const step of exec.StepDetails) {
if (step.State === "FAILED") {
console.error(` Failed step: ${step.Name} — ${step.ErrorMessage}`);
// Get SSM execution details for the specific failure
if (step.StartTime) {
// Note: Config doesn't directly expose the SSM execution ID in the SDK
// Look up in the SSM console under Automation → Executions, filtered by time
console.log(` Check SSM Automation executions around ${step.StartTime.toISOString()}`);
}
}
}
}
}
nextToken = status.NextToken;
} while (nextToken);
}
Failure modes and common mistakes
| Symptom | Root cause | Fix |
|---|---|---|
| Remediation loops — SSM document runs repeatedly for the same resource | The fix doesn't fully resolve the Config rule's compliance check, or the resource drifts back immediately after remediation | Set MaximumAutomaticAttempts: 3; add an alert when a resource needs more than 2 remediation attempts — indicates a persistent misconfiguration |
| SSM Automation execution fails with "AccessDenied" | The remediation IAM role doesn't have the specific write permission needed; or iam:PassRole is missing for sub-roles |
Check the SSM execution step output for the exact IAM action that was denied; add it to the remediation role policy |
| Remediation executions all fail simultaneously when many resources go NON_COMPLIANT | ConcurrentExecutionRatePercentage too high — causes API rate throttling on the target service |
Reduce to 10-25%; add ErrorPercentage: 10 to halt the batch if too many fail (throttling cascades) |
| Auto-remediation never fires even though resources are NON_COMPLIANT | Automatic: true requires that MaximumAutomaticAttempts and RetryAttemptSeconds are also set — omitting them prevents auto-trigger |
Set both fields; verify with describe-remediation-configurations that all three fields are populated |
| Remediation fails: "Resource not found" for the RESOURCE_ID binding | Config passes the resource ID as a plain string, but the SSM document expects an ARN; or the resource was deleted between Config evaluation and remediation execution | Check what format the SSM document's parameter expects; add a step in the document to verify resource existence before attempting the fix |
| DynamoDB table remains NON_COMPLIANT after encryption remediation runs | SSM document ran UpdateTable to enable encryption but the table was still UPDATING when Config re-evaluated |
Add a waitForAwsResourceProperty step waiting for TableStatus = ACTIVE before the document marks completion; add a RetryAttemptSeconds: 300 to allow time for table update |