NAG Utilities¶
The infrastructure.nag_utils module holds the shared cdk-nag suppression
helpers used by the stacks.
API reference¶
infrastructure.nag_utils
¶
Shared cdk-nag helpers.
cdk-nag v3 rule packs are IPolicyValidationPlugins evaluated over the
synthesized assembly, not per-stack IAspects — so pack attachment and
suppression plumbing both live here in v3 form:
attach_nag_packs registers the five rule packs as policy-validation
plugins on an App (or test App) root — once per tree, not once per stack.
apply_compliance_aspects remains per-stack but now carries only this
project's own TemplateConventionChecks validation Aspect (log-group
retention + explicit removal policy on stateful resources — see
infrastructure.validation_aspects). NIST 800-53 R4 is intentionally
omitted — R5 supersedes it and running both would duplicate findings on
overlapping controls.
acknowledge_rules is the project-wide adapter from this repo's
suppression data shape ({id, reason, applies_to?} dicts, unchanged from
the v2 era so every call site's data stays diff-stable) onto v3's
Validations.of(construct).acknowledge(): each applies_to entry
expands to the granular Rule[Finding] id v3 requires, and entries
without applies_to acknowledge the bare rule id. Acknowledgments apply
to the construct's whole subtree (v3 walks the ancestor tree when matching),
which subsumes v2's apply_to_children=True.
CDK_LAMBDA_SUPPRESSIONS is the canonical suppression list for CDK-managed
singleton Lambdas (AwsCustomResource provider, BucketDeployment, S3AutoDeleteObjects).
Their runtime, memory, tracing, DLQ, VPC, and IAM policies are all managed by
CDK and cannot be configured by the caller. Import it and pass it to
acknowledge_rules, or use the suppress_cdk_singletons helper.
Absolute-path suppression is intentionally avoided throughout this project:
the singletons are resolved via node.try_find_child so suppressions
keep working when the stacks are nested under a cdk.Stage (a path string
would break on the added Stage prefix).
acknowledge_rules(construct, suppressions)
¶
Acknowledge cdk-nag findings on construct (v3 Validations API).
The project-wide adapter from this repo's suppression data shape —
{"id": rule, "reason": why, "applies_to": [findings...]?}, unchanged
from the v2 era — onto Validations.of().acknowledge():
- each
applies_toentry expands to the granularRule[Finding]id v3 matches individually (e.g.AwsSolutions-IAM5[Resource::*]); - entries without
applies_toacknowledge the bare rule id, which matches rules that report a single, non-granular finding.
Acknowledgments cover the construct's whole subtree — v3 walks the
ancestor tree when checking a finding — so v2's apply_to_children
distinction no longer exists.
Metadata fallback for validator-rejected ids. CDK's
Validations.acknowledge rejects any id containing more than one
::, but cdk-nag 3.0.1's own IAM4 findings embed managed-policy ARNs
(AwsSolutions-IAM4[Policy::arn:<AWS::Partition>:iam::aws:policy/…]) —
the packs emit finding ids their acknowledge API refuses to accept. The
plugin's matching reads the aws:cdk:acknowledged-rules construct
metadata that acknowledge() writes, WITHOUT re-validating (verified
empirically against 3.0.1), so ids the front door rejects are written to
that metadata key directly. Drop the fallback once cdk-nag/CDK reconcile
the id grammar upstream.
Source code in infrastructure/nag_utils.py
apply_compliance_aspects(stack)
¶
Attach this project's validation Aspect to stack.
Carries TemplateConventionChecks plus cdk-nag's
WriteNagSuppressionsToCloudFormationAspect since cdk-nag v3: the five
rule packs are policy-validation plugins registered once at the App root
via :func:attach_nag_packs, not per-stack Aspects. The project-specific
invariants (log-group retention, explicit removal policies on stateful
resources) remain a per-stack Aspect surfacing error annotations, and the
write-suppressions aspect keeps the v2-style cdk_nag Metadata audit
trail (acknowledged rules + reasons) in every synthesized template — it
must be per stack because aspects do not cross cdk.Stage boundaries.
Source code in infrastructure/nag_utils.py
attach_async_failure_destination(scope, singleton_id, *, encryption_key, queue_id)
¶
Wire an SQS DLQ to a CDK-managed async singleton Lambda.
CDK-managed provider Lambdas (the AwsCustomResource provider, the BucketDeployment handler) are invoked asynchronously by CloudFormation during stack lifecycle events. Without an on_failure destination, a provider crash that exhausts Lambda's two automatic async retries is silently dropped — the stack rollback still surfaces a CFN error, but the cause (Python traceback, AWS API error response) is gone unless someone catches it in CloudWatch within the retention window. SQS as the on_failure destination preserves the failed-event envelope (full request payload + responseContext) for post-mortem.
The queue uses the same CMK as the surrounding stack, with 14-day retention (Lambda's max meaningful window — events older than that have already aged past most rollback investigations).
Returns the created queue so callers can attach alarms or outputs;
returns None if the singleton isn't present under scope (which
happens when no AwsCustomResource has been instantiated in this stack).
Source code in infrastructure/nag_utils.py
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 | |
attach_nag_packs(scope)
¶
Register the five cdk-nag v3 rule packs as policy-validation plugins.
Call once on the App root (app.py does; the nag-gating test fixtures
do the same on their test Apps). cdk-nag v3 packs participate in CDK's
policy validation framework: they evaluate the synthesized assembly
during app.synth() and fail the synthesis on unacknowledged findings —
they are no longer IAspects added per stack.
The v2-compatible cdk_nag Metadata audit trail is NOT enabled here:
the packs' write_suppressions_to_cloud_formation flag registers its
aspect at this (App) scope, and CDK aspects do not cross cdk.Stage
boundaries — Stage-nested stacks would silently lose the metadata
(verified empirically against 3.0.1). apply_compliance_aspects
registers WriteNagSuppressionsToCloudFormationAspect per stack
instead, which reaches every stack regardless of nesting.
Source code in infrastructure/nag_utils.py
build_managed_threat_rules(metric_prefix)
¶
Build the four AWS managed rule groups shared by every WebACL in this project.
Two WebACLs use these: the CLOUDFRONT-scoped ACL in WafStack
(browser traffic at the edge) and the REGIONAL-scoped ACL on API Gateway in
BackendApp (closes the execute-api CloudFront-bypass window). Both
need the identical IP-reputation / common / known-bad-inputs / anonymous-IP
protections, so the list is defined once here — pylint's R0801 duplicate-code
check would otherwise (correctly) flag two ~60-line copies drifting apart, and
a managed rule group that's added to one ACL but forgotten on the other is
exactly the kind of asymmetry this consolidation prevents.
No rate-based rule is part of this shared set because edge and origin need
different aggregation, so each ACL carries its own. The CLOUDFRONT ACL
aggregates by plain IP: it inspects the viewer request, so the source
IP it sees is already the real client's. On the regional ACL guarding the
origin, funnelled traffic arrives from CloudFront edge IPs (plain-IP
aggregation would pool many users behind a few shared edge IPs) — but
direct execute-api callers, the bypass path the regional ACL exists
to cover, arrive from their own IPs without the X-Forwarded-For header
CloudFront appends toward the origin. The regional ACL therefore carries an
IP-aggregated rate rule scoped down to XFF-less requests only — see
BackendApp._attach_regional_waf.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
metric_prefix
|
str
|
Prefix for each rule's CloudWatch metric name (the caller passes its stack name so metrics stay unique across deployments). |
required |
Returns:
| Type | Description |
|---|---|
list[RuleProperty]
|
The four managed-rule-group |
list[RuleProperty]
|
Callers that add a rate rule place it at priority 4. |
Source code in infrastructure/nag_utils.py
create_auto_delete_objects_log_group(scope, encryption_key)
¶
Create an explicit CMK log group for the CDK S3 auto-delete-objects singleton.
auto_delete_objects=True makes CDK synthesize a singleton Lambda that
empties a bucket before deletion. That Lambda's log group is created
implicitly by Lambda on first write — unencrypted and with no retention — so
it dangles after cdk destroy. This pre-creates an explicit CMK-encrypted,
retention-bounded log group with the Lambda's exact name so CloudFormation
owns and deletes it. Shared by every stack that uses auto_delete_objects
(frontend, audit) so the wiring stays in lockstep — pylint's R0801 would
otherwise flag the duplicated ~40-line block.
The provider lookup is type-checked at runtime: if a CDK upgrade swaps the
provider for an incompatible type, the isinstance returns None and the
block is skipped (surfaced as a warning) rather than crashing at synth. We
match CustomResourceProviderBase (not the narrower CustomResourceProvider)
because CDK 2.248's S3 auto-delete singleton synthesizes as the base type.
Returns the provider (or None) so the caller can attach suppressions to it.
Source code in infrastructure/nag_utils.py
create_sse_s3_log_bucket(scope, construct_id, *, suppression_reason, expiration_days, removal_policy, auto_delete, bucket_name=None, object_ownership=None)
¶
Create a standardized SSE-S3 log-sink bucket (+ the log-bucket nag suppressions).
The three log destinations in this project — the frontend access-log bucket, the CloudTrail-logs bucket, and the WAF-logs bucket — share the same posture: block all public access, SSE-S3 (the S3/CloudTrail/WAF delivery services don't support KMS-CMK destination buckets), SSL enforced, no versioning, and a lifecycle that expires objects. Centralizing it keeps them in lockstep (and keeps pylint's R0801 from flagging three near-identical bucket blocks). What varies — name, ACL ownership, lifecycle length, removal policy, auto-delete — is passed in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
Construct
|
Construct scope to create the bucket under. |
required |
construct_id
|
str
|
The bucket's construct id. |
required |
suppression_reason
|
str
|
The reason recorded on the log-bucket nag suppressions. |
required |
expiration_days
|
int
|
Objects expire after this many days. |
required |
removal_policy
|
RemovalPolicy
|
DESTROY for the destroy-friendly default, RETAIN behind retain_data. |
required |
auto_delete
|
bool
|
Whether to empty the bucket on stack delete (must be False when RETAIN). |
required |
bucket_name
|
str | None
|
Explicit name (only where AWS forces it, e.g. WAF's aws-waf-logs- prefix). |
None
|
object_ownership
|
ObjectOwnership | None
|
Set BUCKET_OWNER_PREFERRED where ACL-based log delivery needs it. |
None
|
Returns:
| Type | Description |
|---|---|
Bucket
|
The created bucket. |
Source code in infrastructure/nag_utils.py
create_waf_logs_bucket(scope, suffix)
¶
Create an aws-waf-logs-* S3 bucket wired for AWS WAF log delivery.
AWS WAF requires the destination bucket name to start with aws-waf-logs-
(an AWS hard requirement — so this is a pinned name, unlike the auto-named
buckets elsewhere). It's account-qualified for S3's global uniqueness plus a
short hash of the stack name so multi-env / multi-region deployments in one
account never collide on the name. Pinned-name caveat: a future
replacement-forcing property change collides with the not-yet-deleted old
bucket (CFN replacement is create-before-delete), so such a change must
also change the name (e.g. the suffix) in the same commit — see the
AppConfig profile note in backend_app.py.
The bucket policy must be complete before logging is enabled. When WAF
logging is turned on it ensures the delivery.logs.amazonaws.com write +
ACL-check grant is on the bucket; if no policy exists yet WAF creates one,
which then collides with CDK's own bucket policy (verified on a live deploy:
The bucket policy already exists). So this bucket pre-declares those
exact delivery statements (plus the SSL-deny and the auto-delete grant) in
its CDK-managed policy, and the caller orders the CfnLoggingConfiguration
after that policy (logging.node.add_dependency(bucket.policy)) — WAF
then finds the grant already present and leaves the policy alone. ACLs are
enabled (BucketOwnerPreferred) because WAF writes objects with a
bucket-owner-full-control ACL.
The caller points its CfnLoggingConfiguration.log_destination_configs at
the returned bucket's ARN, adds the policy dependency above, and must call
:func:create_auto_delete_objects_log_group once in the same stack (the
bucket uses auto_delete_objects). SSE-S3 (not CMK) because the WAF/Logs
delivery service doesn't support KMS-CMK destination buckets.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
Construct
|
Any construct in the target stack (the bucket is created at the owning stack's level so the auto-delete-provider wiring can find it). |
required |
suffix
|
str
|
Short discriminator for the bucket name (e.g. |
required |
Returns:
| Type | Description |
|---|---|
Bucket
|
The created bucket (point WAF logging at |
Source code in infrastructure/nag_utils.py
603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 | |
grant_cloudtrail_service_to_key(key, *, account, trail_arn)
¶
Grant CloudTrail the KMS operations needed to write CMK-encrypted trail log files.
CloudTrail needs explicit KMS grants on the encryption key to deliver
SSE-KMS log files. CDK's auto-grants from passing encryption_key= to
cloudtrail.Trail don't always extend to the cloudtrail service
principal when the key is shared with other services (CloudWatch Logs,
CloudFront, etc.), so the principal is added explicitly — mirroring the
logs grant above so all service-principal statements on the
project's CMKs live in one module and stay in lockstep.
Confused-deputy guard: scoped to the EXACT trail. The trail's name is
pinned in AuditStack (its ARN is constructed before the trail resource
exists — the same technique as the bucket-policy Deny statements there), so
aws:SourceArn can name the one trail allowed to use this key rather
than a trail/* wildcard — any other trail in this account is denied
too. CloudTrail sets aws:SourceArn to the trail ARN on every encrypt call;
aws:SourceAccount is checked as defense in depth (some older trail
integrations omit aws:SourceArn).
Source code in infrastructure/nag_utils.py
grant_cloudwatch_alarms_to_key(key, *, account, region)
¶
Grant CloudWatch alarms the KMS operations needed to publish to a CMK-encrypted SNS topic.
When an alarm fires against a topic with SSE enabled, SNS performs the KMS
data-key operations as the publishing service principal — so per the SNS
key-management docs, cloudwatch.amazonaws.com needs kms:Decrypt and
kms:GenerateDataKey* in the key policy. Without this grant the alarm
transitions to ALARM but the notification is silently dropped (the publish
is denied at KMS, and CloudWatch does not surface the failure anywhere
actionable) — the worst failure mode for an alerting path.
Confused-deputy guard: kms:ViaService pins the grant to KMS calls made
through SNS in this region (the only path CloudWatch alarm actions use),
and aws:SourceAccount restricts to alarms in this account.
aws:SourceArn is deliberately omitted: unlike CloudTrail
above, CloudWatch is not documented to set it on the via-SNS KMS calls, and
an unmatched required condition would deny the publish — recreating the
silent-drop failure this grant exists to prevent.
Verified on a live deployment (2026-06): forcing an alarm into ALARM via
set-alarm-state recorded "Successfully executed action
Source code in infrastructure/nag_utils.py
grant_logs_service_to_key(key, *, region, account, partition)
¶
Add the standard CloudWatch Logs service-principal grant to a CMK.
Three CMKs in this project (backend, frontend, WAF) need the same statement:
a grant to logs.{region}.amazonaws.com for symmetric encrypt/decrypt
operations, conditioned via kms:EncryptionContext:aws:logs:arn so only
log groups in this account+region can request key operations. Defining it
in one place keeps the three call sites in lockstep — pylint's R0801
duplicate-code check correctly flags any drift between them, and the
confused-deputy condition is exactly the kind of thing that's harmful to
forget on one of the three CMKs.
Source code in infrastructure/nag_utils.py
suppress_cdk_singletons(scope, singleton_ids)
¶
Apply CDK_LAMBDA_SUPPRESSIONS to any CDK-managed singletons present under scope.
Resolves each ID via node.try_find_child rather than an absolute path
string so suppressions survive being nested in a cdk.Stage. Missing IDs
are tolerated — some singletons only appear when the construct that needs
them is instantiated.
Source code in infrastructure/nag_utils.py
waf_logs_bucket_name(*, account, stack_name, suffix)
¶
Deterministic name for a WAF log bucket: aws-waf-logs-{account}-{hash}-{suffix}.
AWS forces the aws-waf-logs- prefix, so the name is pinned. Account-qualified
for S3's global uniqueness; a short hash of the stack name (which already encodes
env + region) keeps it collision-free and well under the 63-char limit. Shared by
create_waf_logs_bucket (the producer) and the Stage (which computes the WAF
log S3 path for the Athena Glue tables without a cross-stack reference) so the
name formula lives in exactly one place.