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

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 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. 

26 

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. 

31 

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. 

42 

43Typical workflow 

44---------------- 

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

46 python3 scripts/dump_nag_findings.py 

47 

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. 

55 

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

57 pytest tests/test_nag_compliance.py -q 

58""" 

59 

60from __future__ import annotations 

61 

62import sys 

63from pathlib import Path 

64from typing import Any 

65from unittest.mock import MagicMock, patch 

66 

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)) 

73 

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) 

81 

82 

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. 

85 

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. 

90 

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 

101 

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) 

117 

118 

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]] = {} 

128 

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) 

140 

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)))}") 

149 

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 

153 

154 

155if __name__ == "__main__": 

156 raise SystemExit(main())