attackmap 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,335 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def load_eval_fixture(path: str | Path) -> dict[str, Any]:
11
+ return json.loads(Path(path).read_text(encoding="utf-8"))
12
+
13
+
14
+ def load_review_text(path: str | Path) -> str:
15
+ return Path(path).read_text(encoding="utf-8")
16
+
17
+
18
+ def _extract_section(text: str, heading: str) -> str:
19
+ pattern = re.compile(rf"^\s*##\s+{re.escape(heading)}\s*$", re.MULTILINE)
20
+ match = pattern.search(text)
21
+ if match is None:
22
+ return ""
23
+ start = match.end()
24
+ next_heading = re.search(r"^\s*##\s+.+$", text[start:], re.MULTILINE)
25
+ end = start + next_heading.start() if next_heading else len(text)
26
+ return text[start:end].strip()
27
+
28
+
29
+ def _extract_bullets(section_text: str) -> list[str]:
30
+ return [line.strip()[2:].strip() for line in section_text.splitlines() if line.strip().startswith("- ")]
31
+
32
+
33
+ def _count_matches(text: str, pattern: str) -> int:
34
+ return len(re.findall(pattern, text, flags=re.IGNORECASE))
35
+
36
+
37
+ def _missing_keyword_groups(text: str, groups: list[list[str]]) -> list[list[str]]:
38
+ """For each group, `text` must contain at least one member. Return
39
+ the groups that had *no* member present — those are the failures.
40
+
41
+ Enables the #44 fix: instead of requiring `["signing", "service"]`
42
+ literally (which fails when Claude legitimately writes `DPoP` or
43
+ `JWKS` for the same concept), the v2 rubric declares each concept
44
+ as a group of synonyms.
45
+ """
46
+ missing: list[list[str]] = []
47
+ lowered = text.lower()
48
+ for group in groups:
49
+ if not any(term.lower() in lowered for term in group):
50
+ missing.append(list(group))
51
+ return missing
52
+
53
+
54
+ def _flat_keywords(*, groups: list[list[str]] | None, legacy: list[str] | None) -> list[list[str]]:
55
+ """Normalize either the v2 group shape or the v1 flat shape into
56
+ groups. A flat list of `["a", "b"]` becomes `[["a"], ["b"]]` —
57
+ each term is its own single-synonym group. Preserves v1 behavior
58
+ exactly while letting the same code drive both."""
59
+ if groups:
60
+ return [list(g) for g in groups]
61
+ if legacy:
62
+ return [[term] for term in legacy]
63
+ return []
64
+
65
+
66
+ def evaluate_review_text(review_text: str, fixture: dict[str, Any]) -> dict[str, Any]:
67
+ expectations = fixture.get("expectations", {})
68
+ checks: list[dict[str, Any]] = []
69
+
70
+ grounding = expectations.get("grounding", {})
71
+ citation_pattern = grounding.get("citation_pattern", r"(?:surface|finding|path):\d+")
72
+ citations = sorted(set(re.findall(citation_pattern, review_text, flags=re.IGNORECASE)))
73
+ # Two ways to constrain which IDs are legitimate (see #44):
74
+ # - `allowed_id_pattern`: regex — flexible, accepts any evidence-pack
75
+ # ID shape (asset:*, insight:*, control:*, detect:*, plus higher
76
+ # surface/finding indices). Preferred for v2 rubrics.
77
+ # - `allowed_ids`: exact whitelist — v1 behavior, still supported.
78
+ # If both are set, the pattern wins.
79
+ allowed_id_pattern_raw = grounding.get("allowed_id_pattern")
80
+ if allowed_id_pattern_raw:
81
+ allowed_id_re = re.compile(allowed_id_pattern_raw, flags=re.IGNORECASE)
82
+ unknown_citations = sorted(c for c in citations if not allowed_id_re.fullmatch(c))
83
+ else:
84
+ allowed_ids = set(grounding.get("allowed_ids", []))
85
+ unknown_citations = (
86
+ sorted(citation for citation in citations if citation not in allowed_ids)
87
+ if allowed_ids
88
+ else []
89
+ )
90
+ min_citations = int(grounding.get("min_citations", 0))
91
+ grounding_pass = len(citations) >= min_citations and not unknown_citations
92
+ checks.append(
93
+ {
94
+ "name": "grounding",
95
+ "passed": grounding_pass,
96
+ "details": {
97
+ "citations_found": citations,
98
+ "unknown_citations": unknown_citations,
99
+ "min_citations_required": min_citations,
100
+ },
101
+ }
102
+ )
103
+
104
+ observed_inferred = expectations.get("observed_inferred", {})
105
+ observed_token = observed_inferred.get("observed_token", r"\bOBSERVED\b")
106
+ inferred_token = observed_inferred.get("inferred_token", r"\bINFERRED\b")
107
+ observed_count = _count_matches(review_text, observed_token)
108
+ inferred_count = _count_matches(review_text, inferred_token)
109
+ observed_required = int(observed_inferred.get("min_observed", 0))
110
+ inferred_required = int(observed_inferred.get("min_inferred", 0))
111
+ oi_pass = observed_count >= observed_required and inferred_count >= inferred_required
112
+ checks.append(
113
+ {
114
+ "name": "observed_vs_inferred_discipline",
115
+ "passed": oi_pass,
116
+ "details": {
117
+ "observed_count": observed_count,
118
+ "inferred_count": inferred_count,
119
+ "min_observed_required": observed_required,
120
+ "min_inferred_required": inferred_required,
121
+ },
122
+ }
123
+ )
124
+
125
+ strengths_expectation = expectations.get("strengths", {})
126
+ strengths_section = _extract_section(review_text, "Strengths")
127
+ strengths_items = _extract_bullets(strengths_section)
128
+ strengths_groups = _flat_keywords(
129
+ groups=strengths_expectation.get("required_keyword_groups"),
130
+ legacy=strengths_expectation.get("required_keywords"),
131
+ )
132
+ missing_strengths_groups = _missing_keyword_groups(strengths_section, strengths_groups)
133
+ min_strengths_items = int(strengths_expectation.get("min_items", 0))
134
+ strengths_pass = len(strengths_items) >= min_strengths_items and not missing_strengths_groups
135
+ checks.append(
136
+ {
137
+ "name": "strengths_coverage",
138
+ "passed": strengths_pass,
139
+ "details": {
140
+ "item_count": len(strengths_items),
141
+ "min_items_required": min_strengths_items,
142
+ "missing_keyword_groups": missing_strengths_groups,
143
+ },
144
+ }
145
+ )
146
+
147
+ weakness_expectation = expectations.get("weaknesses_hotspots", {})
148
+ weakness_section = _extract_section(review_text, "Weaknesses / Risk Hotspots")
149
+ weakness_items = _extract_bullets(weakness_section)
150
+ min_weakness_items = int(weakness_expectation.get("min_items", 0))
151
+ weakness_groups = _flat_keywords(
152
+ groups=weakness_expectation.get("required_term_groups"),
153
+ legacy=weakness_expectation.get("required_terms"),
154
+ )
155
+ missing_weakness_groups = _missing_keyword_groups(weakness_section, weakness_groups)
156
+ weakness_pass = len(weakness_items) >= min_weakness_items and not missing_weakness_groups
157
+ checks.append(
158
+ {
159
+ "name": "weakness_hotspot_quality",
160
+ "passed": weakness_pass,
161
+ "details": {
162
+ "item_count": len(weakness_items),
163
+ "min_items_required": min_weakness_items,
164
+ "missing_term_groups": missing_weakness_groups,
165
+ },
166
+ }
167
+ )
168
+
169
+ recommendation_expectation = expectations.get("recommendations", {})
170
+ recommendation_section = _extract_section(review_text, "Prioritized Recommendations")
171
+ recommendation_items = _extract_bullets(recommendation_section)
172
+ min_recommendation_items = int(recommendation_expectation.get("min_items", 0))
173
+ action_verbs = [verb.lower() for verb in recommendation_expectation.get("action_verbs", [])]
174
+ recommendation_text = recommendation_section.lower()
175
+ recommendation_verbs_present = [verb for verb in action_verbs if verb in recommendation_text]
176
+ # `action_verbs` is a single "at least one of these must appear" check —
177
+ # semantically already a group. Keep the flat list shape for
178
+ # backward-compat; a rubric can just widen it with synonyms.
179
+ recommendation_pass = len(recommendation_items) >= min_recommendation_items and bool(recommendation_verbs_present)
180
+ checks.append(
181
+ {
182
+ "name": "recommendation_usefulness",
183
+ "passed": recommendation_pass,
184
+ "details": {
185
+ "item_count": len(recommendation_items),
186
+ "min_items_required": min_recommendation_items,
187
+ "action_verbs_present": recommendation_verbs_present,
188
+ },
189
+ }
190
+ )
191
+
192
+ false_positive_expectation = expectations.get("false_positive_control", {})
193
+ banned_phrases = [phrase.lower() for phrase in false_positive_expectation.get("banned_phrases", [])]
194
+ lowered_review = review_text.lower()
195
+ found_banned = [phrase for phrase in banned_phrases if phrase in lowered_review]
196
+ caution_groups = _flat_keywords(
197
+ groups=false_positive_expectation.get("required_caution_groups"),
198
+ legacy=false_positive_expectation.get("required_cautions"),
199
+ )
200
+ missing_caution_groups = _missing_keyword_groups(review_text, caution_groups)
201
+ false_positive_pass = not found_banned and not missing_caution_groups
202
+ checks.append(
203
+ {
204
+ "name": "false_positive_control",
205
+ "passed": false_positive_pass,
206
+ "details": {
207
+ "found_banned_phrases": found_banned,
208
+ "missing_required_caution_groups": missing_caution_groups,
209
+ },
210
+ }
211
+ )
212
+
213
+ passed_count = sum(1 for check in checks if check["passed"])
214
+ total_count = len(checks)
215
+ score = round((passed_count / total_count) * 100.0, 2) if total_count else 0.0
216
+ return {
217
+ "fixture_id": fixture.get("fixture_id", "unknown"),
218
+ "target": fixture.get("target", {}),
219
+ "summary": {
220
+ "score": score,
221
+ "passed_checks": passed_count,
222
+ "total_checks": total_count,
223
+ "status": "pass" if passed_count == total_count else "fail",
224
+ },
225
+ "checks": checks,
226
+ }
227
+
228
+
229
+ def run_evaluation(fixture_path: str | Path, review_path: str | Path) -> dict[str, Any]:
230
+ fixture = load_eval_fixture(fixture_path)
231
+ review_text = load_review_text(review_path)
232
+ return evaluate_review_text(review_text, fixture)
233
+
234
+
235
+ def _resolve_review_path_for_fixture(fixture_path: Path, fixture: dict[str, Any], reviews_dir: Path) -> Path:
236
+ configured = fixture.get("review_file")
237
+ if isinstance(configured, str) and configured.strip():
238
+ return (reviews_dir / configured).resolve()
239
+ return (reviews_dir / f"{fixture_path.stem}.md").resolve()
240
+
241
+
242
+ def run_evaluation_suite(fixtures_dir: str | Path, reviews_dir: str | Path) -> dict[str, Any]:
243
+ fixture_dir_path = Path(fixtures_dir).resolve()
244
+ review_dir_path = Path(reviews_dir).resolve()
245
+ fixture_paths = sorted(fixture_dir_path.glob("*.json"))
246
+ cases: list[dict[str, Any]] = []
247
+
248
+ for fixture_path in fixture_paths:
249
+ fixture = load_eval_fixture(fixture_path)
250
+ review_path = _resolve_review_path_for_fixture(fixture_path, fixture, review_dir_path)
251
+ if not review_path.exists():
252
+ cases.append(
253
+ {
254
+ "fixture_id": fixture.get("fixture_id", fixture_path.stem),
255
+ "summary": {
256
+ "score": 0.0,
257
+ "passed_checks": 0,
258
+ "total_checks": 1,
259
+ "status": "fail",
260
+ },
261
+ "checks": [
262
+ {
263
+ "name": "review_file_presence",
264
+ "passed": False,
265
+ "details": {"missing_review_path": str(review_path)},
266
+ }
267
+ ],
268
+ }
269
+ )
270
+ continue
271
+ cases.append(run_evaluation(fixture_path, review_path))
272
+
273
+ passed = sum(1 for case in cases if case["summary"]["status"] == "pass")
274
+ total = len(cases)
275
+ avg_score = round(sum(case["summary"]["score"] for case in cases) / total, 2) if total else 0.0
276
+ return {
277
+ "suite": {
278
+ "fixtures_dir": str(fixture_dir_path),
279
+ "reviews_dir": str(review_dir_path),
280
+ "fixture_count": total,
281
+ "passed": passed,
282
+ "failed": total - passed,
283
+ "average_score": avg_score,
284
+ "status": "pass" if total > 0 and passed == total else "fail",
285
+ },
286
+ "cases": cases,
287
+ }
288
+
289
+
290
+ def _main() -> int:
291
+ parser = argparse.ArgumentParser(description="Evaluate AttackMap defensive review output against a fixture spec.")
292
+ parser.add_argument("--fixture", help="Path to evaluation fixture JSON.")
293
+ parser.add_argument("--review", help="Path to review markdown output to evaluate.")
294
+ parser.add_argument("--fixtures-dir", help="Directory containing evaluation fixture JSON files.")
295
+ parser.add_argument("--reviews-dir", help="Directory containing review markdown outputs for suite mode.")
296
+ parser.add_argument("--json", action="store_true", help="Print full JSON result.")
297
+ args = parser.parse_args()
298
+
299
+ if args.fixtures_dir or args.reviews_dir:
300
+ if not (args.fixtures_dir and args.reviews_dir):
301
+ parser.error("--fixtures-dir and --reviews-dir must be used together.")
302
+ if args.fixture or args.review:
303
+ parser.error("Use either single mode (--fixture/--review) or suite mode (--fixtures-dir/--reviews-dir), not both.")
304
+ suite_result = run_evaluation_suite(args.fixtures_dir, args.reviews_dir)
305
+ if args.json:
306
+ print(json.dumps(suite_result, indent=2))
307
+ else:
308
+ summary = suite_result["suite"]
309
+ print(f"Suite fixtures: {summary['fixture_count']}")
310
+ print(f"Pass: {summary['passed']}, Fail: {summary['failed']}, Average score: {summary['average_score']}")
311
+ print(f"Status: {summary['status']}")
312
+ for case in suite_result["cases"]:
313
+ case_summary = case["summary"]
314
+ print(f"- {case['fixture_id']}: {case_summary['status']} ({case_summary['score']})")
315
+ return 0 if suite_result["suite"]["status"] == "pass" else 1
316
+
317
+ if not (args.fixture and args.review):
318
+ parser.error("Single mode requires --fixture and --review.")
319
+
320
+ result = run_evaluation(args.fixture, args.review)
321
+ if args.json:
322
+ print(json.dumps(result, indent=2))
323
+ else:
324
+ summary = result["summary"]
325
+ print(f"Fixture: {result['fixture_id']}")
326
+ print(f"Score: {summary['score']} ({summary['passed_checks']}/{summary['total_checks']} checks)")
327
+ print(f"Status: {summary['status']}")
328
+ for check in result["checks"]:
329
+ mark = "PASS" if check["passed"] else "FAIL"
330
+ print(f"- {mark}: {check['name']}")
331
+ return 0 if result["summary"]["status"] == "pass" else 1
332
+
333
+
334
+ if __name__ == "__main__":
335
+ raise SystemExit(_main())
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ from .detection_opportunities import detection_coverage
4
+ from .models import AttackPath, AttackSurface, Finding, ScanResult
5
+ from .security_overlay import build_security_overlay
6
+
7
+ # 1.3.0: weaknesses[].locations (#214); 1.4.0: weaknesses[].rule_id/taxonomy
8
+ # (#250); 1.5.0: detection_opportunities[].sigma/... and detection_coverage (#251)
9
+ SCHEMA_VERSION = "1.5.0"
10
+ LOW_QUALITY_SEGMENTS = ("/tests/", "/__tests__/", "/fixtures/", "/mocks/", "/examples/")
11
+
12
+
13
+ def _severity_rank(value: str) -> int:
14
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
15
+
16
+
17
+ def _risk_rank(value: str) -> int:
18
+ return {"high": 0, "medium": 1, "low": 2}.get(value, 3)
19
+
20
+
21
+ def _is_low_quality_source(path_or_text: str) -> bool:
22
+ normalized = path_or_text.replace("\\", "/").lower()
23
+ return any(segment in f"/{normalized}/" for segment in LOW_QUALITY_SEGMENTS)
24
+
25
+
26
+ def _is_protocol_derived_surface(surface: AttackSurface) -> bool:
27
+ file_lower = surface.file.lower()
28
+ route_lower = surface.route.lower()
29
+ return "lexicon" in file_lower or "/xrpc/" in route_lower or "atproto_" in " ".join(surface.auth_signals).lower()
30
+
31
+
32
+ def _surface_evidence_class(surface: AttackSurface) -> str:
33
+ if _is_low_quality_source(surface.file):
34
+ return "low_quality"
35
+ if _is_protocol_derived_surface(surface):
36
+ return "inferred_protocol"
37
+ if surface.exposure == "public":
38
+ return "observed_runtime_public"
39
+ if surface.exposure == "internal":
40
+ return "observed_runtime_internal"
41
+ return "inferred"
42
+
43
+
44
+ def _evidence_class_counts(surfaces: list[AttackSurface]) -> dict[str, int]:
45
+ counts = {
46
+ "observed_runtime_public": 0,
47
+ "observed_runtime_internal": 0,
48
+ "inferred_protocol": 0,
49
+ "inferred": 0,
50
+ "low_quality": 0,
51
+ }
52
+ for surface in surfaces:
53
+ counts[_surface_evidence_class(surface)] += 1
54
+ return counts
55
+
56
+
57
+ def _strengths(scan: ScanResult, attack_surfaces: list[AttackSurface]) -> list[dict]:
58
+ strengths: list[dict] = []
59
+ auth_hints = sorted({hint.hint for hint in scan.auth_hints})
60
+ if auth_hints:
61
+ strengths.append(
62
+ {
63
+ "statement": "Authentication/identity indicators were observed in code signals.",
64
+ "evidence_basis": "observed",
65
+ "evidence": auth_hints[:8],
66
+ }
67
+ )
68
+ internal_surfaces = [surface for surface in attack_surfaces if surface.exposure == "internal"]
69
+ if internal_surfaces:
70
+ strengths.append(
71
+ {
72
+ "statement": "Some inferred entry points appear internal-only, reducing direct internet exposure when network boundaries are correctly enforced.",
73
+ "evidence_basis": "inferred",
74
+ "evidence": [f"{surface.method} {surface.route} ({surface.location()})" for surface in internal_surfaces[:6]],
75
+ }
76
+ )
77
+ if scan.secret_hints:
78
+ strengths.append(
79
+ {
80
+ "statement": "Secret references appear environment-driven rather than hardcoded literals.",
81
+ "evidence_basis": "observed",
82
+ "evidence": [f"{hint.name} ({hint.file})" for hint in scan.secret_hints[:8]],
83
+ }
84
+ )
85
+ if not strengths:
86
+ strengths.append(
87
+ {
88
+ "statement": "No strong defensive indicators were detected heuristically.",
89
+ "evidence_basis": "inferred",
90
+ "evidence": [],
91
+ }
92
+ )
93
+ return strengths[:4]
94
+
95
+
96
+ def _related_surfaces_for_finding(finding: Finding, attack_surfaces: list[AttackSurface]) -> list[AttackSurface]:
97
+ combined = f"{finding.title} {' '.join(finding.evidence)}".lower()
98
+ related = [
99
+ surface
100
+ for surface in attack_surfaces
101
+ if surface.route.lower() in combined
102
+ or surface.file.lower() in combined
103
+ or surface.category.lower() in combined
104
+ ]
105
+ return related or attack_surfaces
106
+
107
+
108
+ def _basis_label_for_surfaces(surfaces: list[AttackSurface]) -> str:
109
+ counts = _evidence_class_counts(surfaces)
110
+ total = max(sum(counts.values()), 1)
111
+ observed_total = counts["observed_runtime_public"] + counts["observed_runtime_internal"]
112
+ if (observed_total / total) >= 0.5:
113
+ return "observed"
114
+ if (counts["inferred_protocol"] / total) >= 0.5:
115
+ return "inferred_protocol"
116
+ if (counts["low_quality"] / total) >= 0.5:
117
+ return "low_quality"
118
+ return "mixed"
119
+
120
+
121
+ def _weaknesses_and_hotspots(
122
+ attack_surfaces: list[AttackSurface],
123
+ findings: list[Finding],
124
+ ) -> tuple[list[dict], list[dict]]:
125
+ ordered_findings = sorted(findings, key=lambda finding: (_severity_rank(finding.severity), finding.title))
126
+ weakness_items = []
127
+ for finding in ordered_findings[:8]:
128
+ related = _related_surfaces_for_finding(finding, attack_surfaces)
129
+ weakness_items.append(
130
+ {
131
+ "title": finding.title,
132
+ "rule_id": finding.rule_id,
133
+ "taxonomy": finding.taxonomy.model_dump() if finding.taxonomy else None,
134
+ "severity": finding.severity,
135
+ "confidence": finding.confidence,
136
+ "evidence_basis": _basis_label_for_surfaces(related),
137
+ "evidence": finding.evidence[:10],
138
+ "locations": [loc.model_dump() for loc in finding.locations],
139
+ "mitigation": finding.mitigation,
140
+ "attack_techniques": [t.model_dump() for t in finding.attack_techniques],
141
+ }
142
+ )
143
+
144
+ runtime_surfaces = [surface for surface in attack_surfaces if not _is_low_quality_source(surface.file)]
145
+ hotspot_pool = runtime_surfaces if runtime_surfaces else attack_surfaces
146
+ hotspot_pool = sorted(
147
+ hotspot_pool,
148
+ key=lambda surface: (_risk_rank(surface.risk), surface.exposure != "public", surface.route),
149
+ )
150
+ hotspot_items = [
151
+ {
152
+ "surface": {
153
+ "method": surface.method,
154
+ "route": surface.route,
155
+ "file": surface.file,
156
+ "category": surface.category,
157
+ "risk": surface.risk,
158
+ "exposure": surface.exposure,
159
+ },
160
+ "evidence_class": _surface_evidence_class(surface),
161
+ "notes": surface.rationale[:3],
162
+ }
163
+ for surface in hotspot_pool[:6]
164
+ ]
165
+ return weakness_items, hotspot_items
166
+
167
+
168
+ def _evidence_chains(attack_paths: list[AttackPath]) -> list[dict]:
169
+ chains = []
170
+ for path in attack_paths[:6]:
171
+ joined = " ".join(path.steps).lower()
172
+ basis = "inferred"
173
+ if "entry:" in joined and "confidence=" in joined:
174
+ basis = "observed_plus_inferred"
175
+ elif "entry:" in joined:
176
+ basis = "observed"
177
+ chains.append(
178
+ {
179
+ "name": path.name,
180
+ "impact": path.impact,
181
+ "evidence_basis": basis,
182
+ "steps": path.steps[:8],
183
+ }
184
+ )
185
+ return chains
186
+
187
+
188
+ def _recommendations(findings: list[Finding], attack_surfaces: list[AttackSurface]) -> list[dict]:
189
+ ordered_findings = sorted(findings, key=lambda finding: (_severity_rank(finding.severity), finding.title))
190
+ recommendations: list[dict] = []
191
+ seen: set[str] = set()
192
+ for finding in ordered_findings:
193
+ mitigation = finding.mitigation.strip()
194
+ if not mitigation or mitigation in seen:
195
+ continue
196
+ seen.add(mitigation)
197
+ related = _related_surfaces_for_finding(finding, attack_surfaces)
198
+ recommendations.append(
199
+ {
200
+ "priority": finding.severity,
201
+ "evidence_basis": _basis_label_for_surfaces(related),
202
+ "action": mitigation,
203
+ "linked_finding": finding.title,
204
+ }
205
+ )
206
+ if len(recommendations) >= 8:
207
+ break
208
+ return recommendations
209
+
210
+
211
+ def build_defensive_review_json(
212
+ scan: ScanResult,
213
+ attack_surfaces: list[AttackSurface],
214
+ findings: list[Finding],
215
+ attack_paths: list[AttackPath],
216
+ ) -> dict:
217
+ evidence_counts = _evidence_class_counts(attack_surfaces)
218
+ overlay = build_security_overlay(scan, attack_surfaces, findings, attack_paths)
219
+ weaknesses, hotspots = _weaknesses_and_hotspots(attack_surfaces, overlay.findings)
220
+ return {
221
+ "schema_version": SCHEMA_VERSION,
222
+ "target_metadata": {
223
+ "root": scan.root,
224
+ "files_scanned": scan.files_scanned,
225
+ "languages": scan.languages,
226
+ },
227
+ "system_overview": {
228
+ "repository_type": "web/service-facing" if scan.routes else "non-web",
229
+ "raw_inferred_entry_point_count": len(scan.routes),
230
+ "external_call_count": len(scan.external_calls),
231
+ "database_count": len(scan.databases),
232
+ "auth_hint_count": len(scan.auth_hints),
233
+ "secret_hint_count": len(scan.secret_hints),
234
+ "asset_count": len(overlay.assets),
235
+ "control_count_present": sum(1 for c in overlay.controls if c.strength != "absent"),
236
+ "control_count_absent": sum(1 for c in overlay.controls if c.strength == "absent"),
237
+ "notable_observation_count": len(overlay.insights),
238
+ "detection_opportunity_count": len(overlay.detection_opportunities),
239
+ "attack_techniques_observed_count": len(_flatten_attack_techniques(overlay)),
240
+ },
241
+ "attack_surface": {
242
+ "total_surfaces": len(attack_surfaces),
243
+ "evidence_class_counts": evidence_counts,
244
+ "surfaces": [
245
+ {
246
+ "method": surface.method,
247
+ "route": surface.route,
248
+ "file": surface.file,
249
+ "line": surface.line,
250
+ "category": surface.category,
251
+ "exposure": surface.exposure,
252
+ "risk": surface.risk,
253
+ "evidence_class": _surface_evidence_class(surface),
254
+ "auth_signals": surface.auth_signals,
255
+ "data_store_interaction": surface.data_store_interaction,
256
+ "outbound_integration": surface.outbound_integration,
257
+ }
258
+ for surface in attack_surfaces[:60]
259
+ ],
260
+ },
261
+ "assets": [asset.model_dump() for asset in overlay.assets],
262
+ "controls": [control.model_dump() for control in overlay.controls],
263
+ "notable_observations": [insight.model_dump() for insight in overlay.insights],
264
+ "detection_opportunities": [opp.model_dump() for opp in overlay.detection_opportunities],
265
+ "detection_coverage": detection_coverage(overlay.findings, overlay.detection_opportunities),
266
+ "attack_techniques_observed": _flatten_attack_techniques(overlay),
267
+ "strengths": _strengths(scan, attack_surfaces),
268
+ "weaknesses_risk_hotspots": {
269
+ "weaknesses": weaknesses,
270
+ "risk_hotspots": hotspots,
271
+ },
272
+ "evidence_chains": _evidence_chains(attack_paths),
273
+ "recommendations": _recommendations(overlay.findings, attack_surfaces),
274
+ "raw_structured_signals": {
275
+ "scan": scan.model_dump(),
276
+ "attack_surfaces": [surface.model_dump() for surface in attack_surfaces],
277
+ "findings": [finding.model_dump() for finding in overlay.findings],
278
+ "attack_paths": [path.model_dump() for path in attack_paths],
279
+ "assets": [asset.model_dump() for asset in overlay.assets],
280
+ "controls": [control.model_dump() for control in overlay.controls],
281
+ "notable_observations": [insight.model_dump() for insight in overlay.insights],
282
+ "detection_opportunities": [opp.model_dump() for opp in overlay.detection_opportunities],
283
+ },
284
+ "limitations_meta": {
285
+ "analysis_mode": "heuristic",
286
+ "defensive_only": True,
287
+ "notes": [
288
+ "Observed vs inferred classifications are heuristic and based on route, file, and signal patterns.",
289
+ "Low-quality paths (tests/fixtures/mocks/examples) are retained in raw signals but separated in evidence class counts.",
290
+ "Use this artifact as a triage source-of-truth; validate high-priority items with repository context and runtime controls.",
291
+ "Assets, controls, and notable observations are heuristic overlays; absent-control entries indicate scanner did not find evidence, not necessarily that the control is missing in production.",
292
+ "ATT&CK technique mappings and detection opportunities are derived from insight kinds and finding rule ids; treat them as triage hooks for SIEM/detection-engineering work, not authoritative MITRE mappings.",
293
+ "Generated Sigma rules are status experimental: map their log-source fields to your pipeline and tune them on your own traffic before alerting on them.",
294
+ ],
295
+ },
296
+ }
297
+
298
+
299
+ def _flatten_attack_techniques(overlay) -> list[dict]:
300
+ """Deduplicated list of ATT&CK techniques observed across insights and findings."""
301
+ by_id: dict[str, dict] = {}
302
+ for insight in overlay.insights:
303
+ for technique in insight.attack_techniques:
304
+ by_id.setdefault(technique.technique_id, technique.model_dump())
305
+ for finding in overlay.findings:
306
+ for technique in finding.attack_techniques:
307
+ by_id.setdefault(technique.technique_id, technique.model_dump())
308
+ return sorted(by_id.values(), key=lambda t: t["technique_id"])