Guide · AWS Compliance
AWS Config Custom Rules with Lambda Evaluators
Custom Config rules let you enforce compliance logic that no AWS managed rule can express — checking that your ECS task definition environment variables don't contain raw secret strings, that every DynamoDB table uses a customer-managed KMS key rather than the default AWS-managed key, or that your MCP server Lambda function uses a reserved concurrency limit. Three things trip teams up when writing custom rules: CONFIGURATION_CHANGE rules receive the resource's configuration as a JSON string that must be parsed twice (the event has a configurationItem field with a configuration string that is itself a JSON-stringified resource snapshot — you must JSON.parse() both layers), the Lambda timeout must accommodate your batch evaluation loop (a PERIODIC rule evaluating every DynamoDB table in a large account can trigger hundreds of putEvaluations calls; exceed the Lambda timeout and Config marks the invocation failed and retries), and the evaluator must always return a result for every resource it was asked to evaluate (returning nothing silently leaves previous compliance state in place — always publish COMPLIANT, NON_COMPLIANT, or NOT_APPLICABLE).
TL;DR
Use CONFIGURATION_CHANGE trigger for per-resource checks (fires when a specific resource type changes). Use PERIODIC for account-wide or cross-resource checks (fires on a schedule you set). Parse the configurationItem.configuration field with two JSON.parse() calls. Publish results via putEvaluations() in batches of 100. Always call putEvaluations with ResultToken from the event — omitting it makes the evaluation not appear in the Config console.
CONFIGURATION_CHANGE vs PERIODIC — choosing the right trigger mode
Config custom rules support two trigger types that fire the Lambda evaluator at different times and with different inputs. CONFIGURATION_CHANGE fires your Lambda every time Config records a new configuration item for a resource of the type(s) you specify. The Lambda receives the full configuration item (including the current resource configuration as a JSON string) and is expected to evaluate that specific resource and publish a COMPLIANT/NON_COMPLIANT result. PERIODIC fires your Lambda on a schedule (every 1, 3, 6, 12, or 24 hours). The Lambda receives no configuration item — it must call Config or other AWS APIs to discover the resources it needs to evaluate.
| Attribute | CONFIGURATION_CHANGE | PERIODIC |
|---|---|---|
| Lambda receives | ConfigurationItem for the changed resource | Scheduled event with account/region — no resource data |
| Fires when | Any resource of the specified types changes | On schedule (1h / 3h / 6h / 12h / 24h) |
| Best for | Per-resource checks: "does this DynamoDB table use a CMK?" | Cross-resource checks: "is there exactly one CloudTrail trail enabled?" |
| Scope | Only fires for specified resource types | Fires account-wide — Lambda must enumerate resources itself |
| Evaluation ID | Provided in event as resultToken |
Provided in event as resultToken |
| Max Lambda timeout | 15 min (but keep short — fires on every change) | 15 min (needs time to enumerate and batch-publish all evaluations) |
Parsing the ConfigurationItem — the double JSON.parse trap
When a CONFIGURATION_CHANGE rule fires, the Lambda receives an event with an invokingEvent field that is a JSON string. Parsing it gives you an object with a configurationItem field. That field has a configuration property that is itself another JSON string — the serialized AWS resource configuration. You must parse both layers to access the actual resource properties like SSEDescription or KMSMasterKeyArn.
import {
ConfigServiceClient,
PutEvaluationsCommand,
ComplianceType,
} from "@aws-sdk/client-config-service";
const configClient = new ConfigServiceClient({});
interface ConfigEvent {
invokingEvent: string; // JSON string
ruleParameters: string; // JSON string of rule input parameters
resultToken: string; // must be passed back to putEvaluations
eventLeftScope: boolean; // true if resource moved out of rule scope (e.g., deleted)
}
interface ConfigurationItem {
resourceType: string;
resourceId: string;
configuration: string; // ANOTHER JSON string — the resource snapshot
configurationItemCaptureTime: string;
configurationItemStatus: "OK" | "ResourceDeleted" | "ResourceNotRecorded" | "ResourceDeletedNotRecorded";
}
interface DynamoDbConfiguration {
tableStatus: string;
sseDescription?: {
status: "ENABLED" | "ENABLING" | "DISABLED" | "DISABLING";
sseType?: "AES256" | "KMS";
kmsMasterKeyArn?: string; // present only when sseType === "KMS"
};
}
// Custom rule: every DynamoDB table must use a customer-managed KMS key (not aws/dynamodb)
export async function handler(event: ConfigEvent): Promise<void> {
// Layer 1: parse the invokingEvent string
const invokingEvent = JSON.parse(event.invokingEvent) as {
configurationItem?: ConfigurationItem;
messageType: string;
};
// ScheduledNotification fires PERIODIC rules — no config item in payload
if (invokingEvent.messageType === "ScheduledNotification") {
return; // PERIODIC handler is separate
}
const item = invokingEvent.configurationItem!;
// Skip deleted or not-recorded resources
if (item.configurationItemStatus === "ResourceDeleted") {
await publishEvaluation(event.resultToken, item, ComplianceType.NOT_APPLICABLE,
"Resource deleted — no compliance evaluation");
return;
}
// Layer 2: parse the configuration string (the actual DynamoDB resource snapshot)
const tableConfig = JSON.parse(item.configuration) as DynamoDbConfiguration;
const sse = tableConfig.sseDescription;
// Check: must have SSE enabled with KMS type and a non-default CMK
if (!sse || sse.status !== "ENABLED") {
await publishEvaluation(event.resultToken, item, ComplianceType.NON_COMPLIANT,
"DynamoDB table does not have SSE-KMS encryption enabled");
return;
}
if (sse.sseType !== "KMS") {
await publishEvaluation(event.resultToken, item, ComplianceType.NON_COMPLIANT,
"DynamoDB table uses AES-256 (AWS-owned key) — customer-managed KMS key required");
return;
}
if (!sse.kmsMasterKeyArn || sse.kmsMasterKeyArn.includes("alias/aws/dynamodb")) {
await publishEvaluation(event.resultToken, item, ComplianceType.NON_COMPLIANT,
"DynamoDB table uses AWS-managed key (alias/aws/dynamodb) — customer-managed CMK required");
return;
}
await publishEvaluation(event.resultToken, item, ComplianceType.COMPLIANT,
`DynamoDB table encrypted with CMK: ${sse.kmsMasterKeyArn}`);
}
async function publishEvaluation(
resultToken: string,
item: ConfigurationItem,
complianceType: ComplianceType,
annotation: string
): Promise<void> {
await configClient.send(new PutEvaluationsCommand({
ResultToken: resultToken, // CRITICAL: always pass the ResultToken from the event
Evaluations: [{
ComplianceResourceType: item.resourceType,
ComplianceResourceId: item.resourceId,
ComplianceType: complianceType,
Annotation: annotation.slice(0, 256), // max 256 chars
OrderingTimestamp: new Date(item.configurationItemCaptureTime),
}],
}));
}
PERIODIC rule: checking ECS task definitions for exposed secrets
A PERIODIC rule fires on a schedule and must enumerate its own resources. This pattern is appropriate for checks that span multiple resource types — for example, verifying that every ECS task definition's container definitions don't have raw secret strings in the environment array (as opposed to using secrets with a Secrets Manager or Parameter Store reference). Detecting exposed secrets in environment variables requires looking at the task definition contents, which changes infrequently — a 24-hour periodic evaluation is appropriate.
import { ECSClient, ListTaskDefinitionsCommand, DescribeTaskDefinitionCommand } from "@aws-sdk/client-ecs";
import { ConfigServiceClient, PutEvaluationsCommand, ComplianceType } from "@aws-sdk/client-config-service";
const ecsClient = new ECSClient({});
const configClient = new ConfigServiceClient({});
// Patterns that suggest a raw secret in an environment variable value
const SECRET_PATTERNS = [
/^(AKIA|ASIA|AROA)[A-Z0-9]{16}$/, // AWS access key ID
/^[A-Za-z0-9+/]{40}$/, // AWS secret access key (base64, 40 chars)
/^sk-[a-zA-Z0-9]{32,}/, // OpenAI API key
/^ghp_[a-zA-Z0-9]{36}$/, // GitHub personal access token
/password|secret|api.?key|token/i, // suspicious environment variable name
];
export async function handler(event: { resultToken: string }): Promise<void> {
const evaluations: Parameters<typeof PutEvaluationsCommand>[0]["Evaluations"] = [];
// List all active task definition families
let nextToken: string | undefined;
do {
const list = await ecsClient.send(new ListTaskDefinitionsCommand({
status: "ACTIVE",
nextToken,
}));
for (const arn of list.taskDefinitionArns ?? []) {
const { taskDefinition } = await ecsClient.send(
new DescribeTaskDefinitionCommand({ taskDefinition: arn })
);
if (!taskDefinition) continue;
const exposedSecrets: string[] = [];
for (const container of taskDefinition.containerDefinitions ?? []) {
for (const envVar of container.environment ?? []) {
// Check name for suspicious keywords
if (SECRET_PATTERNS.some(p => p.test(envVar.name ?? ""))) {
exposedSecrets.push(`${container.name}:${envVar.name} (suspicious name)`);
}
// Check value for known secret patterns
if (envVar.value && SECRET_PATTERNS.some(p => p.test(envVar.value!))) {
exposedSecrets.push(`${container.name}:${envVar.name} (suspicious value format)`);
}
}
}
evaluations.push({
ComplianceResourceType: "AWS::ECS::TaskDefinition",
ComplianceResourceId: arn,
ComplianceType: exposedSecrets.length > 0 ? ComplianceType.NON_COMPLIANT : ComplianceType.COMPLIANT,
Annotation: exposedSecrets.length > 0
? `Possible raw secrets in env vars: ${exposedSecrets.slice(0, 3).join(", ")}`.slice(0, 256)
: "No raw secrets detected in environment variables",
OrderingTimestamp: new Date(),
});
// putEvaluations accepts at most 100 evaluations per call
if (evaluations.length === 100) {
await configClient.send(new PutEvaluationsCommand({
ResultToken: event.resultToken,
Evaluations: evaluations.splice(0, 100),
}));
}
}
nextToken = list.nextToken;
} while (nextToken);
// Publish any remaining evaluations
if (evaluations.length > 0) {
await configClient.send(new PutEvaluationsCommand({
ResultToken: event.resultToken,
Evaluations: evaluations,
}));
}
}
Deploying a custom rule with CDK
CDK's CustomRule construct handles the Lambda permission grant and Config rule resource creation. You specify the trigger types (CONFIGURATION_CHANGE, PERIODIC, or both) and the resource types for configuration-change triggers. The Lambda must have permission to call config:PutEvaluations — the CDK construct grants this automatically.
import * as config from "aws-cdk-lib/aws-config";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as cdk from "aws-cdk-lib";
// Lambda evaluator function
const evaluatorFn = new NodejsFunction(this, "DynamoDbCmkEvaluator", {
entry: "src/config-rules/dynamodb-cmk-evaluator.ts",
handler: "handler",
runtime: lambda.Runtime.NODEJS_22_X,
timeout: cdk.Duration.minutes(5),
memorySize: 256,
});
// Grant the evaluator permission to read DynamoDB table configurations
evaluatorFn.addToRolePolicy(new iam.PolicyStatement({
actions: ["dynamodb:DescribeTable", "dynamodb:ListTables"],
resources: ["*"],
}));
// CONFIGURATION_CHANGE custom rule
new config.CustomRule(this, "DynamoDbCmkRule", {
lambdaFunction: evaluatorFn,
configRuleName: "mcp-dynamodb-cmk-required",
description: "Every DynamoDB table must use a customer-managed KMS key",
// Trigger on DynamoDB table changes only
configurationChanges: true,
periodic: false,
// Scope to DynamoDB table resource type
ruleScope: config.RuleScope.fromResources([config.ResourceType.DYNAMODB_TABLE]),
});
// PERIODIC custom rule for ECS task definition secret scanning
const ecsEvaluatorFn = new NodejsFunction(this, "EcsSecretScanner", {
entry: "src/config-rules/ecs-secret-evaluator.ts",
handler: "handler",
runtime: lambda.Runtime.NODEJS_22_X,
timeout: cdk.Duration.minutes(10), // needs time to enumerate all task defs
memorySize: 512,
});
ecsEvaluatorFn.addToRolePolicy(new iam.PolicyStatement({
actions: ["ecs:ListTaskDefinitions", "ecs:DescribeTaskDefinition"],
resources: ["*"],
}));
new config.CustomRule(this, "EcsSecretScanRule", {
lambdaFunction: ecsEvaluatorFn,
configRuleName: "mcp-ecs-no-raw-secrets",
description: "ECS task definitions must not have raw secrets in environment variables",
configurationChanges: false,
periodic: true,
maximumExecutionFrequency: config.MaximumExecutionFrequency.TWENTY_FOUR_HOURS,
});
Failure modes and common mistakes
| Symptom | Root cause | Fix |
|---|---|---|
Evaluations not appearing in Config console after putEvaluations call |
ResultToken omitted or set to a hardcoded string instead of the token from the event |
Always pass event.resultToken exactly as received — Config uses this to match evaluations to rule invocations |
| Custom rule Lambda never fires on resource changes | ruleScope not set — rule fires on all changes but Lambda receives unexpected resource types; or Config recorder doesn't cover the resource type |
Set explicit ruleScope.fromResources([ResourceType.DYNAMODB_TABLE]); verify the recorder scope includes the resource type |
| Lambda times out on PERIODIC evaluation of large accounts | Enumerating thousands of resources with individual API calls; no batching of putEvaluations |
Batch evaluations in groups of 100 (API max); increase Lambda timeout to 15 min; use paginator utilities from AWS SDK v3 |
| Task definition environment variable check misses secrets in multi-container task definitions | Only checking the first container in containerDefinitions array |
Loop over all containers in the array — task definitions can have multiple containers (sidecar, init containers) |
configuration field is null in the ConfigurationItem |
Resource was deleted or is in ResourceDeleted status — no configuration available |
Check configurationItemStatus before parsing; publish NOT_APPLICABLE for deleted resources |
| PERIODIC rule fires but evaluations show stale timestamps | OrderingTimestamp set to the resource's capture time instead of current time for PERIODIC rules |
For PERIODIC rules, set OrderingTimestamp: new Date() — the evaluation represents "right now" not "when the resource last changed" |