Coverage for scripts / dump_nag_findings.py: 100.00%
43 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1#!/usr/bin/env python3
2"""Dev-only helper: run the cdk-nag compliance test harness and print
3a human-readable inventory of every finding, grouped by pack + rule +
4resource-path, with the config name(s) each finding appears under.
6When to use this
7----------------
8Reach for this script when ``tests/test_nag_compliance.py`` starts
9failing and you need a compact view of what cdk-nag is actually
10objecting to. pytest's default output buries the per-finding detail
11inside an ``AssertionError`` repr — useful for CI gate failures, but
12hard to read when you're trying to scope a new acknowledgment. This
13script:
151. Iterates the same ``CONFIGS`` list pytest does (imported from
16 ``tests._cdk_config_matrix`` so the two can never drift).
172. For each config, builds the full CDK app the way ``app.py`` does,
18 registers the five rule packs as policy-validation plugins, calls
19 ``app.synth()``, and reads the resulting ``validation-report.json``.
203. Groups them by ``(pack, rule, resource_path)`` so a wildcard that
21 appears in N configs shows up once with the list of config names
22 rather than N repeated entries.
234. Exits 0 if zero findings, 1 otherwise — which matches the pytest
24 gate and means you can pipe this script's output to
25 ``pre-commit`` or use it as a quick smoke before pushing.
27When NOT to use this
28--------------------
29CI should use the pytest gate, not this script. This prints to stdout
30and doesn't produce a junit.xml. For development use only.
32Relationship to other tooling
33-----------------------------
34* ``tests/test_nag_compliance.py`` — the PR gate. Parameterizes over
35 the same ``CONFIGS`` and fails if any unacknowledged finding exists.
36 cdk-nag v3 writes findings to ``validation-report.json`` in the cloud
37 assembly; both this script and the gate read that file.
38* ``tests/test_cdk_synthesis_matrix.py`` — runs ``app.synth()``
39 serially in-process for each config. Catches synth-time breakage; does
40 NOT catch cdk-nag findings because ``app.synth()`` exits 0 even when
41 unacknowledged findings exist.
43Typical workflow
44----------------
45 # A finding shows up in CI on your PR. Reproduce locally:
46 python3 scripts/dump_nag_findings.py
48 # Scope the acknowledgment in gco/stacks/nag_suppressions.py or
49 # whichever construct owns the resource, ideally with
50 # ``applies_to`` as tight as possible (prefer an exact literal
51 # ARN/detail over an unscoped blanket).
52 # Re-run:
53 python3 scripts/dump_nag_findings.py
54 # -> exits 0, no findings.
56 # Then run the full pytest gate to confirm (serially):
57 pytest tests/test_nag_compliance.py -q
58"""
60from __future__ import annotations
62import sys
63from pathlib import Path
64from typing import Any
65from unittest.mock import MagicMock, patch
67# Put the repo root on sys.path so ``tests._cdk_config_matrix`` and
68# ``tests.test_nag_compliance`` are importable when this script is
69# invoked as ``python3 scripts/dump_nag_findings.py`` from any CWD.
70REPO_ROOT = Path(__file__).resolve().parent.parent
71if str(REPO_ROOT) not in sys.path:
72 sys.path.insert(0, str(REPO_ROOT))
74from tests._cdk_config_matrix import CONFIGS # noqa: E402
75from tests.test_nag_compliance import ( # noqa: E402
76 _build_all_stacks,
77 _build_app,
78 _collect_nag_violations,
79 _mock_helm_installer,
80)
83def run_config(name: str, overrides: dict[str, object]) -> list[dict[str, Any]]:
84 """Build and synthesize the full CDK app under one config overlay.
86 Mirrors what ``tests/test_nag_compliance.py::TestCdkNagCompliance``
87 does, minus the pytest wiring. The Docker image asset and the
88 helm installer Lambda are both mocked so no Docker daemon is
89 required — same as the regional-stack unit tests.
91 Returns the list of finding dicts read from ``validation-report.json``
92 (keys: ``pack`` / ``rule`` / ``description`` / ``paths``). An empty
93 list means the config is clean.
94 """
95 # Late import because ``_build_app`` also imports gco.stacks under
96 # the hood — keep the import side-effects inside the call so
97 # ``python3 scripts/dump_nag_findings.py --help`` (or future arg
98 # parsing) doesn't pay the CDK-init cost.
99 from cli.stacks import cdk_asset_consumer
100 from gco.stacks.regional_stack import GCORegionalStack
102 with cdk_asset_consumer(REPO_ROOT):
103 app = _build_app(context_overrides=overrides)
104 with (
105 patch("gco.stacks.regional_stack.ecr_assets.DockerImageAsset") as mock_docker,
106 patch.object(GCORegionalStack, "_create_helm_installer_lambda", _mock_helm_installer),
107 ):
108 mock_image = MagicMock()
109 mock_image.image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/test:latest"
110 mock_docker.return_value = mock_image
111 _build_all_stacks(app)
112 # ``app.synth()`` is what runs the validation plugins and writes
113 # validation-report.json — must happen inside the ``with`` block
114 # so the Docker/helm mocks are still active during synthesis.
115 app.synth()
116 return _collect_nag_violations(app)
119def main() -> int:
120 # Track findings as {(pack, rule, resource_path): [config_names]}
121 # so findings that repeat across configs collapse to one entry
122 # with a list of where they showed up. The keys are deliberately
123 # tuples (not dicts) so the ``sorted()`` call below is
124 # deterministic — same input produces same output, which matters
125 # when this output gets pasted into bug reports or commit
126 # messages.
127 all_findings: dict[tuple[str, str, str], list[str]] = {}
129 for name, overrides in CONFIGS:
130 print(f"\n{'=' * 72}")
131 print(f"CONFIG: {name}")
132 print(f"{'=' * 72}")
133 findings = run_config(name, overrides)
134 print(f" total findings: {len(findings)}")
135 for f in findings:
136 paths = f["paths"] or ["(no construct path)"]
137 for path in paths:
138 key = (str(f["pack"]), str(f["rule"]), str(path))
139 all_findings.setdefault(key, []).append(name)
141 print(f"\n{'=' * 72}")
142 print(f"UNIQUE FINDINGS ACROSS ALL CONFIGS: {len(all_findings)}")
143 print(f"{'=' * 72}")
144 for (pack, rule, path), cfgs in sorted(all_findings.items()):
145 print(f"\n [{pack}] {rule}")
146 print(f" path: {path}")
147 # dedupe + sort configs so order is stable
148 print(f" seen in: {', '.join(sorted(set(cfgs)))}")
150 # Non-zero exit if any finding exists — matches the pytest gate,
151 # so this script can be used as a quick pre-push smoke.
152 return 0 if not all_findings else 1
155if __name__ == "__main__":
156 raise SystemExit(main())