@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
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">→</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 & Breaking Fixes" data-ja="複合攻撃シナリオ・最優先対策 (Attack Chains)">Chained Attack Scenarios & 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)
|