@hoanghainh/komagu 2.2.1 → 2.3.0

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 (36) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/assess/lsa/scripts/attack_chains.py +573 -0
  3. package/assess/lsa/scripts/baseline.py +14 -0
  4. package/assess/lsa/scripts/findings_to_sarif.py +11 -0
  5. package/assess/lsa/scripts/forecast.py +544 -0
  6. package/assess/lsa/scripts/merge_findings.py +14 -0
  7. package/assess/lsa/scripts/render_report.py +86 -0
  8. package/assess/lsa/tests/test_attack_chains.py +292 -0
  9. package/assess/lsa/tests/test_forecast.py +160 -0
  10. package/contracts/schemas/api-inventory-diff.schema.json +213 -0
  11. package/contracts/schemas/attack-chain.schema.json +181 -0
  12. package/contracts/schemas/bas-scenario.schema.json +217 -0
  13. package/contracts/schemas/evidence-manifest.schema.json +117 -0
  14. package/contracts/schemas/finding.schema.json +7 -0
  15. package/contracts/schemas/forecast.schema.json +172 -0
  16. package/governance/scs/__init__.py +21 -0
  17. package/governance/scs/mapping.py +133 -0
  18. package/governance/scs/pack.py +354 -0
  19. package/operate/cli/__init__.py +1 -1
  20. package/operate/cli/commands/evidence.py +138 -0
  21. package/operate/cli/commands/inventory.py +187 -0
  22. package/operate/cli/commands/simulate.py +175 -58
  23. package/operate/cli/main.py +7 -3
  24. package/operate/inventory/__init__.py +22 -0
  25. package/operate/inventory/api_inventory.py +566 -0
  26. package/operate/simulate/__init__.py +16 -0
  27. package/operate/simulate/engine.py +370 -0
  28. package/operate/simulate/loader.py +158 -0
  29. package/operate/simulate/scenarios/idc-frontier-ransomware.json +47 -0
  30. package/operate/simulate/scenarios/metabase-cve-2026-72898.json +84 -0
  31. package/operate/simulate/scenarios/times-car-breach.json +74 -0
  32. package/operate/simulate/scenarios.py +52 -0
  33. package/operate/tests/test_api_inventory.py +239 -0
  34. package/operate/tests/test_declarative_scenarios.py +169 -0
  35. package/operate/tests/test_evidence_pack.py +128 -0
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
4
4
 
