cdk-nag v3: Security Checks in CDK Tests with Vitest
With cdk-nag v3, the most popular checking tool for AWS CDK projects has changed fundamentally: the rules now run through the CDK’s built-in policy validation framework, and the familiar NagSuppressions class is gone. If
With cdk-nag v3, the most popular checking tool for AWS CDK projects has changed fundamentally: the rules now run through the CDK’s built-in policy validation framework, and the familiar NagSuppressions class is gone. If you simply bump the version, the next build mostly shows you red compiler errors.
I like cdk-nag because it catches exactly the mistakes that tend to slip through code review: the queue without a dead-letter queue, the bucket that does not enforce TLS, the role with s3:*. In this article I show you how to set up the new version, document exceptions properly and lock it all in with a Vitest test. The example is a fictional bike rental with a queue, a Lambda function, a DynamoDB table and a bucket for receipts.
In short
cdk-nag v3 plugs into the AWS CDK policy validation via Validations.of(app).addPlugins(), and you set exceptions with Validations.of(construct).acknowledge() including a reason. For tests, validateScope() runs the rules directly against the stack, no synth needed. The biggest pitfall: in my test even plain warnings stopped the synth, and every wildcard finding needs its own acknowledgment.
What you will learn in this article
- What changes from cdk-nag v2 to v3 and why your old code no longer builds
- How to register rule packs and acknowledge findings with a reason
- How to check cdk-nag rules in Vitest with validateScope()
- How to write and test your own house rule as a NagPack
What changed in cdk-nag v3
A quick bit of context: cdk-nag is an open-source library from AWS’s cdklabs. It checks the resources of your CDK app against rule packs, for example AwsSolutions (general best practices), Serverless, HIPAA, NIST 800-53 or PCI DSS. Version 3.0.0 was released on June 12, 2026, and the current version is 3.0.2. The rewrite is big enough to deserve a closer look.
From aspect to validation plugin
Up to v2, every rule pack was an Aspect. Aspects visit every node in the construct tree during synth, and cdk-nag reported its findings as annotations, meaning warnings and errors attached to individual constructs. In v3, a rule pack is an IPolicyValidationPlugin instead. The CDK calls such plugins itself after the templates have been generated, collects the results and writes a shared report. Other checking tools use the same interface, for example the CloudFormation Guard validator.
Same job, new mechanics: v3 uses the AWS CDK policy validation instead of its own aspect.
NagSuppressions becomes acknowledge
The second big change concerns exceptions. Instead of NagSuppressions.addResourceSuppressions() you now write Validations.of(construct).acknowledge({ id, reason }). This is a CDK API, and it applies to the given construct and everything below it. You can pass several acknowledgments per call, each with its own reason.
One detail matters for rules with several findings, such as AwsSolutions-IAM5 for wildcards in IAM policies. According to the migration guide, an acknowledgment without a finding suffix no longer suppresses all hits of the rule in v3. You have to confirm each hit separately, for example AwsSolutions-IAM5[Resource::*]. I think that is a good decision, because this is exactly where most blanket exceptions came from in v2.
| Topic | cdk-nag v2 | cdk-nag v3 |
|---|---|---|
| Register | Aspects.of(app).add(new AwsSolutionsChecks()) |
Validations.of(app).addPlugins(new AwsSolutionsChecks(app)) |
| Exception | NagSuppressions.addResourceSuppressions(...) |
Validations.of(construct).acknowledge({ id, reason }) |
| Single finding | appliesTo: ['Action::s3:*'] |
id: 'AwsSolutions-IAM5[Action::s3:*]' |
| Report | CSV/JSON files from cdk-nag | CDK report in the cloud assembly |
| Custom rules | visit(node) |
checkResource(node) plus name
|
| Minimum version | aws-cdk-lib 2.176.0 | aws-cdk-lib 2.257.0 |
Prerequisites
- Node.js 22 or newer (I tested with Node 22)
- Basic knowledge of the AWS CDK with TypeScript
- You do not need an AWS account for tests and synth. Only
cdk deployneeds credentials. - Tested versions: aws-cdk-lib 2.275.0, cdk-nag 3.0.2, aws-cdk (CLI) 2.1148.0, Vitest 5.1.0, TypeScript 7.0.2
If CDK tests are new to you, read my article Testing a CDK stack with Vitest first. It covers template assertions, this one covers rule checks. The two complement each other well.
Setting up cdk-nag v3 with Vitest step by step
Step 1: Create the project
Create an empty project and install the packages. I use tsx so the CDK CLI can run TypeScript directly.
mkdir cdk-nag-v3-vitest && cd cdk-nag-v3-vitest
npm init -y
npm install aws-cdk-lib constructs cdk-nag
npm install --save-dev aws-cdk typescript tsx vitest @types/node
echo '{ "app": "npx tsx bin/app.ts" }' > cdk.json
Add three scripts to your package.json so you don’t have to memorize anything later:
{
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit",
"synth": "cdk synth --quiet"
}
}
Step 2: Register the rule packs in the app
In the app file you register the packs once for the whole app. New in v3: the pack takes the scope as its first argument. Besides AwsSolutions I also add the Serverless pack and a custom house rule that we build in step 7.
// bin/app.ts
import { App, Validations } from 'aws-cdk-lib';
import { AwsSolutionsChecks, ServerlessChecks } from 'cdk-nag';
import { RentalChecks } from '../lib/rental-rules';
import { RentalStack } from '../lib/rental-stack';
const app = new App();
new RentalStack(app, 'BikeRental');
// Every check runs automatically on cdk synth and cdk deploy.
Validations.of(app).addPlugins(
new AwsSolutionsChecks(app),
new ServerlessChecks(app),
new RentalChecks(app),
);
From now on the check runs with every cdk synth and cdk deploy. If a pack finds something, the synth stops and the CLI shows you where the problem was introduced. The output looks like this:
lib/rental-stack.ts:39:19
ERROR The SQS queue does not require requests to use SSL. (AwsSolutions)
BikeRental/BookingQueue/Resource aws-cdk-lib.aws_sqs.CfnQueue
Acknowledge with 'AwsSolutions::AwsSolutions-SQS4'
Synthesis finished with errors
I find it handy that the message points to the line in your own code and also gives you the ID for a possible acknowledgment. The CDK also stores the full report as a JSON file in the cdk.out folder.
🎬 How cdk-nag v3 checks during synth – this animated diagram only runs on my site: watch it on sascha-ms.de
Step 3: The example stack
The bike rental stack is deliberately small, but it contains the typical spots where cdk-nag fires. Bookings arrive through an SQS queue, a Lambda function writes them to DynamoDB and stores receipts as PDFs in S3. Read the code once in full; I explain the acknowledgments at the end in the next step.
// lib/rental-stack.ts
import { Duration, RemovalPolicy, Stack, Validations, type StackProps } from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { Construct } from 'constructs';
export class RentalStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// Target bucket for the receipt bucket's access logs
const accessLogs = new s3.Bucket(this, 'AccessLogBucket', {
enforceSSL: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
});
Validations.of(accessLogs).acknowledge({
id: 'AwsSolutions-S1',
reason: 'Target bucket for access logs; logging the logs adds no value.',
});
const receipts = new s3.Bucket(this, 'ReceiptBucket', {
enforceSSL: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
serverAccessLogsBucket: accessLogs,
serverAccessLogsPrefix: 'receipts/',
});
// Bookings flow through a queue with a dead-letter queue
const dlq = new sqs.Queue(this, 'BookingDlq', {
enforceSSL: true,
retentionPeriod: Duration.days(14),
});
Validations.of(dlq).acknowledge({
id: 'Serverless-SQSRedrivePolicy',
reason: 'The DLQ is the final stop; an alarm on its depth replaces another redrive level.',
});
const queue = new sqs.Queue(this, 'BookingQueue', {
enforceSSL: true,
visibilityTimeout: Duration.seconds(60),
deadLetterQueue: { queue: dlq, maxReceiveCount: 3 },
});
const table = new dynamodb.Table(this, 'BookingTable', {
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
removalPolicy: RemovalPolicy.RETAIN,
});
const fn = new lambda.Function(this, 'BookingHandler', {
runtime: lambda.Runtime.NODEJS_24_X,
handler: 'index.handler',
code: lambda.Code.fromInline('exports.handler = async () => {};'),
timeout: Duration.seconds(10),
memorySize: 512,
tracing: lambda.Tracing.ACTIVE,
});
fn.addEventSource(new SqsEventSource(queue));
table.grantWriteData(fn);
// Only PutObject below receipts/ instead of grantPut with s3:Abort* and friends
fn.addToRolePolicy(new iam.PolicyStatement({
actions: ['s3:PutObject'],
resources: [receipts.arnForObjects('receipts/*')],
}));
// Acknowledgments apply to the function and everything below it (role, policy, mapping)
Validations.of(fn).acknowledge(
{
id: 'AwsSolutions-IAM4[Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]',
reason: 'The AWS basic role only allows writing its own CloudWatch logs.',
},
{
id: `AwsSolutions-IAM5[Resource::<${this.getLogicalId(receipts.node.defaultChild as s3.CfnBucket)}.Arn>/receipts/*]`,
reason: 'Receipts are stored per booking as receipts/<bookingId>.pdf; the prefix limits access.',
},
{
id: 'AwsSolutions-IAM5[Resource::*]',
reason: 'X-Ray only: PutTraceSegments and PutTelemetryRecords do not support resource ARNs.',
},
{
id: 'Serverless-LambdaDLQ',
reason: 'The function is only invoked synchronously by SQS; failed attempts end up in the queue DLQ.',
},
{
id: 'Serverless-LambdaEventSourceMappingDestination',
reason: 'SQS mappings have no on-failure destination; the queue redrive policy covers this.',
},
);
}
}
Some settings are direct answers to rules. enforceSSL satisfies AwsSolutions-S10 and AwsSolutions-SQS4, the dead-letter queue satisfies AwsSolutions-SQS3, point-in-time recovery satisfies AwsSolutions-DDB3. In addition, AwsSolutions-L1 asks for the latest runtime: with Node.js 22 my test reported an error, with Node.js 24 it did not. On top of that, the Serverless pack wanted more than the default memory size and active X-Ray tracing.
By the way, instead of bucket.grantPut(fn) I only grant s3:PutObject on the receipts/ prefix. grantPut includes actions like s3:Abort*, which cdk-nag reports as a wildcard. A tighter permission is the better fix here than an acknowledgment.
Step 4: Acknowledge findings with a reason
Not every finding is a mistake. The bucket for access logs does not need an access log itself, and X-Ray actions like xray:PutTraceSegments do not support resource ARNs, so Resource: * is unavoidable there. This is what acknowledge is for, with a reason that someone will still understand a year from now.
About the ID: the CLI suggests 'AwsSolutions::AwsSolutions-SQS4', while the cdk-nag README uses the short form 'AwsSolutions-S1'. Both worked in my test. I stick with the short form because it matches the rule IDs in the documentation.
It gets tricky with findings that contain generated names. The finding for the receipt bucket is called AwsSolutions-IAM5[Resource::<ReceiptBucket14EDEAB6.Arn>/receipts/*], and the part with the hash is the logical ID from the template. You should not hard-code this ID. With this.getLogicalId(...) the stack computes it itself, so the acknowledgment still fits after a rename.
Step 5: A test helper with validateScope
Now comes the part I like best in v3. Every rule pack has a validateScope(scope) method. According to the documentation it is the intended entry point for tests: it checks a construct tree directly and returns a report instead of stopping the synth. Acknowledgments set with acknowledge are taken into account. A small helper turns the report into a flat list that is easy to compare:
// test/helpers.ts
import type { NagPack } from 'cdk-nag';
import type { IConstruct } from 'constructs';
export interface Finding {
rule: string;
severity: string;
path: string;
}
/** Runs a rule pack and returns all violations as a flat list. */
export function findings(pack: NagPack, scope: IConstruct): Finding[] {
const report = pack.validateScope(scope);
return report.violations.flatMap((v) =>
v.violatingResources.map((r) => ({
rule: v.ruleName,
severity: v.severity ?? 'error',
path: r.constructPath ?? r.resourceLogicalId ?? '?',
})),
);
}
Step 6: Write the tests
The tests check three things. First: the stack has no open findings in the three packs. Second: every acknowledgment has a reason with a minimum length, so nobody writes 'ok'. Third: the rules really fire when something is missing. I consider this negative test especially important. Without it you won’t notice when a misconfigured check simply stops reporting anything.
// test/rental-nag.test.ts
import { App, Duration, Stack, Validations } from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import { AwsSolutionsChecks, ServerlessChecks } from 'cdk-nag';
import { describe, expect, test } from 'vitest';
import { RentalChecks } from '../lib/rental-rules';
import { RentalStack } from '../lib/rental-stack';
import { findings } from './helpers';
function buildStack(): Stack {
return new RentalStack(new App(), 'BikeRental');
}
describe('cdk-nag rules for the bike rental', () => {
test('AwsSolutions reports no open violations', () => {
expect(findings(new AwsSolutionsChecks(), buildStack())).toEqual([]);
});
test('Serverless pack reports neither errors nor warnings', () => {
expect(findings(new ServerlessChecks(), buildStack())).toEqual([]);
});
test('custom house rules are satisfied', () => {
expect(findings(new RentalChecks(), buildStack())).toEqual([]);
});
test('every acknowledgment has a real reason', () => {
const stack = buildStack();
const reasons: Array<[string, string]> = [];
for (const construct of stack.node.findAll()) {
for (const entry of construct.node.metadata) {
if (entry.type !== Validations.ACKNOWLEDGED_RULES_METADATA_KEY) continue;
for (const [rule, reason] of Object.entries(entry.data as Record<string, string>)) {
if (rule.includes('AwsSolutions') || rule.includes('RentalChecks')) {
reasons.push([rule, reason]);
}
}
}
}
expect(reasons.length).toBeGreaterThan(0);
for (const [, reason] of reasons) {
expect(reason.length).toBeGreaterThanOrEqual(30);
}
});
});
describe('the rules really fire', () => {
test('queue without dead-letter queue is caught by SQS3', () => {
const stack = new Stack(new App(), 'Broken');
new sqs.Queue(stack, 'Loose', { enforceSSL: true });
const rules = findings(new AwsSolutionsChecks(), stack).map((f) => f.rule);
expect(rules).toContain('AwsSolutions-SQS3');
});
test('timeout above 30 seconds violates the house rule', () => {
const stack = new Stack(new App(), 'Slow');
new lambda.Function(stack, 'SlowHandler', {
runtime: lambda.Runtime.NODEJS_24_X,
handler: 'index.handler',
code: lambda.Code.fromInline('exports.handler = async () => {};'),
timeout: Duration.seconds(60),
});
expect(findings(new RentalChecks(), stack)).toEqual([
expect.objectContaining({ rule: 'RentalChecks-L1', path: 'Slow/SlowHandler/Resource' }),
]);
});
});
test('synth with all plugins succeeds', () => {
const app = new App();
new RentalStack(app, 'BikeRental');
Validations.of(app).addPlugins(
new AwsSolutionsChecks(app),
new ServerlessChecks(app),
new RentalChecks(app),
);
expect(() => app.synth()).not.toThrow();
});
For the reason test I read the metadata that acknowledge stores on the construct. The key is available as the constant Validations.ACKNOWLEDGED_RULES_METADATA_KEY. You need the filter on AwsSolutions and RentalChecks because the CDK stores its own entries there as well. In my test, for example, an acknowledgment by the CDK itself for a CloudFormation lint rule showed up. Acknowledgments of the Serverless pack are not covered by this filter; extend it if you need to.
Step 7: A custom house rule as a NagPack
Off-the-shelf rule packs cover a lot, but every team has its own conventions. For the bike rental the rule is: no Lambda function gets a timeout above 30 seconds, because long timeouts hide hanging calls to third-party systems. In v3, a custom pack extends NagPack, needs a name property and implements checkResource. You no longer have to walk the tree yourself.
// lib/rental-rules.ts
import { CfnResource } from 'aws-cdk-lib';
import { CfnFunction } from 'aws-cdk-lib/aws-lambda';
import type { IConstruct } from 'constructs';
import {
NagMessageLevel,
NagPack,
type NagPackProps,
NagRuleCompliance,
NagRules,
} from 'cdk-nag';
/** Custom house rules for the bike rental. */
export class RentalChecks extends NagPack {
public readonly name = 'RentalChecks';
constructor(scope?: IConstruct, props?: NagPackProps) {
super(scope, props);
this.packName = 'RentalChecks';
}
protected checkResource(node: CfnResource): void {
this.applyRule({
ruleSuffixOverride: 'L1',
info: 'The Lambda function has a timeout above 30 seconds.',
explanation:
'Long timeouts hide hanging calls to third-party systems and make failures expensive.',
level: NagMessageLevel.ERROR,
rule: (resource: CfnResource) => {
if (!(resource instanceof CfnFunction)) return NagRuleCompliance.NOT_APPLICABLE;
const timeout = NagRules.resolveIfPrimitive(resource, resource.timeout) ?? 3;
return timeout <= 30 ? NagRuleCompliance.COMPLIANT : NagRuleCompliance.NON_COMPLIANT;
},
node,
});
}
}
With ruleSuffixOverride you define the rule ID, here RentalChecks-L1. NagRules.resolveIfPrimitive resolves the value from the template. If the timeout is missing, Lambda uses three seconds, hence the default in the code.
Step 8: Run tests and synth
npm run typecheck
npm test
npm run synth
Expected result: the type check finishes without output, Vitest reports seven green tests, and cdk synth completes without errors and exit code 0. As a cross-check, remove enforceSSL from the booking queue in lib/rental-stack.ts. The first test then fails with AwsSolutions-SQS4, and the synth stops with exit code 1.
Test Files 1 passed (1)
Tests 7 passed (7)
Costs: cdk-nag itself is free, the check runs locally. The rules do, however, lead you to settings that can cost money in production, such as point-in-time recovery for DynamoDB, stored access logs and X-Ray traces. Check the pricing pages of the services before you deploy.
Migrating from cdk-nag v2 to v3
For existing projects, this is the order I would follow. Sticking to it means you don’t chase compiler errors and new findings at the same time.
- First raise
aws-cdk-libto at least 2.257.0 and build the stack without updating cdk-nag. - Then update cdk-nag to 3.x and replace
Aspects.of(app).add(...)withValidations.of(app).addPlugins(...). - Rewrite all
NagSuppressionscalls asacknowledge. For wildcard rules, list each finding separately with its suffix. - Rework custom rule packs:
visitbecomescheckResource, and you addpublic readonly name. - Switch tools that used to read cdk-nag CSV reports to the CDK validation report, or enable the
writeSuppressionsToCloudFormationoption. - Finally, add the tests from this article to lock in the result.
In a project that has grown over time, expect more findings after the switch than before. That is mainly due to the wildcard hits that now have to be acknowledged one by one. It is annoying, but honest: each of these lines used to be a blanket exception that nobody questioned anymore.
If you want the most important CDK commands at hand, take a look at my AWS CDK cheat sheet.
Common mistakes with cdk-nag v3
Warnings stop the synth
The CDK documentation says that warnings are reported but do not stop the synth. In my test with aws-cdk-lib 2.275.0 and cdk-nag 3.0.2 it was different: a single warning from AwsSolutions-DDB3 was enough, and cdk synth ended with exit code 1. So plan to either fix warnings or acknowledge them deliberately.
An acknowledgment without a suffix is no longer enough
If you carry over { id: 'AwsSolutions-IAM5' } from v2, you will wonder why the findings remain. According to the migration guide, prefix matching is not supported yet. Copy the full ID including the square brackets from the CLI output.
The acknowledgment sits too high up
An acknowledgment applies to the construct and everything below it. If you put AwsSolutions-IAM5[Resource::*] on the stack instead of the function, it will cover every new wildcard in the entire stack from then on. Place acknowledgments as deep as possible, in the example on the function rather than the stack.
The report lives somewhere else
The CSV and JSON reports from cdk-nag no longer exist. In my test the report was written as validation-report.json to the cdk.out folder. With the context flag @aws-cdk/core:validationReportJson you also get policy-validation-report.json, as described in the CDK documentation. After the update, check which file your pipeline actually reads.
The negative test is missing
A test that only expects “no findings” is also green when the rule pack doesn’t run at all. At least one test should deliberately build a violation and expect the finding.
My take
I think the rewrite is the right move, even if the update hurts. It turns cdk-nag into a regular part of the CDK toolchain instead of a special solution with its own suppression logic. Mandatory reasons for acknowledgments and per-finding wildcard confirmations push teams toward more honest decisions.
Two things I see critically. First, the gap between documentation and behavior for warnings, which can cause surprises in pipelines. Second, cdk-nag does not replace controls in the account. The AWS documentation itself says that checks at synth time are an early aid that developers can bypass. For real compliance you also need CloudFormation Hooks or AWS Config. As fast feedback in tests, though, cdk-nag is hard to beat. You will find similar ideas for the Serverless Framework in Testing the Serverless Framework with Vitest.
FAQ
Does NagSuppressions still exist in cdk-nag v3?
No. The class has been removed. You set exceptions with Validations.of(construct).acknowledge({ id, reason }) from aws-cdk-lib.
Which aws-cdk-lib version does cdk-nag v3 need?
At least aws-cdk-lib 2.257.0. Older versions do not provide the policy validation framework in the required form.
Do I need an AWS account for cdk-nag tests?
No. validateScope() and cdk synth run completely locally. You only need credentials for cdk deploy.
Can I use cdk-nag with the Serverless Framework?
Not directly, cdk-nag works with CDK constructs. With the cloudformation-include module, however, you can load and check an existing CloudFormation template, including the output of serverless package.
Your next step
Take an existing CDK project, register AwsSolutionsChecks and write the negative test first. Then work through the findings one by one: fix where you can, explain where you can’t. Which cdk-nag rule has the most acknowledgments in your team, and how many of them would survive an honest reason?
All code from this article, ready to run: smirowstanitzok/sascha-ms-examples
Sources
- cdk-nag on GitHub (README)
- cdk-nag: Migrating from v2 to v3
- cdk-nag release v3.0.0
- cdk-nag: overview of the rule packs
- AWS CDK Developer Guide: Policy validation at synthesis time
- cdk-nag on npm
- Tested with Node.js 22, aws-cdk-lib 2.275.0, cdk-nag 3.0.2, aws-cdk 2.1148.0, Vitest 5.1.0 and TypeScript 7.0.2 (7 tests green,
tsc --noEmitwithout errors,cdk synthsuccessful). Not deployed.
This post was created with AI assistance.
👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.
📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.