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.

AttributeCONFIGURATION_CHANGEPERIODIC
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

SymptomRoot causeFix
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"