CDK Nag: Security and Compliance Testing for AWS CDK Infrastructure

CDK Nag: Security and Compliance Testing for AWS CDK Infrastructure

Writing CDK constructs that deploy working infrastructure is the easy part. Writing CDK constructs that deploy secure infrastructure — that's where teams struggle. CDK Nag is a static analysis tool that checks your CDK stacks against security and compliance rules before a single resource is deployed.

This post covers how to integrate CDK Nag into your testing pipeline and enforce infrastructure security as code.

What Is CDK Nag?

CDK Nag is an open-source library from AWS Labs that applies rule packs to your CDK stacks during synthesis. It catches common misconfigurations:

  • S3 buckets without encryption or versioning
  • Security groups with 0.0.0.0/0 ingress
  • RDS instances without deletion protection
  • IAM policies with wildcard actions
  • Lambda functions without VPC configuration
  • Unencrypted EBS volumes

CDK Nag integrates directly with CDK's aspect system — no separate scanning step required.

Installation

npm install cdk-nag
# or
yarn add cdk-nag

Basic Setup

Add CDK Nag to your CDK app entry point:

// bin/my-app.ts
import { App, Aspects } from 'aws-cdk-lib';
import { AwsSolutionsChecks } from 'cdk-nag';
import { MyStack } from '../lib/my-stack';

const app = new App();
const stack = new MyStack(app, 'MyStack', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' }
});

// Apply AWS Solutions rule pack
Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true }));

Now run cdk synth. If any rules fire, synthesis fails with detailed error messages:

[Error at /MyStack/MyBucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled.
[Error at /MyStack/MyBucket/Resource] AwsSolutions-S2: The S3 Bucket does not have public access block controls enabled.
[Warning at /MyStack/MyBucket/Resource] AwsSolutions-S10: The S3 Bucket does not require requests to use SSL.

Available Rule Packs

CDK Nag ships with several compliance-mapped rule packs:

Pack Standard Use Case
AwsSolutionsChecks AWS Solutions General best practices
NIST80053R4Checks NIST 800-53 Rev 4 US government workloads
NIST80053R5Checks NIST 800-53 Rev 5 Updated US government
HIPAA SecurityChecks HIPAA Healthcare workloads
PCIDSS321Checks PCI DSS 3.2.1 Payment card industry
import { 
  AwsSolutionsChecks,
  NIST80053R5Checks,
  HIPAASecurityChecks
} from 'cdk-nag';

// Apply multiple packs
Aspects.of(app).add(new AwsSolutionsChecks());
Aspects.of(app).add(new NIST80053R5Checks({ verbose: true }));

Writing CDK Nag Tests with Jest

For CI, you want CDK Nag violations to fail your test suite explicitly. Use Jest with the CDK Template and CDK Nag annotations:

// test/my-stack.nag.test.ts
import { App, Aspects } from 'aws-cdk-lib';
import { Annotations, Match } from 'aws-cdk-lib/assertions';
import { AwsSolutionsChecks } from 'cdk-nag';
import { MyStack } from '../lib/my-stack';

describe('CDK Nag Security Tests', () => {
  let app: App;
  let stack: MyStack;

  beforeEach(() => {
    app = new App();
    stack = new MyStack(app, 'TestStack', {
      env: { account: '123456789012', region: 'us-east-1' }
    });
    Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true }));
    // Must synth to trigger nag checks
    app.synth();
  });

  test('no CDK Nag errors', () => {
    const errors = Annotations.fromStack(stack).findError(
      '*',
      Match.stringLikeRegexp('AwsSolutions-.*')
    );
    
    expect(errors).toHaveLength(0);
  });

  test('no critical security warnings', () => {
    const warnings = Annotations.fromStack(stack).findWarning(
      '*',
      Match.stringLikeRegexp('AwsSolutions-.*')
    );
    
    // Warnings are acceptable — errors are not
    // But log them for review
    if (warnings.length > 0) {
      console.warn('CDK Nag warnings:', warnings.map(w => w.entry.data));
    }
  });

  test('S3 bucket is properly secured', () => {
    // Test no S3-specific nag errors
    const s3Errors = Annotations.fromStack(stack).findError(
      '*',
      Match.stringLikeRegexp('AwsSolutions-S.*')
    );
    
    expect(s3Errors).toHaveLength(0);
  });

  test('IAM policies are least-privilege', () => {
    const iamErrors = Annotations.fromStack(stack).findError(
      '*',
      Match.stringLikeRegexp('AwsSolutions-IAM.*')
    );
    
    expect(iamErrors).toHaveLength(0);
  });
});

