@hoanghainh/komagu 2.2.1 → 2.4.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 (44) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assess/lsa/CHANGELOG.md +15 -0
  3. package/assess/lsa/scripts/attack_chains.py +573 -0
  4. package/assess/lsa/scripts/baseline.py +14 -0
  5. package/assess/lsa/scripts/detect_stack.py +2 -1
  6. package/assess/lsa/scripts/findings_to_sarif.py +11 -0
  7. package/assess/lsa/scripts/forecast.py +544 -0
  8. package/assess/lsa/scripts/lsa_common.py +1 -5
  9. package/assess/lsa/scripts/merge_findings.py +14 -0
  10. package/assess/lsa/scripts/redaction.py +118 -0
  11. package/assess/lsa/scripts/render_report.py +86 -0
  12. package/assess/lsa/tests/test_attack_chains.py +292 -0
  13. package/assess/lsa/tests/test_forecast.py +160 -0
  14. package/assess/lsa/tests/test_modern_stacks.py +13 -0
  15. package/contracts/schemas/api-inventory-diff.schema.json +213 -0
  16. package/contracts/schemas/attack-chain.schema.json +181 -0
  17. package/contracts/schemas/bas-scenario.schema.json +217 -0
  18. package/contracts/schemas/evidence-manifest.schema.json +117 -0
  19. package/contracts/schemas/finding.schema.json +7 -0
  20. package/contracts/schemas/forecast.schema.json +172 -0
  21. package/governance/scs/__init__.py +21 -0
  22. package/governance/scs/mapping.py +133 -0
  23. package/governance/scs/pack.py +354 -0
  24. package/operate/ai/context.py +22 -2
  25. package/operate/cli/__init__.py +1 -1
  26. package/operate/cli/commands/evidence.py +138 -0
  27. package/operate/cli/commands/inventory.py +187 -0
  28. package/operate/cli/commands/simulate.py +175 -58
  29. package/operate/cli/main.py +7 -3
  30. package/operate/inventory/__init__.py +22 -0
  31. package/operate/inventory/api_inventory.py +566 -0
  32. package/operate/simulate/__init__.py +16 -0
  33. package/operate/simulate/engine.py +370 -0
  34. package/operate/simulate/loader.py +158 -0
  35. package/operate/simulate/scenarios/idc-frontier-ransomware.json +47 -0
  36. package/operate/simulate/scenarios/metabase-cve-2026-72898.json +84 -0
  37. package/operate/simulate/scenarios/times-car-breach.json +74 -0
  38. package/operate/simulate/scenarios.py +52 -0
  39. package/operate/tests/test_ai_redaction.py +181 -0
  40. package/operate/tests/test_api_inventory.py +239 -0
  41. package/operate/tests/test_declarative_scenarios.py +169 -0
  42. package/operate/tests/test_evidence_pack.py +128 -0
  43. package/operate/tests/test_headless_ai.py +16 -1
  44. package/package.json +1 -1
