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 synth command 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:

NagPackFrameworkCoverage
AwsSolutionsChecksAWS best practice guidanceS3, IAM, Lambda, API Gateway, RDS, CloudTrail, VPC, SQS, SNS, ECS, Cognito
HIPAASecurityChecksHIPAA Security Rule (45 CFR §164.3)Encryption at rest and in transit, CloudTrail audit trails, IAM access controls, public access blocked, backup and versioning
NIST80053R5ChecksNIST SP 800-53 Rev 5AC (access control), AU (audit and accountability), SC (comms protection), SI (system integrity), IA (authentication)
PCIDSS321ChecksPCI DSS 3.2.1Req 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:

ServiceWhat it checks
S3Server access logging, SSL-only access, public access block, versioning, default encryption
IAMNo AWS-managed policies, no wildcard actions or resources, no inline policies
LambdaNon-deprecated runtime, reserved concurrency configured
API GatewayAccess logging, request authorizer configured, WAF association
RDSMulti-AZ, storage encryption, backup retention, deletion protection
CloudTrailLog file validation, CloudWatch Logs integration
VPCFlow logs enabled
SQSSSL enforcement, SSE enabled, DLQ configured
SNSSSL enforcement, SSE enabled
ECSNo secrets in environment variables, read-only root filesystem
CognitoMFA 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:

ResourceRuleStatus
Service roleIAM4Suppressed
Service roleIAM5Compliant
SNS topicSNS3Compliant
Lambda ServiceRoleIAM4Suppressed
Lambda ServiceRoleIAM5Compliant
Lambda DefaultPolicyIAM5Suppressed
Lambda functionL1Compliant
LogRetention ServiceRoleIAM4Suppressed
LogRetention ServiceRoleIAM5Compliant
LogRetention DefaultPolicyIAM5Suppressed

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

  1. 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.
  2. Start with AwsSolutionsChecks and add other NagPacks once the AwsSolutions findings are clean - running all packs at once on a new codebase produces a lot of noise.
  3. An acknowledgment without a reason string will not be accepted by cdk-nag. The reason field is required and shows up in the audit report.
  4. cdk-nag does not check runtime behaviour - it checks the synthesised CloudFormation template. It catches misconfiguration, not application bugs.
  5. 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.