Coverage for scripts / dump_nag_findings.py: 100.00%

35 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +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. 

5 

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: 

14 

151. Iterates ``NAG_CONFIGS``, the list the pytest gate parametrizes over 

16 (imported from ``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 (``tests._cdk_app_builder``, the builder the gate uses), registers the 

19 five rule packs as policy-validation plugins, synthesizes, and reads the 

20 resulting ``validation-report.json``. 

213. Groups them by ``(pack, rule, resource_path)`` so a wildcard that 

22 appears in N configs shows up once with the list of config names 

23 rather than N repeated entries. 

244. Exits 0 if zero findings, 1 otherwise — which matches the pytest 

25 gate and means you can pipe this script's output to 

26 ``pre-commit`` or use it as a quick smoke before pushing. 

27 

28When NOT to use this 

29-------------------- 

30CI should use the pytest gate, not this script. This prints to stdout 

31and doesn't produce a junit.xml. For development use only. 

32 

33Relationship to other tooling 

34----------------------------- 

35* ``tests/test_nag_compliance.py`` — the PR gate. Parameterizes over 

36 the same ``NAG_CONFIGS`` and fails if any unacknowledged finding exists. 

37 cdk-nag v3 writes findings to ``validation-report.json`` in the cloud 

38 assembly; both this script and the gate read that file. 

39* ``tests/test_cdk_synthesis_matrix.py`` — runs ``app.synth()`` 

40 serially in-process for each config. Catches synth-time breakage; does 

41 NOT catch cdk-nag findings because ``app.synth()`` exits 0 even when 

42 unacknowledged findings exist. 

43 

44Typical workflow 

45---------------- 

46 # A finding shows up in CI on your PR. Reproduce locally: 

47 python3 scripts/dump_nag_findings.py 

48 

49 # Scope the acknowledgment in gco/stacks/nag_suppressions.py or 

50 # whichever construct owns the resource, ideally with 

51 # ``applies_to`` as tight as possible (prefer an exact literal 

52 # ARN/detail over an unscoped blanket). 

53 # Re-run: 

54 python3 scripts/dump_nag_findings.py 

55 # -> exits 0, no findings. 

56 

57 # Then run the full pytest gate to confirm (serially): 

58 pytest tests/test_nag_compliance.py -q 

59""" 

60 

61from __future__ import annotations 

62 

63import sys 

64from pathlib import Path 

65from typing import Any 

66 

67# Put the repo root on sys.path so ``tests._cdk_app_builder``, 

68# ``tests._cdk_config_matrix`` and ``tests.test_nag_compliance`` are importable 

69# when this script is 

70# invoked as ``python3 scripts/dump_nag_findings.py`` from any CWD. 

71REPO_ROOT = Path(__file__).resolve().parent.parent 

72if str(REPO_ROOT) not in sys.path: 

73 sys.path.insert(0, str(REPO_ROOT)) 

74 

75from tests._cdk_app_builder import synthesize_app # noqa: E402 

76from tests._cdk_config_matrix import NAG_CONFIGS as CONFIGS # noqa: E402 

77from tests.test_nag_compliance import _build_app, _collect_nag_violations # noqa: E402 

78 

79 

80def run_config(name: str, overrides: dict[str, object]) -> list[dict[str, Any]]: 

81 """Build and synthesize the full CDK app under one config overlay. 

82 

83 Mirrors what ``tests/test_nag_compliance.py::TestCdkNagCompliance`` 

84 does, minus the pytest wiring: ``_build_app`` registers the rule packs and 

85 ``synthesize_app`` builds every app.py stack and synthesizes offline, with 

86 the Docker image assets stood in so no Docker daemon is required. 

87 

88 Returns the list of finding dicts read from ``validation-report.json`` 

89 (keys: ``pack`` / ``rule`` / ``description`` / ``paths``). An empty 

90 list means the config is clean. 

91 """ 

92 app = _build_app(context_overrides=overrides) 

93 # Synthesis is what runs the validation plugins and writes 

94 # validation-report.json, so the findings are read only afterwards. 

95 synthesize_app(app) 

96 return _collect_nag_violations(app) 

97 

98 

99def main() -> int: 

100 # Track findings as {(pack, rule, resource_path): [config_names]} 

101 # so findings that repeat across configs collapse to one entry 

102 # with a list of where they showed up. The keys are deliberately 

103 # tuples (not dicts) so the ``sorted()`` call below is 

104 # deterministic — same input produces same output, which matters 

105 # when this output gets pasted into bug reports or commit 

106 # messages. 

107 all_findings: dict[tuple[str, str, str], list[str]] = {} 

108 

109 for name, overrides in CONFIGS: 

110 print(f"\n{'=' * 72}") 

111 print(f"CONFIG: {name}") 

112 print(f"{'=' * 72}") 

113 findings = run_config(name, overrides) 

114 print(f" total findings: {len(findings)}") 

115 for f in findings: 

116 paths = f["paths"] or ["(no construct path)"] 

117 for path in paths: 

118 key = (str(f["pack"]), str(f["rule"]), str(path)) 

119 all_findings.setdefault(key, []).append(name) 

120 

121 print(f"\n{'=' * 72}") 

122 print(f"UNIQUE FINDINGS ACROSS ALL CONFIGS: {len(all_findings)}") 

123 print(f"{'=' * 72}") 

124 for (pack, rule, path), cfgs in sorted(all_findings.items()): 

125 print(f"\n [{pack}] {rule}") 

126 print(f" path: {path}") 

127 # dedupe + sort configs so order is stable 

128 print(f" seen in: {', '.join(sorted(set(cfgs)))}") 

129 

130 # Non-zero exit if any finding exists — matches the pytest gate, 

131 # so this script can be used as a quick pre-push smoke. 

132 return 0 if not all_findings else 1 

133 

134 

135if __name__ == "__main__": 

136 raise SystemExit(main())