This is Chapter 1 of the DevSecOps Guardrails series.
I wired cdk-nag in as the first guardrail in this series because it is CDK-native: it runs against the construct tree at synth time and blocks the build on any rule violation - before a changeset is created, before any AWS API call is made. It sees the construct level, not just the synthesised template, which means it can catch things cfn-lint cannot.
A five-line change to app.py is all the setup. The part that matters more is suppressions: knowing when to suppress versus fix, and writing a reason string that makes the decision visible in a code review. A synth failure exits non-zero, which blocks the pipeline stage in Jenkins, GitHub Actions, and Azure DevOps with no extra pipeline code needed.
The examples use the S3 + Lambda stack from the Infrastructure as Code with AWS CDK series.
What cdk-nag checks
cdk-nag is an open-source library from cdklabs that runs rule sets (called NagPacks) against a synthesised CDK app. It traverses the CDK construct tree and raises a finding for any resource that violates a rule.
Two severity levels:
- NagError - blocks synthesis. The
cdk synthcommand fails and no CloudFormation template is written until the violation is resolved or suppressed with a justification. - NagWarning - does not block synthesis. Worth reviewing but will not stop a deploy.
NagPacks that ship out of the box:
| NagPack | Framework | Coverage |
|---|---|---|
AwsSolutionsChecks | AWS best practice guidance | S3, IAM, Lambda, API Gateway, RDS, CloudTrail, VPC, SQS, SNS, ECS, Cognito |
HIPAASecurityChecks | HIPAA Security Rule (45 CFR §164.3) | Encryption at rest and in transit, CloudTrail audit trails, IAM access controls, public access blocked, backup and versioning |
NIST80053R5Checks | NIST SP 800-53 Rev 5 | AC (access control), AU (audit and accountability), SC (comms protection), SI (system integrity), IA (authentication) |
PCIDSS321Checks | PCI DSS 3.2.1 | Req 1-2 (network controls), Req 3-4 (encryption), Req 6 (patching), Req 7-8 (access and auth), Req 10 (logging and monitoring) |
For most CDK projects, AwsSolutionsChecks is the right starting point. Service-level checks:
| Service | What it checks |
|---|---|
| S3 | Server access logging, SSL-only access, public access block, versioning, default encryption |
| IAM | No AWS-managed policies, no wildcard actions or resources, no inline policies |
| Lambda | Non-deprecated runtime, reserved concurrency configured |
| API Gateway | Access logging, request authorizer configured, WAF association |
| RDS | Multi-AZ, storage encryption, backup retention, deletion protection |
| CloudTrail | Log file validation, CloudWatch Logs integration |
| VPC | Flow logs enabled |
| SQS | SSL enforcement, SSE enabled, DLQ configured |
| SNS | SSL enforcement, SSE enabled |
| ECS | No secrets in environment variables, read-only root filesystem |
| Cognito | MFA enabled |
HIPAASecurityChecks, NIST80053R5Checks, and PCIDSS321Checks overlap heavily in what they flag - unencrypted data stores, missing audit trails, overly permissive IAM - but each maps violations to a different regulation section and produces separate findings. A suppression in AwsSolutionsChecks does not carry across.
Installation
Run this from the project root - the same folder as app.py, cdk.json, and requirements.txt - with the virtual environment from Chapter 1 activated. Outside the project, or without the venv active, it installs somewhere pip freeze below won’t see:
pip install cdk-nag
Add cdk-nag to requirements.txt so it is installed in CI - pin it with pip freeze rather than a bare cdk-nag line, matching the rest of the file:
pip freeze | grep cdk-nag >> requirements.txt
Adding cdk-nag to the app
cdk-nag v3 dropped the Aspect-based wiring - NagPacks are now validation plugins registered through CDK’s native Validations API, not Aspects.of(app).add(). Register cdk-nag in app.py, after the stacks are instantiated:
import aws_cdk as cdk
from aws_cdk import Validations
from cdk_nag import AwsSolutionsChecks
from my_cdk_app.my_stack import MyStack
app = cdk.App()
env = app.node.try_get_context("env") or "dev"
config = app.node.try_get_context(env)
MyStack(app, "MyStack",
env=cdk.Environment(account="123456789012", region="us-east-1"),
config=config
)
Validations.of(app).add_plugins(AwsSolutionsChecks(app))
app.synth()
Every subsequent cdk synth run - locally or in CI - will check the synthesised template against the AwsSolutions rules.
Using additional NagPacks
add_plugins() takes more than one pack in a single call. In app.py, extend the same call from the previous section - add the others after AwsSolutionsChecks once those findings are clean; adding one at that point is a single extra import.
from cdk_nag import AwsSolutionsChecks, HIPAASecurityChecks, NIST80053R5Checks, PCIDSS321Checks
Validations.of(app).add_plugins(
AwsSolutionsChecks(app),
HIPAASecurityChecks(app), # HIPAA Security Rule
NIST80053R5Checks(app), # NIST 800-53 Rev 5
PCIDSS321Checks(app), # PCI DSS 3.2.1
)
Each pack runs independently and has its own rule IDs (e.g. HIPAA.Security-S3BucketLoggingEnabled, NIST.800.53.R5-S3BucketLoggingEnabled). A suppression for AwsSolutions-S1 does not suppress the equivalent rule in HIPAA or NIST - if the same resource violates rules across multiple packs, each needs its own suppression with a justification.
Start with AwsSolutionsChecks on its own. Running all four packs simultaneously on a codebase that has not been through any nag checks yet produces a lot of noise and makes it harder to prioritise.
Scoping to specific stacks
Validations.of()’s target is what controls scope - not a separate “which stacks” option on the NagPack itself. An app with 10 stacks can run cdk-nag against just one of them by pointing Validations.of() at that stack instance instead of app:
app = cdk.App()
stack1 = MyStack(app, "Stack1", env=cdk.Environment(account="123456789012", region="us-east-1"), config=config)
stack2 = MyStack(app, "Stack2", env=cdk.Environment(account="123456789012", region="us-east-1"), config=config)
# ... stacks 3-10 instantiated the same way
Validations.of(stack1).add_plugins(AwsSolutionsChecks(app))
app.synth()
The other 9 stacks still synth normally - they’re just not checked. Useful for rolling cdk-nag out across an existing multi-stack app one stack at a time, rather than turning it on everywhere at once and triaging every finding in a single pass.
For more than one stack, call Validations.of() once per target - there’s no multi-target version of the call:
Validations.of(stack1).add_plugins(AwsSolutionsChecks(app))
Validations.of(stack2).add_plugins(AwsSolutionsChecks(app))
When stacks are conditionally instantiated
A large app often synths one stack at a time via a CLI context value, rather than instantiating every stack on every run:
stack_name = app.node.try_get_context("stack")
stack1 = None
if stack_name in [None, "Stack1"]:
stack1 = MyStack(app, "Stack1", env=cdk.Environment(account="123456789012", region="us-east-1"), config=config)
stack2 = None
if stack_name in [None, "Stack2"]:
stack2 = MyStack(app, "Stack2", env=cdk.Environment(account="123456789012", region="us-east-1"), config=config)
# ... stacks 3-10 follow the same pattern
for stack in (stack1, stack2):
if stack is not None:
Validations.of(stack).add_plugins(AwsSolutionsChecks(app))
app.synth()
The None default and the guard in the loop matter here - running cdk synth --context stack=Stack5 means stack1 and stack2 are never instantiated that invocation, and calling Validations.of(None) raises rather than silently skipping.
How violations look
A bucket without server_access_logs_bucket set is the simplest trigger:
bucket = s3.Bucket(self, "FilesBucket",
bucket_name=config["bucket_name"]
)
No server_access_logs_bucket - this triggers AwsSolutions-S1, a NagError, at construct path /MyStack/FilesBucket/Resource. cdk-nag writes it to cdk.out/policy-validation-report.json, with a summary printed to the terminal.
Reading the report
cdk-nag emits one row per (resource, rule) pair where a rule applies to a resource type present in the stack. A small row count is not a sign the check ran partially.
The AwsSolutions pack has around 350 rules, but the majority target resource types that may not be in every stack - S3, RDS, EC2, ECS, API Gateway, Cognito. A stack built primarily around resource types without AwsSolutions coverage produces a short report: the supporting IAM roles, Lambda functions, and SNS topics generate rows, but the primary resources do not.
The rows that do appear cover exactly the resources with applicable rules. A stack with a handful of IAM roles, a Lambda, and an SNS topic produces 10 rows - that is the complete picture:
| Resource | Rule | Status |
|---|---|---|
| Service role | IAM4 | Suppressed |
| Service role | IAM5 | Compliant |
| SNS topic | SNS3 | Compliant |
| Lambda ServiceRole | IAM4 | Suppressed |
| Lambda ServiceRole | IAM5 | Compliant |
| Lambda DefaultPolicy | IAM5 | Suppressed |
| Lambda function | L1 | Compliant |
| LogRetention ServiceRole | IAM4 | Suppressed |
| LogRetention ServiceRole | IAM5 | Compliant |
| LogRetention DefaultPolicy | IAM5 | Suppressed |
All rules that fire are either suppressed with documented reasons or genuinely compliant. The primary resources produce no rows because AwsSolutions has no rules for those resource types.
Suppressions
Sometimes a rule genuinely does not apply - a development bucket that intentionally has no access logging, a Lambda that has a pinned runtime for a documented reason. v3 dropped NagSuppressions entirely; suppressing is now “acknowledging” a finding through the same Validations API used to register the NagPacks:
from aws_cdk import Validations, Acknowledgment
Validations.of(fn).acknowledge(
Acknowledgment(
id="AwsSolutions-L1",
reason="Runtime pinned to 3.12; upgrade tracked in backlog"
)
)
The acknowledgment lives in the same stack code as the resource it covers. That means it is code-reviewed alongside the change that triggered it - not buried in a config file or applied globally.
For a stack-wide acknowledgment (use sparingly):
Validations.of(self).acknowledge(
Acknowledgment(
id="AwsSolutions-IAM4",
reason="AWSLambdaBasicExecutionRole is an AWS-managed policy; risk accepted"
)
)
v3 also dropped bulk suppression by rule ID prefix - a wildcard finding like AwsSolutions-IAM5 on an auto-generated policy now needs its own specific ID per finding (e.g. AwsSolutions-IAM5[Resource::*]), taken from the actual finding text rather than guessed.
cdk-nag in Jenkins, GitHub Actions, and Azure DevOps
Because cdk-nag is registered as a validation plugin in app.py, it runs automatically on every cdk synth call - wherever that call happens. The synth step already exists in the Jenkins and GitHub Actions pipelines from Chapter 5 of the CDK series; Azure DevOps follows the same pattern. Adding cdk-nag to requirements.txt is all the pipeline change needed - no new pipeline code.
A NagError causes cdk synth to exit non-zero, and each platform blocks the deploy the same way:
- Jenkins - the Synth stage is marked failed; the Deploy stage never runs.
- GitHub Actions - the Synth step fails the job, which blocks the deploy job downstream via
needs: deploy-dev. - Azure DevOps - the Synth step is marked failed and the pipeline stops; the Deploy step never runs.
Nothing ships until the violation is fixed or acknowledged.
Generating a report
Pass verbose to a NagPack’s constructor for more detail in that report:
Validations.of(app).add_plugins(AwsSolutionsChecks(app, verbose=True))
If existing tooling depends on the v2-style cdk_nag metadata block in the synthesised CloudFormation template, cdk-nag has a flag to restore it - check the current docs for the exact option name before relying on it for an audit pipeline.
Surfacing the report in CI
The report is just a JSON file in cdk.out/ - archive it as a build artifact after the Synth stage so it’s visible without re-running the pipeline, including on a failed build.
Jenkins
stage('Synth') {
steps {
sh 'cdk synth -c env=dev'
}
post {
always {
archiveArtifacts artifacts: 'cdk.out/policy-validation-report.json', allowEmptyArchive: true
}
}
}
allowEmptyArchive: true stops the archive step itself from failing the build on a run where the file doesn’t exist (e.g. synth failed before writing it). The report shows up as a downloadable artifact on the build page.
GitHub Actions
- name: Synth
run: cdk synth -c env=dev
- name: Upload cdk-nag report
if: always()
uses: actions/upload-artifact@v4
with:
name: policy-validation-report
path: cdk.out/policy-validation-report.json
if: always() runs the upload step even if the Synth step above it failed - the report is attached to the workflow run.
Azure DevOps
- script: cdk synth -c env=dev
displayName: Synth
- task: PublishBuildArtifacts@1
condition: always()
inputs:
pathToPublish: 'cdk.out/policy-validation-report.json'
artifactName: policy-validation-report
condition: always() does the same job - the report attaches to the pipeline run regardless of whether the Synth step passed.
Notes
- Register cdk-nag in
app.py, not inside a stack. Registered at the app level it covers every stack in the app. Registered inside a stack it only covers that stack. - Start with
AwsSolutionsChecksand add other NagPacks once the AwsSolutions findings are clean - running all packs at once on a new codebase produces a lot of noise. - An acknowledgment without a
reasonstring will not be accepted by cdk-nag. The reason field is required and shows up in the audit report. - cdk-nag does not check runtime behaviour - it checks the synthesised CloudFormation template. It catches misconfiguration, not application bugs.
- This post targets cdk-nag v3 (3.0.0+, released June 2026). Pin your version in
requirements.txt- a major version bump like this will break a pipeline that was passing the day before.