5
+ ## [2.3.0](https://github.com/hoanghainh1188/komagu/compare/v2.2.1...v2.3.0) (2026-11-01)
6
+
7
+
8
+ ### Features
9
+
10
+ * **assess:** build attack chains from linked findings ([#316](https://github.com/hoanghainh1188/komagu/issues/316)) ([#344](https://github.com/hoanghainh1188/komagu/issues/344)) ([27c1aec](https://github.com/hoanghainh1188/komagu/commit/27c1aec1d53bfbcacd3d61e4844d2a821cbb95d4))
11
+ * **assess:** forecast security debt and target dates from scan history ([#318](https://github.com/hoanghainh1188/komagu/issues/318)) ([#346](https://github.com/hoanghainh1188/komagu/issues/346)) ([0441ccf](https://github.com/hoanghainh1188/komagu/commit/0441ccf0fcbfd950b644510d43893e49949ba0dd))
12
+ * **governance:** METI SCS ★3/★4 and Active Cyber Defense evidence pack ([#271](https://github.com/hoanghainh1188/komagu/issues/271)) ([#349](https://github.com/hoanghainh1188/komagu/issues/349)) ([88ddfc7](https://github.com/hoanghainh1188/komagu/commit/88ddfc7f2012a03bee7215a1da426401b0152303))
13
+ * **operate:** declarative scenario files for komagu simulate ([#317](https://github.com/hoanghainh1188/komagu/issues/317)) ([#348](https://github.com/hoanghainh1188/komagu/issues/348)) ([15585ee](https://github.com/hoanghainh1188/komagu/commit/15585eee079d5beb4ce4b9674d9c6fa92600ba82))
14
+ * **operate:** detect shadow and zombie APIs by comparing code routes, OpenAPI spec and Kong traffic ([#288](https://github.com/hoanghainh1188/komagu/issues/288)) ([#347](https://github.com/hoanghainh1188/komagu/issues/347)) ([0295373](https://github.com/hoanghainh1188/komagu/commit/0295373f67fb0dad0b0afe067561511a86de5a23))
15
+
5
16
  ## [2.2.1](https://github.com/hoanghainh1188/komagu/compare/v2.2.0...v2.2.1) (2026-11-01)
6
17
 
7
18
 
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env python3
2
+ """Build explainable multi-step attack chains from linked security findings.
3
+
4
+ Standard-library-only (Python 3.8+).
5
+
6
+ A chain links individual findings into realistic end-to-end exploit flows:
7
+ e.g., Mass Assignment (gain role) -> BFLA (admin access) -> BOLA (cross-tenant leak).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import datetime
13
+ import html
14
+ import json
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional, Set, Tuple
19
+
20
+ # Severity weights for sorting chains
21
+ SEVERITY_WEIGHT = {
22
+ "critical": 4,
23
+ "high": 3,
24
+ "medium": 2,
25
+ "low": 1,
26
+ "info": 0,
27
+ }
28
+
29
+ CONFIDENCE_RANK = {
30
+ "confirmed": 3,
31
+ "likely": 2,
32
+ "needs_review": 1,
33
+ }
34
+
35
+ # Regex patterns to detect privilege-granting or tenant-switching fields
36
+ ADMIN_FIELD_PATTERNS = re.compile(
37
+ r"(?i)\b(role|roles|is_?admin|admin|is_?staff|is_?superuser|group|groups|permission|permissions|privilege)\b"
38
+ )
39
+ TENANT_FIELD_PATTERNS = re.compile(
40
+ r"(?i)\b(tenant_?id|org_?id|company_?id|account_?id|workspace_?id)\b"
41
+ )
42
+ ADMIN_ROUTE_PATTERNS = re.compile(
43
+ r"(?i)(/admin/|/manage/|/internal/|/settings/|/system/|/users?/admin)"
44
+ )
45
+
46
+
47
+ def extract_entity_name(endpoint: str, location: str) -> str:
48
+ """Extract a representative resource entity name from an endpoint or file path."""
49
+ text = endpoint or location or ""
50
+ # Try route segments: e.g. /api/customers/{id} -> customers
51
+ segments = [s.strip("/") for s in text.split("/") if s and not s.startswith("{") and not s.startswith(":")]
52
+ ignored = {"api", "v1", "v2", "v3", "rest", "internal", "admin", "app"}
53
+ for seg in reversed(segments):
54
+ clean = re.sub(r"\.[a-zA-Z0-9]+$", "", seg) # strip extension (.php, .json, etc.)
55
+ if clean.lower() not in ignored and len(clean) > 2:
56
+ return clean.lower()
57
+ return "records"
58
+
59
+
60
+ def profile_finding(finding: Dict[str, Any], route_map: Optional[Dict[str, Dict]] = None) -> Dict[str, Any]:
61
+ """Assign entry requirements, role in chain, and capabilities gained for a finding."""
62
+ cat = (finding.get("category") or "").lower()
63
+ endpoint = finding.get("endpoint") or ""
64
+ loc = finding.get("location") or ""
65
+ attack_vec = finding.get("attack_vector") or {}
66
+ fields = attack_vec.get("fields") or {}
67
+
68
+ requires = "authenticated_user"
69
+ gains: List[str] = []
70
+ role_in_chain = "access"
71
+ entity = extract_entity_name(endpoint, loc)
72
+
73
+ # Check associated route if provided
74
+ route_id = finding.get("route_id") or finding.get("candidate_id")
75
+ route_priv = None
76
+ if route_map and route_id and route_id in route_map:
77
+ route_priv = route_map[route_id].get("privilege")
78
+
79
+ is_admin_endpoint = bool(ADMIN_ROUTE_PATTERNS.search(endpoint) or ADMIN_ROUTE_PATTERNS.search(loc))
80
+
81
+ if cat == "mass_assignment":
82
+ # Check if registration/signup endpoint (anonymous entry)
83
+ if any(w in endpoint.lower() or w in loc.lower() for w in ("register", "signup", "join", "onboard")):
84
+ requires = "anonymous"
85
+ role_in_chain = "entry_point"
86
+ else:
87
+ requires = "authenticated_user"
88
+ role_in_chain = "privilege_gain"
89
+
90
+ # Check fields targeted
91
+ field_names = " ".join(fields.keys()) if isinstance(fields, dict) else str(fields)
92
+ if not field_names:
93
+ field_names = finding.get("evidence") or loc
94
+
95
+ if ADMIN_FIELD_PATTERNS.search(field_names):
96
+ gains.append("privilege:admin")
97
+ elif TENANT_FIELD_PATTERNS.search(field_names):
98
+ gains.append("scope:other_tenant")
99
+ else:
100
+ gains.append("state:tamper")
101
+
102
+ elif cat == "bfla":
103
+ role_in_chain = "privilege_gain" if not is_admin_endpoint else "access"
104
+ if route_priv == "anonymous":
105
+ requires = "anonymous"
106
+ else:
107
+ requires = "authenticated_user"
108
+
109
+ # BFLA allows caller to perform administrative tasks
110
+ gains.append("privilege:admin")
111
+ gains.append(f"access:{entity}")
112
+
113
+ elif cat in ("bola", "idor"):
114
+ role_in_chain = "impact"
115
+ if is_admin_endpoint:
116
+ requires = "privilege:admin"
117
+ else:
118
+ requires = "authenticated_user"
119
+
120
+ gains.append(f"impact:leak:{entity}")
121
+ gains.append("impact:data_leak")
122
+
123
+ elif cat in ("sql_injection", "nosql_injection"):
124
+ role_in_chain = "impact"
125
+ if any(w in endpoint.lower() or w in loc.lower() for w in ("login", "auth", "register", "search", "public")):
126
+ requires = "anonymous"
127
+ elif is_admin_endpoint:
128
+ requires = "privilege:admin"
129
+ else:
130
+ requires = "authenticated_user"
131
+
132
+ gains.append("impact:db_read")
133
+ gains.append("impact:data_leak")
134
+ else:
135
+ # Fallback profile
136
+ requires = "authenticated_user"
137
+ gains.append(f"access:{cat}")
138
+
139
+ return {
140
+ "finding_id": finding.get("finding_id") or finding.get("fingerprint"),
141
+ "category": cat,
142
+ "subtype": finding.get("subtype", ""),
143
+ "location": loc,
144
+ "endpoint": endpoint,
145
+ "severity": finding.get("severity", "medium").lower(),
146
+ "confidence": finding.get("confidence", "likely").lower(),
147
+ "status": finding.get("status", "likely").lower(),
148
+ "requires": requires,
149
+ "gains": gains,
150
+ "role_in_chain": role_in_chain,
151
+ "entity": entity,
152
+ "raw": finding,
153
+ }
154
+
155
+
156
+ def _min_confidence(confidences: List[str]) -> str:
157
+ """Return the lowest confidence among a list of confidences."""
158
+ if not confidences:
159
+ return "likely"
160
+ ranked = sorted(confidences, key=lambda c: CONFIDENCE_RANK.get(c, 1))
161
+ return ranked[0]
162
+
163
+
164
+ def _max_severity(severities: List[str]) -> str:
165
+ """Return the highest severity among a list of severities."""
166
+ if not severities:
167
+ return "medium"
168
+ ranked = sorted(severities, key=lambda s: SEVERITY_WEIGHT.get(s, 0), reverse=True)
169
+ return ranked[0]
170
+
171
+
172
+ def build_attack_chains(
173
+ findings: List[Dict[str, Any]],
174
+ routes: Optional[List[Dict[str, Any]]] = None,
175
+ max_chains: int = 10,
176
+ max_length: int = 4,
177
+ ) -> Dict[str, Any]:
178
+ """Analyze findings and build explainable multi-step attack chains.
179
+
180
+ Returns a dictionary conforming to contracts/schemas/attack-chain.schema.json.
181
+ """
182
+ # Exclude rejected findings or suppressions
183
+ active_findings = [
184
+ f for f in findings
185
+ if f.get("status") != "rejected" and not f.get("suppression")
186
+ ]
187
+
188
+ # Map routes by id
189
+ route_map: Dict[str, Dict] = {}
190
+ if routes:
191
+ for r in routes:
192
+ rid = r.get("route_id")
193
+ if rid:
194
+ route_map[rid] = r
195
+
196
+ # Profile each finding
197
+ profiles = [profile_finding(f, route_map) for f in active_findings]
198
+ # Filter out profiles without a valid finding_id
199
+ profiles = [p for p in profiles if p["finding_id"]]
200
+
201
+ # Graph Traversal: search for chains from entry to impact
202
+ chains_found: List[Dict[str, Any]] = []
203
+
204
+ # Separate candidates by role
205
+ entry_candidates = [p for p in profiles if p["requires"] in ("anonymous", "authenticated_user")]
206
+ impact_candidates = [p for p in profiles if p["role_in_chain"] == "impact"]
207
+
208
+ if not impact_candidates or len(profiles) < 2:
209
+ # Not enough findings to form any multi-step chain
210
+ return _empty_chain_doc()
211
+
212
+ visited_paths: Set[Tuple[str, ...]] = set()
213
+
214
+ def search_chain(current_path: List[Dict[str, Any]], active_capabilities: Set[str]):
215
+ if len(current_path) > max_length:
216
+ return
217
+
218
+ last_step = current_path[-1]
219
+
220
+ # Check if the last step is an impact finding and path length >= 2
221
+ if len(current_path) >= 2 and last_step["role_in_chain"] == "impact":
222
+ path_ids = tuple(p["finding_id"] for p in current_path)
223
+ if path_ids not in visited_paths:
224
+ visited_paths.add(path_ids)
225
+ chains_found.append(_assemble_chain(current_path))
226
+ return
227
+
228
+ # Attempt to extend path with next finding
229
+ current_ids = {p["finding_id"] for p in current_path}
230
+ for candidate in profiles:
231
+ if candidate["finding_id"] in current_ids:
232
+ continue
233
+
234
+ # Check if candidate requirements are satisfied by active capabilities or caller
235
+ req = candidate["requires"]
236
+ if req in active_capabilities or req in ("anonymous", "authenticated_user"):
237
+ new_caps = set(active_capabilities) | set(candidate["gains"])
238
+ search_chain(current_path + [candidate], new_caps)
239
+
240
+ # Start DFS from each entry candidate
241
+ for entry in entry_candidates:
242
+ initial_caps = {"anonymous", "authenticated_user"} | set(entry["gains"])
243
+ search_chain([entry], initial_caps)
244
+
245
+ # Deduplicate and sort chains: critical first, then confirmed, then shorter length
246
+ sorted_chains = sorted(
247
+ chains_found,
248
+ key=lambda c: (
249
+ SEVERITY_WEIGHT.get(c["severity"], 0),
250
+ CONFIDENCE_RANK.get(c["confidence"], 0),
251
+ -len(c["steps"]),
252
+ ),
253
+ reverse=True,
254
+ )
255
+
256
+ # Keep top N unique chains
257
+ selected_chains = sorted_chains[:max_chains]
258
+
259
+ # Re-number chain IDs
260
+ for idx, chain in enumerate(selected_chains, start=1):
261
+ chain["chain_id"] = f"chain-{idx:03d}"
262
+
263
+ # Calculate Top Breaking Fixes
264
+ breaking_fix_counts: Dict[str, Dict[str, Any]] = {}
265
+ for chain in selected_chains:
266
+ for fid in chain["breaking_fixes"]:
267
+ if fid not in breaking_fix_counts:
268
+ # Find profile
269
+ prof = next((p for p in profiles if p["finding_id"] == fid), None)
270
+ breaking_fix_counts[fid] = {
271
+ "finding_id": fid,
272
+ "category": prof["category"] if prof else "unknown",
273
+ "location": prof["location"] if prof else "unknown",
274
+ "chains_broken": 0,
275
+ }
276
+ breaking_fix_counts[fid]["chains_broken"] += 1
277
+
278
+ top_breaking_fixes = sorted(
279
+ breaking_fix_counts.values(),
280
+ key=lambda b: b["chains_broken"],
281
+ reverse=True,
282
+ )
283
+
284
+ by_severity = {
285
+ s: sum(1 for c in selected_chains if c["severity"] == s)
286
+ for s in ("critical", "high", "medium", "low")
287
+ }
288
+
289
+ return {
290
+ "version": 1,
291
+ "generated_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
292
+ "chains": selected_chains,
293
+ "summary": {
294
+ "total_chains": len(selected_chains),
295
+ "by_severity": by_severity,
296
+ "top_breaking_fixes": top_breaking_fixes[:5],
297
+ },
298
+ }
299
+
300
+
301
+ def _assemble_chain(path: List[Dict[str, Any]]) -> Dict[str, Any]:
302
+ """Assemble a formatted attack chain from a sequence of finding profiles."""
303
+ steps: List[Dict[str, Any]] = []
304
+ severities: List[str] = []
305
+ confidences: List[str] = []
306
+ breaking_fixes: List[str] = []
307
+
308
+ for idx, p in enumerate(path, start=1):
309
+ steps.append({
310
+ "step_index": idx,
311
+ "finding_id": p["finding_id"],
312
+ "category": p["category"],
313
+ "subtype": p["subtype"],
314
+ "location": p["location"],
315
+ "endpoint": p["endpoint"],
316
+ "role_in_chain": p["role_in_chain"],
317
+ "requires": p["requires"],
318
+ "gains": p["gains"],
319
+ "explanation": f"Exploits {p['category']} at {p['endpoint'] or p['location']} to gain {', '.join(p['gains'])}.",
320
+ })
321
+ severities.append(p["severity"])
322
+ confidences.append(p["confidence"])
323
+ breaking_fixes.append(p["finding_id"])
324
+
325
+ entry_step = path[0]
326
+ final_step = path[-1]
327
+
328
+ # Generate a descriptive title
329
+ cats = [p["category"].replace("_", " ").title() for p in path]
330
+ title = " → ".join(cats) + f" targeting {final_step['entity']}"
331
+
332
+ outcome = f"Unauthorized access and data exfiltration of {final_step['entity']} records."
333
+ business_impact = (
334
+ f"Attackers can leverage initial {entry_step['category']} on {entry_step['endpoint'] or entry_step['location']} "
335
+ f"to achieve full compromise and data leakage of {final_step['entity']} records, causing regulatory breach and customer trust loss."
336
+ )
337
+
338
+ # Overall chain severity is at least high if it reaches impact, critical if it reaches admin/leak
339
+ chain_sev = _max_severity(severities)
340
+ if any("impact:data_leak" in p["gains"] for p in path) and any("privilege:admin" in p["gains"] for p in path):
341
+ chain_sev = "critical"
342
+
343
+ return {
344
+ "chain_id": "chain-000",
345
+ "title": title,
346
+ "entry": entry_step["requires"],
347
+ "steps": steps,
348
+ "outcome": outcome,
349
+ "business_impact": business_impact,
350
+ "severity": chain_sev,
351
+ "confidence": _min_confidence(confidences),
352
+ "breaking_fixes": breaking_fixes,
353
+ }
354
+
355
+
356
+ def _empty_chain_doc() -> Dict[str, Any]:
357
+ return {
358
+ "version": 1,
359
+ "generated_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
360
+ "chains": [],
361
+ "summary": {
362
+ "total_chains": 0,
363
+ "by_severity": {"critical": 0, "high": 0, "medium": 0, "low": 0},
364
+ "top_breaking_fixes": [],
365
+ },
366
+ }
367
+
368
+
369
+ def render_attack_chains_md(chains_doc: Dict[str, Any], lang: str = "en") -> List[str]:
370
+ """Generate Markdown report section for attack chains in English or Japanese."""
371
+ chains = chains_doc.get("chains", [])
372
+ if not chains:
373
+ return []
374
+
375
+ top_fixes = chains_doc.get("summary", {}).get("top_breaking_fixes", [])
376
+
377
+ if lang == "ja":
378
+ lines = [
379
+ "## 攻撃チェーン(複合攻撃シナリオ)",
380
+ "",
381
+ "単独では重大度が「中」または「高」にとどまる脆弱性であっても、攻撃者はそれらを組み合わせて権限昇格や大規模な情報漏洩を達成します。"
382
+ "以下の**最優先対策(Breaking Fixes)**を適用することで、最小限の修正工数で最も多くの攻撃シナリオを無力化できます。",
383
+ "",
384
+ "### 最優先対策(Breaking Fixes)",
385
+ "",
386
+ "| 検出ID | 脆弱性分類 | 該当箇所 | 遮断されるシナリオ数 |",
387
+ "|---|---|---|---|",
388
+ ]
389
+ for tf in top_fixes:
390
+ lines.append(f"| `{tf['finding_id']}` | {tf['category']} | `{tf['location']}` | **{tf['chains_broken']}** |")
391
+
392
+ lines += ["", "### 特定された攻撃シナリオ一覧", ""]
393
+ for c in chains:
394
+ lines += [
395
+ f"#### {c['chain_id']} — {c['title']}",
396
+ "",
397
+ f"- **開始権限:** `{c['entry']}`",
398
+ f"- **重要度 / 確信度:** {c['severity']} / {c['confidence']}",
399
+ f"- **結果 / 影響:** {c['outcome']}",
400
+ f"- **ビジネス影響:** {c['business_impact']}",
401
+ "- **攻撃ステップ:**",
402
+ ]
403
+ for s in c["steps"]:
404
+ lines.append(f" {s['step_index']}. `{s['finding_id']}` ({s['role_in_chain']}): {s['explanation']}")
405
+ lines.append(f"- **遮断に必要な対策:** {', '.join(f'`{f}`' for f in c['breaking_fixes'])}")
406
+ lines.append("")
407
+ else:
408
+ lines = [
409
+ "## Attack Chains (Chained Attack Scenarios)",
410
+ "",
411
+ "Real-world attacks combine multiple vulnerabilities into end-to-end exploit flows. "
412
+ "Fixing the bottleneck findings below breaks the most attack chains with minimum engineering effort.",
413
+ "",
414
+ "### Top Breaking Fixes",
415
+ "",
416
+ "| Finding ID | Category | Location | Chains Broken |",
417
+ "|---|---|---|---|",
418
+ ]
419
+ for tf in top_fixes:
420
+ lines.append(f"| `{tf['finding_id']}` | {tf['category']} | `{tf['location']}` | **{tf['chains_broken']}** |")
421
+
422
+ lines += ["", "### Identified Attack Scenarios", ""]
423
+ for c in chains:
424
+ lines += [
425
+ f"#### {c['chain_id']} — {c['title']}",
426
+ "",
427
+ f"- **Starting Privilege:** `{c['entry']}`",
428
+ f"- **Severity / Confidence:** {c['severity']} / {c['confidence']}",
429
+ f"- **Outcome:** {c['outcome']}",
430
+ f"- **Business Impact:** {c['business_impact']}",
431
+ "- **Attack Steps:**",
432
+ ]
433
+ for s in c["steps"]:
434
+ lines.append(f" {s['step_index']}. `{s['finding_id']}` ({s['role_in_chain']}): {s['explanation']}")
435
+ lines.append(f"- **Breaking Fixes:** {', '.join(f'`{f}`' for f in c['breaking_fixes'])}")
436
+ lines.append("")
437
+
438
+ return lines
439
+
440
+
441
+ def render_attack_chains_html(chains_doc: Dict[str, Any]) -> str:
442
+ """Generate self-contained bilingual HTML card visualizing attack chains."""
443
+ chains = chains_doc.get("chains", [])
444
+ if not chains:
445
+ return ""
446
+
447
+ top_fixes = chains_doc.get("summary", {}).get("top_breaking_fixes", [])
448
+
449
+ fix_rows = "".join(
450
+ f"<tr><td><code>{html.escape(tf['finding_id'])}</code></td>"
451
+ f"<td>{html.escape(tf['category'])}</td>"
452
+ f"<td><code>{html.escape(tf['location'])}</code></td>"
453
+ f"<td><span class=\"badge badge-critical\">{tf['chains_broken']}</span></td></tr>"
454
+ for tf in top_fixes
455
+ ) or "<tr><td colspan=\"4\">No breaking fixes identified.</td></tr>"
456
+
457
+ chain_cards: List[str] = []
458
+ for c in chains:
459
+ sev = c.get("severity", "high")
460
+ steps_html = []
461
+ for s in c.get("steps", []):
462
+ role_label = html.escape(s.get("role_in_chain", "step"))
463
+ cat = html.escape(s.get("category", ""))
464
+ fid = html.escape(s.get("finding_id", ""))
465
+ loc = html.escape(s.get("location", ""))
466
+ steps_html.append(
467
+ f'<div class="chain-step-node">'
468
+ f'<div class="chain-step-badge">Step {s.get("step_index", 1)}: {role_label}</div>'
469
+ f'<div class="chain-step-id"><strong>{fid}</strong> ({cat})</div>'
470
+ f'<div class="chain-step-loc">{loc}</div>'
471
+ f'</div>'
472
+ )
473
+ flow_html = ' <span class="chain-arrow">&rarr;</span> '.join(steps_html)
474
+
475
+ chain_cards.append(
476
+ f'<div class="chain-item-card">'
477
+ f'<div class="chain-item-header">'
478
+ f'<span class="badge badge-{sev}">{sev.upper()}</span> '
479
+ f'<strong>{html.escape(c.get("chain_id", ""))}</strong>: {html.escape(c.get("title", ""))}'
480
+ f'</div>'
481
+ f'<div class="chain-flow-diagram">{flow_html}</div>'
482
+ f'<div class="chain-impact-desc"><strong>Impact:</strong> {html.escape(c.get("business_impact", ""))}</div>'
483
+ f'</div>'
484
+ )
485
+
486
+ all_chains_html = "".join(chain_cards)
487
+
488
+ return (
489
+ '<section class="card" aria-label="Chained Attack Scenarios">'
490
+ '<h2 class="card-title" data-en="Chained Attack Scenarios &amp; Breaking Fixes" data-ja="複合攻撃シナリオ・最優先対策 (Attack Chains)">Chained Attack Scenarios &amp; Breaking Fixes</h2>'
491
+ '<p class="card-desc" data-en="Real-world attacks combine multiple vulnerabilities. Prioritize fixing the bottleneck findings below to neutralize the most attack chains with minimum engineering effort." '
492
+ 'data-ja="実際の攻撃は複数の脆弱性を組み合わせます。以下の最優先対策(Bottleneck)を実施することで、最小の工数で最大の防御効果が得られます。">'
493
+ 'Real-world attacks combine multiple vulnerabilities. Prioritize fixing the bottleneck findings below to neutralize the most attack chains with minimum engineering effort.</p>'
494
+ '<h3 data-en="Top Breaking Fixes (Max Impact)" data-ja="最優先対策(最大防御効果)">Top Breaking Fixes (Max Impact)</h3>'
495
+ '<table><thead><tr>'
496
+ '<th data-en="Finding ID" data-ja="検出ID">Finding ID</th>'
497
+ '<th data-en="Category" data-ja="脆弱性分類">Category</th>'
498
+ '<th data-en="Location" data-ja="該当箇所">Location</th>'
499
+ '<th data-en="Chains Broken" data-ja="遮断シナリオ数">Chains Broken</th>'
500
+ f'</tr></thead><tbody>{fix_rows}</tbody></table>'
501
+ '<h3 data-en="Identified Attack Flows" data-ja="特定された攻撃フロー">Identified Attack Flows</h3>'
502
+ f'<div class="chain-list-container">{all_chains_html}</div>'
503
+ '</section>'
504
+ )
505
+
506
+
507
+ def attach_chains_to_sarif(sarif_data: Dict[str, Any], chains_doc: Dict[str, Any]) -> Dict[str, Any]:
508
+ """Annotate SARIF results with attack chain memberships in properties.attackChains."""
509
+ if not chains_doc.get("chains") or not sarif_data.get("runs"):
510
+ return sarif_data
511
+
512
+ # Map finding_id to chain IDs
513
+ finding_to_chains: Dict[str, List[str]] = {}
514
+ for chain in chains_doc["chains"]:
515
+ cid = chain["chain_id"]
516
+ for step in chain["steps"]:
517
+ fid = step["finding_id"]
518
+ finding_to_chains.setdefault(fid, []).append(cid)
519
+
520
+ for run in sarif_data.get("runs", []):
521
+ for res in run.get("results", []):
522
+ rule_id = res.get("ruleId", "")
523
+ fingerprints = res.get("partialFingerprints", {})
524
+ kfp = fingerprints.get("komagu/v1") or fingerprints.get("komainu/v1")
525
+
526
+ matched_chains = set()
527
+ if rule_id in finding_to_chains:
528
+ matched_chains.update(finding_to_chains[rule_id])
529
+ if kfp and kfp in finding_to_chains:
530
+ matched_chains.update(finding_to_chains[kfp])
531
+
532
+ if matched_chains:
533
+ props = res.setdefault("properties", {})
534
+ props["attackChains"] = sorted(matched_chains)
535
+
536
+ return sarif_data
537
+
538
+
539
+ def main() -> int:
540
+ parser = argparse.ArgumentParser(description="Build attack chains from findings.json.")
541
+ parser.add_argument("out_dir", type=Path, help="Path to audit OUT directory.")
542
+ parser.add_argument("--routes", type=Path, default=None, help="Path to routes.json (optional).")
543
+ parser.add_argument("--output", type=Path, default=None, help="Output attack-chains.json path.")
544
+ args = parser.parse_args()
545
+
546
+ findings_file = args.out_dir / "findings.json"
547
+ if not findings_file.exists():
548
+ print(f"Error: {findings_file} not found.", file=sys.stderr)
549
+ return 1
550
+
551
+ with open(findings_file, encoding="utf-8") as fh:
552
+ findings = json.load(fh)
553
+
554
+ routes = None
555
+ routes_file = args.routes or (args.out_dir / "routes.json")
556
+ if routes_file.exists():
557
+ with open(routes_file, encoding="utf-8") as fh:
558
+ routes = json.load(fh)
559
+
560
+ chain_doc = build_attack_chains(findings, routes)
561
+
562
+ out_file = args.output or (args.out_dir / "attack-chains.json")
563
+ out_file.parent.mkdir(parents=True, exist_ok=True)
564
+ with open(out_file, "w", encoding="utf-8") as fh:
565
+ json.dump(chain_doc, fh, ensure_ascii=False, indent=2)
566
+ fh.write("\n")
567
+
568
+ print(f"✔ Generated {chain_doc['summary']['total_chains']} attack chains -> {out_file}")
569
+ return 0
570
+
571
+
572
+ if __name__ == "__main__":
573
+ sys.exit(main())
@@ -236,6 +236,20 @@ def main(argv: Optional[List[str]] = None) -> int:
236
236
  history = append_history(history, snapshot(counts, diff.findings, args.today))
237
237
  updated = update_baseline(entries, diff, args.today, history)
238
238
  write_json(findings_file, {**doc, "findings": diff.findings})
239
+ try:
240
+ from attack_chains import build_attack_chains
241
+ routes_file = out / "routes.json"
242
+ routes_list = read_json(routes_file).get("routes", []) if routes_file.exists() else []
243
+ chains_doc = build_attack_chains(diff.findings, routes_list)
244
+ write_json(out / "attack-chains.json", chains_doc)
245
+ except Exception:
246
+ pass
247
+ try:
248
+ from forecast import project_debt
249
+ forecast_doc = project_debt(history, today=args.today)
250
+ write_json(out / "forecast.json", forecast_doc)
251
+ except Exception:
252
+ pass
239
253
  write_json(out / "baseline-diff.json", {
240
254
  "generated": args.today,
241
255
  "baseline": str(baseline_path) if baseline_path else None,
@@ -374,6 +374,7 @@ def to_sarif(
374
374
  "confidence": f.get("confidence", "high"),
375
375
  "status": f.get("status", "unverified"),
376
376
  "cwe": norm_cwes,
377
+ **({"attackChains": sorted(f["attack_chains"])} if f.get("attack_chains") else {}),
377
378
  **replay_properties(f),
378
379
  },
379
380
  }
@@ -466,6 +467,16 @@ def main() -> int:
466
467
  end_time=t1,
467
468
  )
468
469
 
470
+ chains_file = out / "attack-chains.json"
471
+ if chains_file.is_file():
472
+ try:
473
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
474
+ from attack_chains import attach_chains_to_sarif # noqa: E402
475
+ chains_doc = read_json(chains_file)
476
+ sarif = attach_chains_to_sarif(sarif, chains_doc)
477
+ except Exception:
478
+ pass
479
+
469
480
  out_file = Path(a.sarif_out).resolve() if a.sarif_out else (out / "findings.sarif")
470
481
  out_file.parent.mkdir(parents=True, exist_ok=True)
471
482
  write_json(out_file, sarif)