Suppressing Rules with Justification

Not every rule applies to every situation. CDK Nag lets you suppress specific rules with a required reason — creating an audit trail of intentional exceptions:

import { NagSuppressions } from 'cdk-nag';

// Suppress a rule on a specific construct
const bucket = new s3.Bucket(this, 'DataBucket', {
  // ... config
});

NagSuppressions.addResourceSuppressions(bucket, [
  {
    id: 'AwsSolutions-S1',
    reason: 'This is a temporary scratch bucket used only for Lambda ephemeral processing. Access logs are not required per our data classification policy for scratch data.'
  }
]);

// Suppress on a construct and all children
NagSuppressions.addResourceSuppressions(
  myLambdaRole,
  [
    {
      id: 'AwsSolutions-IAM4',
      reason: 'AWSLambdaBasicExecutionRole is acceptable for this read-only Lambda per security review 2025-Q3'
    }
  ],
  true  // applyToChildren
);

// Suppress at the stack level
NagSuppressions.addStackSuppressions(stack, [
  {
    id: 'AwsSolutions-IAM5',
    reason: 'Wildcard actions on CloudWatch Logs are acceptable per our log writing policy'
  }
]);

Testing That Suppressions Are Documented

You can test that suppressions include justification — preventing lazy reason: 'suppressed' entries:

test('all suppressions have meaningful justifications', () => {
  // Read the suppression registry from your stack
  const suppressions = getNagSuppressions(stack);
  
  for (const suppression of suppressions) {
    expect(suppression.reason.length).toBeGreaterThan(20);
    expect(suppression.reason).not.toMatch(/suppressed|ignore|skip|n\/a/i);
  }
});

Custom Rules

CDK Nag's built-in rules don't cover every organizational policy. Write custom rules for your specific requirements:

// lib/nag-rules/require-cost-tags.ts
import { CfnResource, Stack } from 'aws-cdk-lib';
import { NagMessageLevel, NagPack, NagPackProps } from 'cdk-nag';

export class CompanyPolicyChecks extends NagPack {
  constructor(props?: NagPackProps) {
    super(props);
    this.packName = 'CompanyPolicy';
  }

  public visit(node: IConstruct): void {
    if (node instanceof CfnResource) {
      this.checkRequiredTags(node);
      this.checkResourceNaming(node);
    }
  }

  private checkRequiredTags(resource: CfnResource): void {
    const requiredTags = ['CostCenter', 'Environment', 'Owner'];
    const resourceTags = (resource as any).tags?.renderTags() || {};
    
    for (const tag of requiredTags) {
      if (!resourceTags[tag]) {
        this.applyRule({
          ruleSuffixOverride: 'TAG001',
          info: `Resource is missing required tag: ${tag}`,
          explanation: 'All resources must have CostCenter, Environment, and Owner tags per company tagging policy.',
          level: NagMessageLevel.ERROR,
          rule: class extends NagRules {
            static readonly [$RuleMetaData] = {
              info: `Missing required tag: ${tag}`,
              explanation: 'Required for cost allocation and ownership tracking'
            };
            public validate(node: CfnResource): boolean {
              return false; // already checked above
            }
          },
          node: resource
        });
      }
    }
  }