@@ -0,0 +1,118 @@
1
+ """Secret redaction for code excerpts: Tier 1 snippets and every excerpt the headless agents send to a model (#350).
2
+
3
+ Pattern-based, so it cannot catch every secret: the pilot runbook also leaves key and environment config files
4
+ out of the source copy. Standard-library only (Python 3.8+ compatible).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import List, Optional, Tuple
11
+
12
+ MASK = "[REDACTED]"
13
+ # Longer lines (minified bundles, generated data) are dropped whole: they are not useful to an auditor and
14
+ # keep the regexes below from running on megabyte-long input
15
+ MAX_LINE_CHARS = 4000
16
+
17
+ # A secret-like key name; the lookahead keeps passwordPolicy, tokenizer and Passenger out
18
+ _KW = r"(?:password|passwd|passphrase|pwd|pw|pass|secret|credential|api[_-]?key|token)(?![a-z])"
19
+ _NAME_TAGS = r"(?:param-name|name|key)"
20
+ _VALUE_TAGS = r"(?:param-value|value)"
21
+
22
+
23
+ def _rx(pattern: str, flags: int = re.IGNORECASE) -> re.Pattern[str]:
24
+ return re.compile(pattern, flags)
25
+
26
+
27
+ # (pattern, replacement); applied in order to each line
28
+ _RULES: Tuple[Tuple[re.Pattern[str], str], ...] = (
29
+ # -----BEGIN PRIVATE KEY----- ... -----END PRIVATE KEY----- on one line
30
+ (_rx(r"(-----BEGIN [A-Z ]*PRIVATE KEY-----).*?(-----END [A-Z ]*PRIVATE KEY-----)", 0), r"\1" + MASK + r"\2"),
31
+ # "password": "x", 'password' => 'x', pw = "x", keystorePass="x", setProperty("mail.smtp.password", "x")
32
+ (_rx(r"(?<![\w.\-])((['\"]?)[\w.\-]*?" + _KW + r"\2\s*(?:=>|->|[=:,])\s*)(['\"])[^'\"]+\3"), r"\1\3" + MASK + r"\3"),
33
+ # conn.setPassword("x"), setKeystorePass("x")
34
+ (_rx(r"(\bset\w*?" + _KW + r"\s*\(\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
35
+ # DriverManager.getConnection(url, "sa", "x")
36
+ (_rx(r"(\bgetConnection\s*\([^,()]*,[^,()]*,\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
37
+ # new PasswordAuthentication("u", "x".toCharArray())
38
+ (_rx(r"(\bPasswordAuthentication\s*\([^,()]*,\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
39
+ # <property name="jdbc.password" value="x"/>
40
+ (_rx(r"(\bname\s*=\s*['\"][^'\"]*?" + _KW + r"[^'\"]*['\"][^>]*?\bvalue\s*=\s*)(['\"])[^'\"]*\2"),
41
+ r"\1\2" + MASK + r"\2"),
42
+ # <param-name>password</param-name><param-value>x</param-value>
43
+ (_rx(r"(<" + _NAME_TAGS + r">[^<]*?" + _KW + r"[^<]*</" + _NAME_TAGS + r">\s*<" + _VALUE_TAGS + r">)[^<]+"),
44
+ r"\1" + MASK),
45
+ # <password>x</password>
46
+ (_rx(r"(<([\w.\-:]*?" + _KW + r")\s*>)[^<]+(?=</\2\s*>)"), r"\1" + MASK),
47
+ # jdbc:sqlserver://h;password=x ?pwd=x& password={a;b}
48
+ (_rx(r"([;?&](?:password|passwd|pwd)=)(?:\{[^}]*\}|[^;\"'&\s]+)"), r"\1" + MASK),
49
+ # scheme://user:pass@host
50
+ (_rx(r"(?<![a-z0-9+.\-])([a-z][a-z0-9+.\-]*://[^/\s:@\"']+:)[^@\s\"'/]+@"), r"\1" + MASK + "@"),
51
+ # jdbc:oracle:thin:user/pass@host
52
+ (_rx(r"(\bjdbc:oracle:\w+:[^/\s:@\"']+/)[^@\s\"']+@"), r"\1" + MASK + "@"),
53
+ # Authorization: Basic xxx / Bearer xxx
54
+ (_rx(r"(\b(?:Basic|Bearer)\s+)[A-Za-z0-9._~+/=\-]{8,}"), r"\1" + MASK),
55
+ # Well-known token formats (case-sensitive)
56
+ (_rx(r"\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_\w{20,}|sk-[A-Za-z0-9_\-]{20,}"
57
+ r"|AIza[0-9A-Za-z_\-]{35})\b", 0), MASK),
58
+ # Unquoted config values (.properties, YAML, .env) on their own line; ${PLACEHOLDER} and code are kept
59
+ (_rx(r"^(\s*[\w.\-]*?" + _KW + r"[\w.\-]*\s*[=:]\s*)(?![\"'$=\s])[^\s\"'(){}<>,;]+\s*(?:(?:#|//).*)?$"), r"\1" + MASK),
60
+ # Dotted config keys inside comments or strings: <%-- db.password=x --%>
61
+ (_rx(r"(?<![\w.\-])([\w\-]+\.[\w.\-]*?" + _KW + r"[\w\-]*\s*=\s*)(?![\"'$=\s])[^\s\"'(){}<>,;]+(?=\s|$|[\"'])"),
62
+ r"\1" + MASK),
63
+ )
64
+
65
+ _PRIVATE_KEY_BEGIN = _rx(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", 0)
66
+ _PRIVATE_KEY_END = _rx(r"-----END [A-Z ]*PRIVATE KEY-----", 0)
67
+ # A secret name whose value follows on a later line: <property name="password"\n value="x"/>, web.xml params
68
+ _PENDING_NAME = _rx(r"\bname\s*=\s*['\"][^'\"]*?" + _KW + r"[^'\"]*['\"]|<" + _NAME_TAGS + r">[^<]*?" + _KW)
69
+ _PENDING_VALUE: Tuple[Tuple[re.Pattern[str], str], ...] = (
70
+ (_rx(r"(\bvalue\s*=\s*)(['\"])[^'\"]*\2"), r"\1\2" + MASK + r"\2"),
71
+ (_rx(r"(<" + _VALUE_TAGS + r">)[^<]+"), r"\1" + MASK),
72
+ )
73
+ _PENDING_LINES = 2
74
+
75
+
76
+ def _too_long(s: str) -> Optional[str]:
77
+ return f"[line of {len(s)} characters omitted]" if len(s) > MAX_LINE_CHARS else None
78
+
79
+
80
+ def redact(s: str) -> str:
81
+ """Mask secrets in one line of code or config."""
82
+ omitted = _too_long(s)
83
+ if omitted:
84
+ return omitted
85
+ for pattern, repl in _RULES:
86
+ s = pattern.sub(repl, s)
87
+ return s
88
+
89
+
90
+ def _redact_pending(line: str) -> Tuple[str, bool]:
91
+ for pattern, repl in _PENDING_VALUE:
92
+ masked, n = pattern.subn(repl, line)
93
+ if n:
94
+ return masked, True
95
+ return line, False
96
+
97
+
98
+ def redact_lines(lines: List[str]) -> List[str]:
99
+ """Redact each line, blank out private key bodies and mask values split from their names.
100
+
101
+ The line count stays the same, so line numbers in excerpts remain valid.
102
+ """
103
+ out: List[str] = []
104
+ in_key, pending = False, 0
105
+ for line in lines:
106
+ if in_key:
107
+ in_key = not _PRIVATE_KEY_END.search(line)
108
+ out.append(MASK if in_key else line)
109
+ continue
110
+ masked = redact(line)
111
+ if pending:
112
+ masked, done = _redact_pending(masked)
113
+ pending = 0 if done else pending - 1
114
+ if _PENDING_NAME.search(line) and MASK not in masked:
115
+ pending = _PENDING_LINES
116
+ in_key = bool(_PRIVATE_KEY_BEGIN.search(line)) and not _PRIVATE_KEY_END.search(line)
117
+ out.append(masked)
118
+ return out
@@ -17,6 +17,14 @@ from typing import Any, Dict, List
17
17
  sys.path.insert(0, str(Path(__file__).resolve().parent))
18
18
  from lsa_common import KIT_VERSION, read_json # noqa: E402
19
19
  from trend import render_trend_html, render_trend_md # noqa: E402
20
+ from attack_chains import render_attack_chains_html, render_attack_chains_md # noqa: E402
21
+ from forecast import render_forecast_html, render_forecast_md # noqa: E402
22
+
23
+ try:
24
+ from operate.inventory.api_inventory import render_inventory_html, render_inventory_md # noqa: E402
25
+ except (ImportError, ModuleNotFoundError):
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))
27
+ from operate.inventory.api_inventory import render_inventory_html, render_inventory_md # noqa: E402
20
28
 
21
29
  SEV = ["critical", "high", "medium", "low", "info"]
22
30
 
@@ -68,6 +76,39 @@ def _load_trend(out: Path) -> Dict[str, Any]:
68
76
  return trend if trend.get("history") else {}
69
77
 
70
78
 
79
+ def _load_attack_chains(out: Path) -> Dict[str, Any]:
80
+ """Attack chains from attack_chains.py (OUT/attack-chains.json)."""
81
+ path = out / "attack-chains.json"
82
+ if not path.is_file():
83
+ return {}
84
+ try:
85
+ return read_json(path) or {}
86
+ except (OSError, ValueError, AttributeError):
87
+ return {}
88
+
89
+
90
+ def _load_forecast(out: Path) -> Dict[str, Any]:
91
+ """Security debt forecast from forecast.py (OUT/forecast.json)."""
92
+ path = out / "forecast.json"
93
+ if not path.is_file():
94
+ return {}
95
+ try:
96
+ return read_json(path) or {}
97
+ except (OSError, ValueError, AttributeError):
98
+ return {}
99
+
100
+
101
+ def _load_inventory_diff(out: Path) -> Dict[str, Any]:
102
+ """API inventory diff from operate inventory (OUT/api-inventory-diff.json)."""
103
+ path = out / "api-inventory-diff.json"
104
+ if not path.is_file():
105
+ return {}
106
+ try:
107
+ return read_json(path) or {}
108
+ except (OSError, ValueError, AttributeError):
109
+ return {}
110
+
111
+
71
112
  def md_escape(s: str) -> str:
72
113
  return (s or "").replace("|", "\\|").replace("\n", " ")
73
114
 
@@ -130,6 +171,15 @@ def render_en(out: Path, project: str) -> str:
130
171
  trend = _load_trend(out)
131
172
  if trend:
132
173
  L += render_trend_md(trend, "en")
174
+ fc = _load_forecast(out)
175
+ if fc:
176
+ L += render_forecast_md(fc, "en")
177
+ inv = _load_inventory_diff(out)
178
+ if inv and inv.get("summary"):
179
+ L += render_inventory_md(inv, "en")
180
+ chains = _load_attack_chains(out)
181
+ if chains and chains.get("chains"):
182
+ L += render_attack_chains_md(chains, "en")
133
183
  L += ["## 3. Findings", "",
134
184
  "| ID | Sev | Conf | Status | Category | Endpoint | Location | Title |", "|---|---|---|---|---|---|---|---|"]
135
185
  for f in active:
@@ -223,6 +273,15 @@ def render_ja(out: Path, project: str) -> str:
223
273
  trend = _load_trend(out)
224
274
  if trend:
225
275
  L += render_trend_md(trend, "ja")
276
+ fc = _load_forecast(out)
277
+ if fc:
278
+ L += render_forecast_md(fc, "ja")
279
+ inv = _load_inventory_diff(out)
280
+ if inv and inv.get("summary"):
281
+ L += render_inventory_md(inv, "ja")
282
+ chains = _load_attack_chains(out)
283
+ if chains and chains.get("chains"):
284
+ L += render_attack_chains_md(chains, "ja")
226
285
  L += ["## 3. 検出事項の詳細", "",
227
286
  "| ID | 重要度 | 確信度 | ステータス | 脆弱性分類 | 対象エンドポイント | 該当箇所 | タイトル |",
228
287
  "|---|---|---|---|---|---|---|---|"]
@@ -427,6 +486,12 @@ def render_html(out: Path, project: str, default_lang: str = "en") -> str:
427
486
  )
428
487
  trend = _load_trend(out)
429
488
  trend_card = render_trend_html(trend) if trend else ""
489
+ fc = _load_forecast(out)
490
+ forecast_card = render_forecast_html(fc) if fc else ""
491
+ inv = _load_inventory_diff(out)
492
+ inv_card = render_inventory_html(inv) if inv and inv.get("summary") else ""
493
+ chains = _load_attack_chains(out)
494
+ chains_card = render_attack_chains_html(chains) if chains and chains.get("chains") else ""
430
495
 
431
496
  # Build severity legend rows
432
497
  sev_colors = {
@@ -906,6 +971,21 @@ def render_html(out: Path, project: str, default_lang: str = "en") -> str:
906
971
  font-size: 12px;
907
972
  color: #475569;
908
973
  }}
974
+ .chain-list-container {{ display: flex; flex-direction: column; gap: 16px; margin-top: 16px; }}
975
+ .chain-item-card {{ border: 1px solid var(--border); border-radius: 8px; padding: 16px; background: #fdfdfd; }}
976
+ .chain-item-header {{ font-size: 15px; margin-bottom: 12px; }}
977
+ .chain-flow-diagram {{ display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0; }}
978
+ .chain-step-node {{ background: #ffffff; border: 1px solid var(--border); border-radius: 6px; padding: 8px 12px; font-size: 12px; }}
979
+ .chain-step-badge {{ font-weight: 600; color: var(--accent); margin-bottom: 4px; }}
980
+ .chain-step-id {{ font-size: 13px; }}
981
+ .chain-step-loc {{ color: var(--muted); font-family: monospace; font-size: 11px; }}
982
+ .chain-arrow {{ font-size: 18px; color: var(--muted); font-weight: bold; }}
983
+ .chain-impact-desc {{ font-size: 13px; color: var(--muted); margin-top: 8px; }}
984
+ .forecast-stats-grid {{ display: flex; flex-wrap: wrap; gap: 16px; margin: 12px 0 18px; }}
985
+ .forecast-stat-box {{ background: #fdfdfd; border: 1px solid var(--border); border-radius: 8px; padding: 12px 18px; flex: 1; min-width: 140px; }}
986
+ .stat-lbl {{ font-size: 12px; color: var(--muted); }}
987
+ .stat-val {{ font-size: 20px; font-weight: 700; margin-top: 4px; }}
988
+ .forecast-alert {{ padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 13px; }}
909
989
  @media print {{
910
990
  body {{ background: #fff; }}
911
991
  .btn-ctrl, .filter-bar, .btn-inspect, .btn-copy {{ display: none !important; }}
@@ -986,8 +1066,14 @@ def render_html(out: Path, project: str, default_lang: str = "en") -> str:
986
1066
  </div>
987
1067
  </section>
988
1068
 
1069
+ {chains_card}
1070
+
1071
+ {inv_card}
1072
+
989
1073
  {trend_card}
990
1074
 
1075
+ {forecast_card}
1076
+
991
1077
  <section class="card" aria-label="Interactive Findings Table">
992
1078
  <h2 class="card-title" data-en="Interactive Findings Explorer" data-ja="検出事項一覧・インタラクティブ検索 (Findings Explorer)">Interactive Findings Explorer</h2>
993
1079
  <div class="filter-bar">
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for attack chain synthesis (Issue #316).
3
+
4
+ Verifies:
5
+ 1. attack-chain.schema.json schema conformity.
6
+ 2. 3-step chain synthesis (Mass assignment -> BFLA -> BOLA) with breaking fix calculation.
7
+ 3. Safe / disconnected findings produce no chains.
8
+ 4. Single findings never become a chain.
9
+ 5. Rejected findings are excluded.
10
+ 6. SARIF output enrichment with attackChains property.
11
+
12
+ Standard-library-only (Python 3.8+).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+ import unittest
19
+ from pathlib import Path
20
+
21
+ HERE = Path(__file__).resolve().parent
22
+ KIT = HERE.parent
23
+ sys.path.insert(0, str(KIT / "scripts"))
24
+
25
+ from attack_chains import ( # noqa: E402
26
+ attach_chains_to_sarif,
27
+ build_attack_chains,
28
+ profile_finding,
29
+ render_attack_chains_html,
30
+ render_attack_chains_md,
31
+ )
32
+ from merge_findings import load_validator # noqa: E402
33
+ from render_report import render_en, render_html, render_ja # noqa: E402
34
+
35
+
36
+ class TestAttackChains(unittest.TestCase):
37
+ def setUp(self) -> None:
38
+ self.validate_schema = load_validator("attack-chain.schema.json")
39
+ self.validate_finding = load_validator("finding.schema.json")
40
+
41
+ def test_schema_validates_empty_chain_doc(self) -> None:
42
+ doc = build_attack_chains([])
43
+ errors = self.validate_schema(doc)
44
+ self.assertEqual(errors, [], f"Schema errors: {errors}")
45
+ self.assertEqual(doc["summary"]["total_chains"], 0)
46
+
47
+ def test_single_finding_never_becomes_chain(self) -> None:
48
+ finding = {
49
+ "finding_id": "BOLA-001",
50
+ "category": "bola",
51
+ "subtype": "missing_ownership_check",
52
+ "title": "BOLA in orders",
53
+ "location": "Controllers/OrderController.cs:42",
54
+ "endpoint": "GET /api/orders/{id}",
55
+ "evidence": "db.Orders.Find(id)",
56
+ "severity": "high",
57
+ "confidence": "confirmed",
58
+ "cwe": "CWE-639",
59
+ "recommendation": "Check owner",
60
+ "status": "confirmed",
61
+ }
62
+ doc = build_attack_chains([finding])
63
+ self.assertEqual(doc["summary"]["total_chains"], 0)
64
+ self.assertEqual(self.validate_schema(doc), [])
65
+
66
+ def test_rejected_findings_are_excluded(self) -> None:
67
+ f1 = {
68
+ "finding_id": "MASS-001",
69
+ "category": "mass_assignment",
70
+ "subtype": "whole_object_binding",
71
+ "title": "Mass assignment on register",
72
+ "location": "register.php:10",
73
+ "endpoint": "POST /register",
74
+ "evidence": "extract($_POST)",
75
+ "severity": "high",
76
+ "confidence": "confirmed",
77
+ "cwe": "CWE-915",
78
+ "recommendation": "Use allowlist",
79
+ "status": "rejected", # Rejected by verifier
80
+ "attack_vector": {"fields": {"role": "admin"}},
81
+ }
82
+ f2 = {
83
+ "finding_id": "BOLA-001",
84
+ "category": "bola",
85
+ "subtype": "missing_ownership_check",
86
+ "title": "BOLA in orders",
87
+ "location": "orders.php:20",
88
+ "endpoint": "GET /orders/{id}",
89
+ "evidence": "SELECT * FROM orders WHERE id = $id",
90
+ "severity": "high",
91
+ "confidence": "confirmed",
92
+ "cwe": "CWE-639",
93
+ "recommendation": "Filter by user",
94
+ "status": "confirmed",
95
+ }
96
+ doc = build_attack_chains([f1, f2])
97
+ self.assertEqual(doc["summary"]["total_chains"], 0)
98
+
99
+ def test_three_step_planted_attack_chain(self) -> None:
100
+ """Planted 3-step chain: Mass Assignment -> BFLA -> BOLA."""
101
+ step1_mass = {
102
+ "finding_id": "MASS-001",
103
+ "category": "mass_assignment",
104
+ "subtype": "whole_object_binding",
105
+ "title": "Mass assignment on registration allows role escalation",
106
+ "location": "php-plain/register.php:4",
107
+ "endpoint": "POST /register",
108
+ "evidence": "extract($_POST) overrides $role",
109
+ "severity": "high",
110
+ "confidence": "confirmed",
111
+ "cwe": "CWE-915",
112
+ "recommendation": "Whitelist registration fields",
113
+ "status": "confirmed",
114
+ "attack_vector": {
115
+ "method": "POST",
116
+ "path": "/register",
117
+ "fields": {"role": "admin", "is_admin": True},
118
+ },
119
+ }
120
+
121
+ step2_bfla = {
122
+ "finding_id": "BFLA-001",
123
+ "category": "bfla",
124
+ "subtype": "missing_role_check",
125
+ "title": "Admin tenant list accessible without role check",
126
+ "location": "php-plain/admin/tenants.php:12",
127
+ "endpoint": "GET /admin/tenants",
128
+ "evidence": "Checks login session but not admin role",
129
+ "severity": "high",
130
+ "confidence": "confirmed",
131
+ "cwe": "CWE-285",
132
+ "recommendation": "Add requireRole('admin') guard",
133
+ "status": "confirmed",
134
+ }
135
+
136
+ step3_bola = {
137
+ "finding_id": "BOLA-001",
138
+ "category": "bola",
139
+ "subtype": "missing_ownership_check",
140
+ "title": "Customer records readable across tenants by ID",
141
+ "location": "php-plain/customers.php:25",
142
+ "endpoint": "GET /api/customers/{id}",
143
+ "evidence": "SELECT * FROM customers WHERE id = ?",
144
+ "severity": "high",
145
+ "confidence": "likely",
146
+ "cwe": "CWE-639",
147
+ "recommendation": "Bind tenant_id to authenticated user",
148
+ "status": "likely",
149
+ }
150
+
151
+ doc = build_attack_chains([step1_mass, step2_bfla, step3_bola])
152
+ errors = self.validate_schema(doc)
153
+ self.assertEqual(errors, [], f"Schema validation failed: {errors}")
154
+
155
+ self.assertGreaterEqual(doc["summary"]["total_chains"], 1)
156
+ chain = doc["chains"][0]
157
+ self.assertEqual(chain["chain_id"], "chain-001")
158
+ self.assertEqual(chain["entry"], "anonymous")
159
+ self.assertEqual(chain["severity"], "critical")
160
+ # Confidence is weakest link (likely from step3)
161
+ self.assertEqual(chain["confidence"], "likely")
162
+
163
+ # Verify steps in order
164
+ step_ids = [s["finding_id"] for s in chain["steps"]]
165
+ self.assertIn("MASS-001", step_ids)
166
+ self.assertIn("BOLA-001", step_ids)
167
+
168
+ # Breaking fixes must include all chain findings
169
+ self.assertIn("MASS-001", chain["breaking_fixes"])
170
+ self.assertIn("BOLA-001", chain["breaking_fixes"])
171
+
172
+ # Check summary top breaking fixes
173
+ top_fixes = doc["summary"]["top_breaking_fixes"]
174
+ self.assertGreaterEqual(len(top_fixes), 1)
175
+ broken_counts = {tf["finding_id"]: tf["chains_broken"] for tf in top_fixes}
176
+ self.assertGreaterEqual(broken_counts.get("MASS-001", 0), 1)
177
+
178
+ def test_sarif_enrichment(self) -> None:
179
+ """Verify attach_chains_to_sarif adds properties.attackChains."""
180
+ sarif_mock = {
181
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
182
+ "version": "2.1.0",
183
+ "runs": [
184
+ {
185
+ "tool": {"driver": {"name": "komagu"}},
186
+ "results": [
187
+ {
188
+ "ruleId": "BOLA-001",
189
+ "message": {"text": "BOLA"},
190
+ "partialFingerprints": {"komagu/v1": "kfp1:111111"},
191
+ },
192
+ {
193
+ "ruleId": "INJ-001",
194
+ "message": {"text": "Unlinked injection"},
195
+ },
196
+ ],
197
+ }
198
+ ],
199
+ }
200
+
201
+ chains_mock = {
202
+ "chains": [
203
+ {
204
+ "chain_id": "chain-001",
205
+ "steps": [
206
+ {"finding_id": "MASS-001"},
207
+ {"finding_id": "BOLA-001"},
208
+ ],
209
+ }
210
+ ]
211
+ }
212
+
213
+ enriched = attach_chains_to_sarif(sarif_mock, chains_mock)
214
+ r0 = enriched["runs"][0]["results"][0]
215
+ self.assertEqual(r0.get("properties", {}).get("attackChains"), ["chain-001"])
216
+
217
+ r1 = enriched["runs"][0]["results"][1]
218
+ self.assertNotIn("attackChains", r1.get("properties", {}))
219
+
220
+ def test_markdown_and_html_rendering(self) -> None:
221
+ """Verify attack chains render properly in markdown (EN & JA) and HTML dashboard."""
222
+ mock_chains = {
223
+ "version": 1,
224
+ "generated_at": "2026-11-01T20:00:00Z",
225
+ "chains": [
226
+ {
227
+ "chain_id": "chain-001",
228
+ "title": "Mass Assignment → BFLA → BOLA targeting customers",
229
+ "entry": "anonymous",
230
+ "steps": [
231
+ {
232
+ "step_index": 1,
233
+ "finding_id": "MASS-001",
234
+ "category": "mass_assignment",
235
+ "location": "register.php:4",
236
+ "role_in_chain": "entry_point",
237
+ "explanation": "Overwrites role to admin",
238
+ },
239
+ {
240
+ "step_index": 2,
241
+ "finding_id": "BOLA-001",
242
+ "category": "bola",
243
+ "location": "customers.php:25",
244
+ "role_in_chain": "impact",
245
+ "explanation": "Leaks other tenants",
246
+ },
247
+ ],
248
+ "outcome": "Exfiltration of customer records",
249
+ "business_impact": "Loss of customer trust and regulatory fine.",
250
+ "severity": "critical",
251
+ "confidence": "likely",
252
+ "breaking_fixes": ["MASS-001", "BOLA-001"],
253
+ }
254
+ ],
255
+ "summary": {
256
+ "total_chains": 1,
257
+ "by_severity": {"critical": 1, "high": 0, "medium": 0, "low": 0},
258
+ "top_breaking_fixes": [
259
+ {
260
+ "finding_id": "MASS-001",
261
+ "category": "mass_assignment",
262
+ "location": "register.php:4",
263
+ "chains_broken": 1,
264
+ }
265
+ ],
266
+ },
267
+ }
268
+
269
+ # Test Markdown EN
270
+ md_en = "\n".join(render_attack_chains_md(mock_chains, "en"))
271
+ self.assertIn("Attack Chains", md_en)
272
+ self.assertIn("Top Breaking Fixes", md_en)
273
+ self.assertIn("MASS-001", md_en)
274
+ self.assertIn("chain-001", md_en)
275
+
276
+ # Test Markdown JA
277
+ md_ja = "\n".join(render_attack_chains_md(mock_chains, "ja"))
278
+ self.assertIn("攻撃チェーン", md_ja)
279
+ self.assertIn("最優先対策", md_ja)
280
+ self.assertIn("MASS-001", md_ja)
281
+ self.assertIn("chain-001", md_ja)
282
+
283
+ # Test HTML Card
284
+ html_card = render_attack_chains_html(mock_chains)
285
+ self.assertIn("Chained Attack Scenarios", html_card)
286
+ self.assertIn("Top Breaking Fixes", html_card)
287
+ self.assertIn("chain-001", html_card)
288
+ self.assertIn("MASS-001", html_card)
289
+
290
+
291
+ if __name__ == "__main__":
292
+ unittest.main()