@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.
- package/CHANGELOG.md +11 -0
- package/assess/lsa/scripts/attack_chains.py +573 -0
- package/assess/lsa/scripts/baseline.py +14 -0
- package/assess/lsa/scripts/findings_to_sarif.py +11 -0
- package/assess/lsa/scripts/forecast.py +544 -0
- package/assess/lsa/scripts/merge_findings.py +14 -0
- package/assess/lsa/scripts/render_report.py +86 -0
- package/assess/lsa/tests/test_attack_chains.py +292 -0
- package/assess/lsa/tests/test_forecast.py +160 -0
- package/contracts/schemas/api-inventory-diff.schema.json +213 -0
- package/contracts/schemas/attack-chain.schema.json +181 -0
- package/contracts/schemas/bas-scenario.schema.json +217 -0
- package/contracts/schemas/evidence-manifest.schema.json +117 -0
- package/contracts/schemas/finding.schema.json +7 -0
- package/contracts/schemas/forecast.schema.json +172 -0
- package/governance/scs/__init__.py +21 -0
- package/governance/scs/mapping.py +133 -0
- package/governance/scs/pack.py +354 -0
- package/operate/cli/__init__.py +1 -1
- package/operate/cli/commands/evidence.py +138 -0
- package/operate/cli/commands/inventory.py +187 -0
- package/operate/cli/commands/simulate.py +175 -58
- package/operate/cli/main.py +7 -3
- package/operate/inventory/__init__.py +22 -0
- package/operate/inventory/api_inventory.py +566 -0
- package/operate/simulate/__init__.py +16 -0
- package/operate/simulate/engine.py +370 -0
- package/operate/simulate/loader.py +158 -0
- package/operate/simulate/scenarios/idc-frontier-ransomware.json +47 -0
- package/operate/simulate/scenarios/metabase-cve-2026-72898.json +84 -0
- package/operate/simulate/scenarios/times-car-breach.json +74 -0
- package/operate/simulate/scenarios.py +52 -0
- package/operate/tests/test_api_inventory.py +239 -0
- package/operate/tests/test_declarative_scenarios.py +169 -0
- package/operate/tests/test_evidence_pack.py +128 -0
- package/package.json +1 -1
|
@@ -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()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for security debt forecasting (Issue #318).
|
|
3
|
+
|
|
4
|
+
Verifies:
|
|
5
|
+
1. forecast.schema.json schema conformity.
|
|
6
|
+
2. Insufficient history detection (< 6 snapshots or < 28 days span).
|
|
7
|
+
3. Converging burndown calculations (P10 <= P50 <= P90).
|
|
8
|
+
4. Flat and diverging trajectory handling without false promises.
|
|
9
|
+
5. Target deadline evaluation (on_track, at_risk, off_track).
|
|
10
|
+
6. What-if velocity simulation.
|
|
11
|
+
7. Markdown (EN & JA) and HTML card rendering.
|
|
12
|
+
|
|
13
|
+
Standard-library-only (Python 3.8+).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import sys
|
|
19
|
+
import unittest
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
HERE = Path(__file__).resolve().parent
|
|
23
|
+
KIT = HERE.parent
|
|
24
|
+
sys.path.insert(0, str(KIT / "scripts"))
|
|
25
|
+
|
|
26
|
+
from forecast import project_debt, render_forecast_html, render_forecast_md # noqa: E402
|
|
27
|
+
from merge_findings import load_validator # noqa: E402
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestForecast(unittest.TestCase):
|
|
31
|
+
def setUp(self) -> None:
|
|
32
|
+
self.validate_schema = load_validator("forecast.schema.json")
|
|
33
|
+
|
|
34
|
+
def test_schema_validates_empty_and_insufficient_history(self) -> None:
|
|
35
|
+
doc = project_debt([])
|
|
36
|
+
errors = self.validate_schema(doc)
|
|
37
|
+
self.assertEqual(errors, [], f"Schema validation errors: {errors}")
|
|
38
|
+
self.assertFalse(doc["has_enough_history"])
|
|
39
|
+
self.assertIn("Requires at least", doc["reason_if_insufficient"])
|
|
40
|
+
|
|
41
|
+
def test_insufficient_snapshots_or_span(self) -> None:
|
|
42
|
+
# 4 snapshots over 10 days
|
|
43
|
+
history = [
|
|
44
|
+
{"date": "2026-09-01", "open": 20, "new": 2, "fixed": 1, "regressed": 0, "by_severity": {"critical": 5, "high": 10}},
|
|
45
|
+
{"date": "2026-09-03", "open": 19, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 4, "high": 10}},
|
|
46
|
+
{"date": "2026-09-06", "open": 18, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 3, "high": 10}},
|
|
47
|
+
{"date": "2026-09-10", "open": 16, "new": 0, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 10}},
|
|
48
|
+
]
|
|
49
|
+
doc = project_debt(history)
|
|
50
|
+
self.assertFalse(doc["has_enough_history"])
|
|
51
|
+
for p in doc["projections"]:
|
|
52
|
+
self.assertEqual(p["status"], "insufficient_history")
|
|
53
|
+
self.assertIsNone(p["target_zero_date_p50"])
|
|
54
|
+
self.assertEqual(self.validate_schema(doc), [])
|
|
55
|
+
|
|
56
|
+
def test_converging_burndown_and_targets(self) -> None:
|
|
57
|
+
# 7 snapshots spanning 42 days, steady fix velocity
|
|
58
|
+
history = [
|
|
59
|
+
{"date": "2026-08-01", "open": 30, "new": 1, "fixed": 3, "regressed": 0, "by_severity": {"critical": 6, "high": 12}},
|
|
60
|
+
{"date": "2026-08-08", "open": 28, "new": 1, "fixed": 3, "regressed": 0, "by_severity": {"critical": 5, "high": 11}},
|
|
61
|
+
{"date": "2026-08-15", "open": 25, "new": 0, "fixed": 3, "regressed": 0, "by_severity": {"critical": 4, "high": 10}},
|
|
62
|
+
{"date": "2026-08-22", "open": 22, "new": 1, "fixed": 4, "regressed": 0, "by_severity": {"critical": 3, "high": 9}},
|
|
63
|
+
{"date": "2026-08-29", "open": 19, "new": 1, "fixed": 4, "regressed": 0, "by_severity": {"critical": 2, "high": 8}},
|
|
64
|
+
{"date": "2026-09-05", "open": 16, "new": 0, "fixed": 3, "regressed": 0, "by_severity": {"critical": 1, "high": 8}},
|
|
65
|
+
{"date": "2026-09-12", "open": 13, "new": 0, "fixed": 3, "regressed": 0, "by_severity": {"critical": 1, "high": 7}},
|
|
66
|
+
]
|
|
67
|
+
targets = {
|
|
68
|
+
"critical": "2026-10-15", # Generous target
|
|
69
|
+
"overall": "2026-10-01", # Very tight target -> should be off_track
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
doc = project_debt(history, targets=targets, today="2026-09-12")
|
|
73
|
+
errors = self.validate_schema(doc)
|
|
74
|
+
self.assertEqual(errors, [], f"Schema validation failed: {errors}")
|
|
75
|
+
self.assertTrue(doc["has_enough_history"])
|
|
76
|
+
|
|
77
|
+
# Check rates
|
|
78
|
+
self.assertGreater(doc["rates"]["fix_rate_per_week"], 2.0)
|
|
79
|
+
self.assertLess(doc["rates"]["net_rate_per_week"], -1.0)
|
|
80
|
+
|
|
81
|
+
crit_proj = next(p for p in doc["projections"] if p["severity"] == "critical")
|
|
82
|
+
self.assertEqual(crit_proj["status"], "converging")
|
|
83
|
+
self.assertIsNotNone(crit_proj["target_zero_date_p50"])
|
|
84
|
+
# Dates must be sorted chronologically: P10 <= P50 <= P90
|
|
85
|
+
p10 = datetime.datetime.strptime(crit_proj["target_zero_date_p10"], "%Y-%m-%d")
|
|
86
|
+
p50 = datetime.datetime.strptime(crit_proj["target_zero_date_p50"], "%Y-%m-%d")
|
|
87
|
+
p90 = datetime.datetime.strptime(crit_proj["target_zero_date_p90"], "%Y-%m-%d")
|
|
88
|
+
self.assertLessEqual(p10, p50)
|
|
89
|
+
self.assertLessEqual(p50, p90)
|
|
90
|
+
|
|
91
|
+
# Critical target is generous -> on_track or at_risk
|
|
92
|
+
self.assertIn(crit_proj["target_status"], ("on_track", "at_risk"))
|
|
93
|
+
|
|
94
|
+
overall_proj = next(p for p in doc["projections"] if p["severity"] == "overall")
|
|
95
|
+
# 13 items open, net rate ~ -3/wk -> ~4 weeks (mid October). Target is Oct 1 -> off_track
|
|
96
|
+
self.assertEqual(overall_proj["target_status"], "off_track")
|
|
97
|
+
|
|
98
|
+
def test_diverging_or_flat_history(self) -> None:
|
|
99
|
+
# History where incoming defects exceed fixes
|
|
100
|
+
history = [
|
|
101
|
+
{"date": "2026-08-01", "open": 10, "new": 4, "fixed": 1, "regressed": 0, "by_severity": {"critical": 2, "high": 4}},
|
|
102
|
+
{"date": "2026-08-08", "open": 13, "new": 4, "fixed": 1, "regressed": 0, "by_severity": {"critical": 3, "high": 5}},
|
|
103
|
+
{"date": "2026-08-15", "open": 16, "new": 3, "fixed": 0, "regressed": 0, "by_severity": {"critical": 3, "high": 6}},
|
|
104
|
+
{"date": "2026-08-22", "open": 18, "new": 3, "fixed": 1, "regressed": 0, "by_severity": {"critical": 4, "high": 7}},
|
|
105
|
+
{"date": "2026-08-29", "open": 20, "new": 3, "fixed": 1, "regressed": 0, "by_severity": {"critical": 5, "high": 8}},
|
|
106
|
+
{"date": "2026-09-05", "open": 22, "new": 3, "fixed": 1, "regressed": 0, "by_severity": {"critical": 5, "high": 9}},
|
|
107
|
+
{"date": "2026-09-12", "open": 25, "new": 4, "fixed": 1, "regressed": 0, "by_severity": {"critical": 6, "high": 10}},
|
|
108
|
+
]
|
|
109
|
+
doc = project_debt(history, targets={"overall": "2026-12-31"}, today="2026-09-12")
|
|
110
|
+
self.assertTrue(doc["has_enough_history"])
|
|
111
|
+
overall_proj = next(p for p in doc["projections"] if p["severity"] == "overall")
|
|
112
|
+
self.assertEqual(overall_proj["status"], "diverging")
|
|
113
|
+
self.assertIsNone(overall_proj["target_zero_date_p50"])
|
|
114
|
+
self.assertEqual(overall_proj["target_status"], "not_converging")
|
|
115
|
+
|
|
116
|
+
def test_what_if_scenarios(self) -> None:
|
|
117
|
+
history = [
|
|
118
|
+
{"date": "2026-08-01", "open": 20, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 4, "high": 8}},
|
|
119
|
+
{"date": "2026-08-08", "open": 19, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 4, "high": 7}},
|
|
120
|
+
{"date": "2026-08-15", "open": 18, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 3, "high": 7}},
|
|
121
|
+
{"date": "2026-08-22", "open": 17, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 3, "high": 6}},
|
|
122
|
+
{"date": "2026-08-29", "open": 16, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 6}},
|
|
123
|
+
{"date": "2026-09-05", "open": 15, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 5}},
|
|
124
|
+
{"date": "2026-09-12", "open": 14, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 1, "high": 5}},
|
|
125
|
+
]
|
|
126
|
+
what_ifs = [
|
|
127
|
+
{"id": "add_dev", "fix_rate_delta": 3.0, "description": "Add 1 developer (+3 fixes/wk)"}
|
|
128
|
+
]
|
|
129
|
+
doc = project_debt(history, what_if_modifiers=what_ifs, today="2026-09-12")
|
|
130
|
+
self.assertEqual(len(doc["scenarios"]), 2)
|
|
131
|
+
sc = doc["scenarios"][1]
|
|
132
|
+
self.assertEqual(sc["scenario_id"], "add_dev")
|
|
133
|
+
self.assertIn("Estimated ~", sc["projected_date_range"])
|
|
134
|
+
|
|
135
|
+
def test_markdown_and_html_rendering(self) -> None:
|
|
136
|
+
history = [
|
|
137
|
+
{"date": "2026-08-01", "open": 10, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 4}},
|
|
138
|
+
{"date": "2026-08-08", "open": 9, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 3}},
|
|
139
|
+
{"date": "2026-08-15", "open": 8, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 1, "high": 3}},
|
|
140
|
+
{"date": "2026-08-22", "open": 7, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 1, "high": 2}},
|
|
141
|
+
{"date": "2026-08-29", "open": 6, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 1, "high": 1}},
|
|
142
|
+
{"date": "2026-09-05", "open": 5, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 0, "high": 1}},
|
|
143
|
+
{"date": "2026-09-12", "open": 4, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 0, "high": 0}},
|
|
144
|
+
]
|
|
145
|
+
doc = project_debt(history, targets={"critical": "2026-10-01"})
|
|
146
|
+
md_en = "\n".join(render_forecast_md(doc, "en"))
|
|
147
|
+
self.assertIn("Outlook & Debt Forecast", md_en)
|
|
148
|
+
self.assertIn("Arrival Rate", md_en)
|
|
149
|
+
|
|
150
|
+
md_ja = "\n".join(render_forecast_md(doc, "ja"))
|
|
151
|
+
self.assertIn("見通しとセキュリティ負債予測", md_ja)
|
|
152
|
+
self.assertIn("目標達成予測", md_ja)
|
|
153
|
+
|
|
154
|
+
html_card = render_forecast_html(doc)
|
|
155
|
+
self.assertIn("Outlook & Debt Forecast", html_card)
|
|
156
|
+
self.assertIn("Arrival Rate", html_card)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
if __name__ == "__main__":
|
|
160
|
+
unittest.main()
|