  private checkResourceNaming(resource: CfnResource): void {
    // Enforce kebab-case naming for logical IDs
    const logicalId = Stack.of(resource).getLogicalId(resource);
    if (!/^[a-z][a-z0-9-]*$/.test(logicalId)) {
      this.applyRule({
        // ... rule config
      });
    }
  }
}

Use it alongside standard packs:

Aspects.of(app).add(new AwsSolutionsChecks());
Aspects.of(app).add(new CompanyPolicyChecks({ verbose: true }));

CI/CD Integration

CDK Nag checks should block deployment when violations exist. In GitHub Actions:

# .github/workflows/cdk-security.yml
name: CDK Security Checks

on:
  pull_request:
    paths:
      - 'infra/**'
      - 'lib/**'

jobs:
  cdk-nag:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run CDK Nag tests
        run: npm test -- --testPathPattern="nag"
        env:
          CDK_DEFAULT_ACCOUNT: '123456789012'
          CDK_DEFAULT_REGION: 'us-east-1'
      
      - name: CDK Synth with Nag (additional check)
        run: npx cdk synth 2>&1 | tee synth-output.txt
        
      - name: Check for Nag errors in synth output
        run: |
          if grep -q '\[Error' synth-output.txt; then
            echo "❌ CDK Nag violations found:"
            grep '\[Error' synth-output.txt
            exit 1
          fi
          echo "✅ No CDK Nag errors"

Combining CDK Nag with Fine-Grained Assertions

CDK Nag and CDK's Template.fromStack assertions are complementary. Use both:

import { Template } from 'aws-cdk-lib/assertions';
import { Annotations, Match } from 'aws-cdk-lib/assertions';
import { AwsSolutionsChecks } from 'cdk-nag';

describe('Database Stack', () => {
  let template: Template;
  let stack: DatabaseStack;

  beforeEach(() => {
    const app = new App();
    stack = new DatabaseStack(app, 'DBStack', { /* props */ });
    Aspects.of(app).add(new AwsSolutionsChecks());
    app.synth();
    template = Template.fromStack(stack);
  });

  // Fine-grained assertions: check the structure is correct
  test('RDS instance has correct config', () => {
    template.hasResourceProperties('AWS::RDS::DBInstance', {
      DBInstanceClass: 'db.t3.medium',
      MultiAZ: true,
      StorageEncrypted: true
    });
  });

  // CDK Nag: check compliance rules pass
  test('no RDS compliance violations', () => {
    const rdsErrors = Annotations.fromStack(stack).findError(
      '*',
      Match.stringLikeRegexp('AwsSolutions-RDS.*')
    );
    expect(rdsErrors).toHaveLength(0);
  });
});

Metrics and Tracking Compliance Over Time

Export CDK Nag results as JSON for tracking compliance drift over time:

// tools/nag-report.ts
import { App, Aspects } from 'aws-cdk-lib';
import { AwsSolutionsChecks, NagReportFormat, NagReportLogger } from 'cdk-nag';

const app = new App();
// ... add stacks

Aspects.of(app).add(new AwsSolutionsChecks({
  reports: true,        // Generate CSV reports
  verbose: true
}));

app.synth();
// Reports written to cdk.out/AwsSolutions-*.csv

Commit the CSV report and diff it in CI — a great way to see whether your compliance posture is improving:

git diff --stat HEAD~1 HEAD -- "cdk.out/*.csv"

Key Takeaways

CDK Nag shifts security-left in your infrastructure pipeline:

  • Run it on cdk synth and in Jest tests — catches issues before any AWS API call
  • Use NagSuppressions with meaningful reasons to create an auditable exception log
  • Write custom rules for company-specific policies
  • Pair with fine-grained Template assertions for complete coverage
  • Export reports for compliance trending over time

For teams running HelpMeTest against CDK-provisioned environments, CDK Nag ensures the infrastructure your tests run on is itself compliant — closing the loop between application testing and infrastructure security.

Read more

Start now free