@hoanghainh/komagu 2.2.0 → 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 +18 -0
- package/README.md +1 -1
- 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/examples/member-portal/backend-dotnet/MemberPortal.Backend.csproj +2 -2
- 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/remediate/authz/dotnet/src/Komagu.Security.EntityFrameworkCore/Komagu.Security.EntityFrameworkCore.csproj +1 -1
- package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/Komagu.Security.Authorization.Tests.csproj +1 -1
- package/remediate/authz/spring/build.gradle.kts +4 -4
- package/remediate/identity/dotnet/src/Komagu.Security.Identity/Komagu.Security.Identity.csproj +1 -1
- package/remediate/identity/dotnet/tests/Komagu.Security.Identity.Tests/Komagu.Security.Identity.Tests.csproj +1 -1
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Forecast security debt burndown and target completion dates from scan history.
|
|
3
|
+
|
|
4
|
+
Standard-library-only (Python 3.8+).
|
|
5
|
+
|
|
6
|
+
Calculates arrival rate (new/regressed per week), fix rate (fixed per week),
|
|
7
|
+
and net burndown velocity. Projects 10th-90th percentile date ranges to reach
|
|
8
|
+
zero open vulnerabilities, evaluates target deadlines, and models what-if scenarios.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime
|
|
14
|
+
import html
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
SEVERITIES = ("critical", "high", "medium", "low", "info")
|
|
22
|
+
MIN_SNAPSHOTS = 6
|
|
23
|
+
MIN_SPAN_DAYS = 28
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_date(date_str: str) -> datetime.date:
|
|
27
|
+
"""Parse YYYY-MM-DD date string."""
|
|
28
|
+
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_date(d: datetime.date) -> str:
|
|
32
|
+
"""Format date to YYYY-MM-DD string."""
|
|
33
|
+
return d.strftime("%Y-%m-%d")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def calculate_history_span(history: List[Dict[str, Any]]) -> Tuple[int, Optional[datetime.date], Optional[datetime.date]]:
|
|
37
|
+
"""Return (span_days, start_date, end_date) for sorted history snapshots."""
|
|
38
|
+
if not history:
|
|
39
|
+
return 0, None, None
|
|
40
|
+
valid_dates = []
|
|
41
|
+
for h in history:
|
|
42
|
+
try:
|
|
43
|
+
valid_dates.append(parse_date(h["date"]))
|
|
44
|
+
except (KeyError, ValueError, TypeError):
|
|
45
|
+
continue
|
|
46
|
+
if not valid_dates:
|
|
47
|
+
return 0, None, None
|
|
48
|
+
valid_dates.sort()
|
|
49
|
+
start_d, end_d = valid_dates[0], valid_dates[-1]
|
|
50
|
+
return (end_d - start_d).days, start_d, end_d
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def calculate_weekly_intervals(history: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
54
|
+
"""Compute weekly rates between successive snapshot intervals."""
|
|
55
|
+
sorted_h = sorted(history, key=lambda x: str(x.get("date", "")))
|
|
56
|
+
intervals = []
|
|
57
|
+
for i in range(len(sorted_h) - 1):
|
|
58
|
+
h1, h2 = sorted_h[i], sorted_h[i + 1]
|
|
59
|
+
try:
|
|
60
|
+
d1, d2 = parse_date(h1["date"]), parse_date(h2["date"])
|
|
61
|
+
except (KeyError, ValueError, TypeError):
|
|
62
|
+
continue
|
|
63
|
+
days = (d2 - d1).days
|
|
64
|
+
if days <= 0:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
scale = 7.0 / days
|
|
68
|
+
open_delta = (h2.get("open", 0) - h1.get("open", 0)) * scale
|
|
69
|
+
new_rate = h2.get("new", 0) * scale
|
|
70
|
+
fixed_rate = h2.get("fixed", 0) * scale
|
|
71
|
+
regressed_rate = h2.get("regressed", 0) * scale
|
|
72
|
+
|
|
73
|
+
# Per severity deltas
|
|
74
|
+
sev1 = h1.get("by_severity", {})
|
|
75
|
+
sev2 = h2.get("by_severity", {})
|
|
76
|
+
sev_deltas = {
|
|
77
|
+
s: (sev2.get(s, 0) - sev1.get(s, 0)) * scale
|
|
78
|
+
for s in SEVERITIES
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
intervals.append({
|
|
82
|
+
"start": d1,
|
|
83
|
+
"end": d2,
|
|
84
|
+
"days": days,
|
|
85
|
+
"open_delta_per_week": open_delta,
|
|
86
|
+
"new_per_week": new_rate,
|
|
87
|
+
"fixed_per_week": fixed_rate,
|
|
88
|
+
"regressed_per_week": regressed_rate,
|
|
89
|
+
"sev_deltas": sev_deltas,
|
|
90
|
+
})
|
|
91
|
+
return intervals
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def compute_rates_and_percentiles(values: List[float]) -> Tuple[float, float, float]:
|
|
95
|
+
"""Compute (p10, p50, p90) values for a list of rates."""
|
|
96
|
+
if not values:
|
|
97
|
+
return 0.0, 0.0, 0.0
|
|
98
|
+
sorted_vals = sorted(values)
|
|
99
|
+
n = len(sorted_vals)
|
|
100
|
+
|
|
101
|
+
def percentile(p: float) -> float:
|
|
102
|
+
k = (n - 1) * p
|
|
103
|
+
f = math.floor(k)
|
|
104
|
+
c = math.ceil(k)
|
|
105
|
+
if f == c:
|
|
106
|
+
return sorted_vals[int(k)]
|
|
107
|
+
d0 = sorted_vals[int(f)] * (c - k)
|
|
108
|
+
d1 = sorted_vals[int(c)] * (k - f)
|
|
109
|
+
return d0 + d1
|
|
110
|
+
|
|
111
|
+
return percentile(0.10), percentile(0.50), percentile(0.90)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def project_debt(
|
|
115
|
+
history: List[Dict[str, Any]],
|
|
116
|
+
targets: Optional[Dict[str, str]] = None,
|
|
117
|
+
what_if_modifiers: Optional[List[Dict[str, Any]]] = None,
|
|
118
|
+
today: Optional[str] = None,
|
|
119
|
+
) -> Dict[str, Any]:
|
|
120
|
+
"""Calculate rates and project debt burndown ranges.
|
|
121
|
+
|
|
122
|
+
Conforms to contracts/schemas/forecast.schema.json.
|
|
123
|
+
"""
|
|
124
|
+
targets = targets or {}
|
|
125
|
+
what_if_modifiers = what_if_modifiers or []
|
|
126
|
+
current_date = parse_date(today) if today else datetime.date.today()
|
|
127
|
+
gen_time = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
128
|
+
|
|
129
|
+
span_days, start_date, end_date = calculate_history_span(history)
|
|
130
|
+
history_summary = {
|
|
131
|
+
"snapshot_count": len(history),
|
|
132
|
+
"span_days": span_days,
|
|
133
|
+
"start_date": format_date(start_date) if start_date else None,
|
|
134
|
+
"end_date": format_date(end_date) if end_date else None,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Guardrails: verify minimum required history
|
|
138
|
+
if len(history) < MIN_SNAPSHOTS or span_days < MIN_SPAN_DAYS:
|
|
139
|
+
latest = history[-1] if history else {}
|
|
140
|
+
open_by_sev = latest.get("by_severity", {})
|
|
141
|
+
projections = []
|
|
142
|
+
for s in ("critical", "high", "overall"):
|
|
143
|
+
cnt = latest.get("open", 0) if s == "overall" else open_by_sev.get(s, 0)
|
|
144
|
+
projections.append({
|
|
145
|
+
"severity": s,
|
|
146
|
+
"current_open": cnt,
|
|
147
|
+
"status": "insufficient_history",
|
|
148
|
+
"net_weekly_change": 0.0,
|
|
149
|
+
"target_zero_date_p10": None,
|
|
150
|
+
"target_zero_date_p50": None,
|
|
151
|
+
"target_zero_date_p90": None,
|
|
152
|
+
"target_date": targets.get(s),
|
|
153
|
+
"target_status": None,
|
|
154
|
+
"required_fix_rate_per_week": None,
|
|
155
|
+
})
|
|
156
|
+
return {
|
|
157
|
+
"version": 1,
|
|
158
|
+
"generated_at": gen_time,
|
|
159
|
+
"has_enough_history": False,
|
|
160
|
+
"reason_if_insufficient": (
|
|
161
|
+
f"Requires at least {MIN_SNAPSHOTS} snapshots over at least {MIN_SPAN_DAYS} days "
|
|
162
|
+
f"(current: {len(history)} snapshots over {span_days} days)."
|
|
163
|
+
),
|
|
164
|
+
"history_summary": history_summary,
|
|
165
|
+
"rates": {
|
|
166
|
+
"arrival_rate_per_week": 0.0,
|
|
167
|
+
"fix_rate_per_week": 0.0,
|
|
168
|
+
"net_rate_per_week": 0.0,
|
|
169
|
+
},
|
|
170
|
+
"projections": projections,
|
|
171
|
+
"scenarios": [],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
intervals = calculate_weekly_intervals(history)
|
|
175
|
+
if not intervals:
|
|
176
|
+
intervals = [{
|
|
177
|
+
"open_delta_per_week": 0.0,
|
|
178
|
+
"new_per_week": 0.0,
|
|
179
|
+
"fixed_per_week": 0.0,
|
|
180
|
+
"regressed_per_week": 0.0,
|
|
181
|
+
"sev_deltas": {s: 0.0 for s in SEVERITIES},
|
|
182
|
+
}]
|
|
183
|
+
|
|
184
|
+
# Average rates across intervals
|
|
185
|
+
avg_arrival = sum(iv["new_per_week"] + iv["regressed_per_week"] for iv in intervals) / len(intervals)
|
|
186
|
+
avg_fix = sum(iv["fixed_per_week"] for iv in intervals) / len(intervals)
|
|
187
|
+
avg_net = sum(iv["open_delta_per_week"] for iv in intervals) / len(intervals)
|
|
188
|
+
|
|
189
|
+
latest_snap = history[-1]
|
|
190
|
+
latest_open = latest_snap.get("open", 0)
|
|
191
|
+
latest_by_sev = latest_snap.get("by_severity", {})
|
|
192
|
+
|
|
193
|
+
projections = []
|
|
194
|
+
# Project for critical, high, overall
|
|
195
|
+
project_targets = [
|
|
196
|
+
("critical", latest_by_sev.get("critical", 0)),
|
|
197
|
+
("high", latest_by_sev.get("high", 0)),
|
|
198
|
+
("overall", latest_open),
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
for sev_name, current_open in project_targets:
|
|
202
|
+
if sev_name == "overall":
|
|
203
|
+
delta_series = [iv["open_delta_per_week"] for iv in intervals]
|
|
204
|
+
else:
|
|
205
|
+
delta_series = [iv["sev_deltas"].get(sev_name, 0.0) for iv in intervals]
|
|
206
|
+
|
|
207
|
+
p10_delta, p50_delta, p90_delta = compute_rates_and_percentiles(delta_series)
|
|
208
|
+
median_net = p50_delta
|
|
209
|
+
|
|
210
|
+
target_date_str = targets.get(sev_name)
|
|
211
|
+
target_date_obj = parse_date(target_date_str) if target_date_str else None
|
|
212
|
+
|
|
213
|
+
if current_open == 0:
|
|
214
|
+
status = "converging"
|
|
215
|
+
p10_date = p50_date = p90_date = format_date(current_date)
|
|
216
|
+
target_status = "on_track" if target_date_obj else None
|
|
217
|
+
req_fix_rate = 0.0
|
|
218
|
+
elif median_net < -0.01:
|
|
219
|
+
status = "converging"
|
|
220
|
+
# Net change is negative -> decreasing open count
|
|
221
|
+
# p10_delta is fastest reduction (most negative), p90_delta is slowest reduction
|
|
222
|
+
# If even p90 is negative:
|
|
223
|
+
fastest_rate = -min(p10_delta, p50_delta)
|
|
224
|
+
median_rate = -p50_delta
|
|
225
|
+
slowest_rate = max(-p90_delta, 0.1)
|
|
226
|
+
|
|
227
|
+
weeks_p10 = current_open / fastest_rate if fastest_rate > 0 else 0
|
|
228
|
+
weeks_p50 = current_open / median_rate if median_rate > 0 else 0
|
|
229
|
+
weeks_p90 = current_open / slowest_rate if slowest_rate > 0 else 0
|
|
230
|
+
|
|
231
|
+
p10_date = format_date(current_date + datetime.timedelta(days=int(weeks_p10 * 7)))
|
|
232
|
+
p50_date = format_date(current_date + datetime.timedelta(days=int(weeks_p50 * 7)))
|
|
233
|
+
p90_date = format_date(current_date + datetime.timedelta(days=int(weeks_p90 * 7)))
|
|
234
|
+
|
|
235
|
+
if target_date_obj:
|
|
236
|
+
avail_days = (target_date_obj - current_date).days
|
|
237
|
+
avail_weeks = max(avail_days / 7.0, 0.1)
|
|
238
|
+
req_fix_rate = round(current_open / avail_weeks, 2)
|
|
239
|
+
p90_obj = parse_date(p90_date)
|
|
240
|
+
p50_obj = parse_date(p50_date)
|
|
241
|
+
if p90_obj <= target_date_obj:
|
|
242
|
+
target_status = "on_track"
|
|
243
|
+
elif p50_obj <= target_date_obj:
|
|
244
|
+
target_status = "at_risk"
|
|
245
|
+
else:
|
|
246
|
+
target_status = "off_track"
|
|
247
|
+
else:
|
|
248
|
+
target_status = None
|
|
249
|
+
req_fix_rate = None
|
|
250
|
+
elif abs(median_net) <= 0.01:
|
|
251
|
+
status = "flat"
|
|
252
|
+
p10_date = p50_date = p90_date = None
|
|
253
|
+
target_status = "not_converging" if target_date_obj else None
|
|
254
|
+
if target_date_obj:
|
|
255
|
+
avail_days = (target_date_obj - current_date).days
|
|
256
|
+
avail_weeks = max(avail_days / 7.0, 0.1)
|
|
257
|
+
req_fix_rate = round(current_open / avail_weeks, 2)
|
|
258
|
+
else:
|
|
259
|
+
req_fix_rate = None
|
|
260
|
+
else:
|
|
261
|
+
status = "diverging"
|
|
262
|
+
p10_date = p50_date = p90_date = None
|
|
263
|
+
target_status = "not_converging" if target_date_obj else None
|
|
264
|
+
if target_date_obj:
|
|
265
|
+
avail_days = (target_date_obj - current_date).days
|
|
266
|
+
avail_weeks = max(avail_days / 7.0, 0.1)
|
|
267
|
+
req_fix_rate = round((current_open / avail_weeks) + median_net, 2)
|
|
268
|
+
else:
|
|
269
|
+
req_fix_rate = None
|
|
270
|
+
|
|
271
|
+
projections.append({
|
|
272
|
+
"severity": sev_name,
|
|
273
|
+
"current_open": current_open,
|
|
274
|
+
"status": status,
|
|
275
|
+
"net_weekly_change": round(median_net, 2),
|
|
276
|
+
"target_zero_date_p10": p10_date,
|
|
277
|
+
"target_zero_date_p50": p50_date,
|
|
278
|
+
"target_zero_date_p90": p90_date,
|
|
279
|
+
"target_date": target_date_str,
|
|
280
|
+
"target_status": target_status,
|
|
281
|
+
"required_fix_rate_per_week": req_fix_rate,
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
# Evaluate What-If scenarios
|
|
285
|
+
scenarios = []
|
|
286
|
+
base_proj = next((p for p in projections if p["severity"] == "overall"), projections[0])
|
|
287
|
+
scenarios.append({
|
|
288
|
+
"scenario_id": "current_pace",
|
|
289
|
+
"description": "Baseline pace using recent 4-week moving average",
|
|
290
|
+
"net_weekly_change": base_proj["net_weekly_change"],
|
|
291
|
+
"projected_date_range": (
|
|
292
|
+
f"{base_proj['target_zero_date_p10']} to {base_proj['target_zero_date_p90']}"
|
|
293
|
+
if base_proj["target_zero_date_p10"] else "Not converging"
|
|
294
|
+
),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
for mod in what_if_modifiers:
|
|
298
|
+
fix_delta = mod.get("fix_rate_delta", 0.0)
|
|
299
|
+
desc = mod.get("description", f"Velocity change: {fix_delta:+.1f} fixes/week")
|
|
300
|
+
scenario_id = mod.get("id", f"what_if_{fix_delta}")
|
|
301
|
+
|
|
302
|
+
sim_net = base_proj["net_weekly_change"] - fix_delta
|
|
303
|
+
if sim_net < -0.01:
|
|
304
|
+
w = latest_open / (-sim_net)
|
|
305
|
+
date_est = format_date(current_date + datetime.timedelta(days=int(w * 7)))
|
|
306
|
+
date_range = f"Estimated ~{date_est}"
|
|
307
|
+
else:
|
|
308
|
+
date_range = "Not converging"
|
|
309
|
+
|
|
310
|
+
scenarios.append({
|
|
311
|
+
"scenario_id": scenario_id,
|
|
312
|
+
"description": desc,
|
|
313
|
+
"net_weekly_change": round(sim_net, 2),
|
|
314
|
+
"projected_date_range": date_range,
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
"version": 1,
|
|
319
|
+
"generated_at": gen_time,
|
|
320
|
+
"has_enough_history": True,
|
|
321
|
+
"history_summary": history_summary,
|
|
322
|
+
"rates": {
|
|
323
|
+
"arrival_rate_per_week": round(avg_arrival, 2),
|
|
324
|
+
"fix_rate_per_week": round(avg_fix, 2),
|
|
325
|
+
"net_rate_per_week": round(avg_net, 2),
|
|
326
|
+
},
|
|
327
|
+
"projections": projections,
|
|
328
|
+
"scenarios": scenarios,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def render_forecast_md(forecast_doc: Dict[str, Any], lang: str = "en") -> List[str]:
|
|
333
|
+
"""Generate Markdown report section for security debt forecast (EN / JA)."""
|
|
334
|
+
if not forecast_doc.get("has_enough_history"):
|
|
335
|
+
reason = forecast_doc.get("reason_if_insufficient", "Insufficient scan history.")
|
|
336
|
+
if lang == "ja":
|
|
337
|
+
return [
|
|
338
|
+
"## 見通しとセキュリティ負債予測 (Outlook & Debt Forecast)",
|
|
339
|
+
"",
|
|
340
|
+
f"> **注意:** 信頼性の高い予測には最低{MIN_SNAPSHOTS}回のスキャン(期間28日以上)が必要です。現時点では十分な履歴データがありません({reason})。",
|
|
341
|
+
"",
|
|
342
|
+
]
|
|
343
|
+
return [
|
|
344
|
+
"## Outlook & Debt Forecast",
|
|
345
|
+
"",
|
|
346
|
+
f"> **Note:** Reliable forecasting requires at least {MIN_SNAPSHOTS} scans spanning at least {MIN_SPAN_DAYS} days. {reason}",
|
|
347
|
+
"",
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
rates = forecast_doc.get("rates", {})
|
|
351
|
+
projections = forecast_doc.get("projections", [])
|
|
352
|
+
scenarios = forecast_doc.get("scenarios", [])
|
|
353
|
+
|
|
354
|
+
if lang == "ja":
|
|
355
|
+
lines = [
|
|
356
|
+
"## 見通しとセキュリティ負債予測 (Outlook & Debt Forecast)",
|
|
357
|
+
"",
|
|
358
|
+
"過去のスキャン履歴から計測した修正速度(Velocity)に基づき、未解決の脆弱性が解消される時期をレンジ(10〜90パーセンタイル)で予測します。",
|
|
359
|
+
"",
|
|
360
|
+
f"- **新規・再発発生率:** {rates.get('arrival_rate_per_week', 0)} 件/週",
|
|
361
|
+
f"- **平均修正消化率:** {rates.get('fix_rate_per_week', 0)} 件/週",
|
|
362
|
+
f"- **週間純増減(Net Velocity):** {rates.get('net_rate_per_week', 0):+.2f} 件/週",
|
|
363
|
+
"",
|
|
364
|
+
"### 目標達成予測 (Target & burndown projections)",
|
|
365
|
+
"",
|
|
366
|
+
"| 重要度 | 残件数 | 週間純増減 | 予測達成時期 (Range) | 目標期限 | 達成確度 |",
|
|
367
|
+
"|---|---|---|---|---|---|",
|
|
368
|
+
]
|
|
369
|
+
status_labels = {
|
|
370
|
+
"on_track": "順調 (On Track)",
|
|
371
|
+
"at_risk": "要警戒 (At Risk)",
|
|
372
|
+
"off_track": "遅延 (Off Track)",
|
|
373
|
+
"not_converging": "収束せず (Not Converging)",
|
|
374
|
+
}
|
|
375
|
+
for p in projections:
|
|
376
|
+
sev = p["severity"].capitalize()
|
|
377
|
+
cur = p["current_open"]
|
|
378
|
+
net = f"{p.get('net_weekly_change', 0):+.2f}/週"
|
|
379
|
+
if p["status"] == "converging":
|
|
380
|
+
rng = f"{p['target_zero_date_p10']} 〜 {p['target_zero_date_p90']}"
|
|
381
|
+
else:
|
|
382
|
+
rng = "現状ペースでは収束せず"
|
|
383
|
+
tgt = p.get("target_date") or "-"
|
|
384
|
+
t_status = status_labels.get(p.get("target_status"), "-")
|
|
385
|
+
lines.append(f"| {sev} | {cur} | {net} | {rng} | {tgt} | {t_status} |")
|
|
386
|
+
|
|
387
|
+
if len(scenarios) > 1:
|
|
388
|
+
lines += ["", "### What-If シナリオ分析", "", "| シナリオ | 週間増減 | 予測完了時期 |", "|---|---|---|"]
|
|
389
|
+
for sc in scenarios:
|
|
390
|
+
lines.append(f"| {sc['description']} | {sc['net_weekly_change']:+.2f}/週 | {sc['projected_date_range']} |")
|
|
391
|
+
lines.append("")
|
|
392
|
+
else:
|
|
393
|
+
lines = [
|
|
394
|
+
"## Outlook & Debt Forecast",
|
|
395
|
+
"",
|
|
396
|
+
"Projects target completion dates based on historical fix velocity, showing uncertainty ranges honestly.",
|
|
397
|
+
"",
|
|
398
|
+
f"- **Arrival Rate:** {rates.get('arrival_rate_per_week', 0)} findings/week",
|
|
399
|
+
f"- **Fix Rate:** {rates.get('fix_rate_per_week', 0)} findings/week",
|
|
400
|
+
f"- **Net Velocity:** {rates.get('net_rate_per_week', 0):+.2f} findings/week",
|
|
401
|
+
"",
|
|
402
|
+
"### Burndown Projections",
|
|
403
|
+
"",
|
|
404
|
+
"| Scope | Open | Net Change | Projected Date (P10 - P90) | Target Date | Target Status |",
|
|
405
|
+
"|---|---|---|---|---|---|",
|
|
406
|
+
]
|
|
407
|
+
status_labels = {
|
|
408
|
+
"on_track": "On Track",
|
|
409
|
+
"at_risk": "At Risk",
|
|
410
|
+
"off_track": "Off Track",
|
|
411
|
+
"not_converging": "Not Converging",
|
|
412
|
+
}
|
|
413
|
+
for p in projections:
|
|
414
|
+
sev = p["severity"].capitalize()
|
|
415
|
+
cur = p["current_open"]
|
|
416
|
+
net = f"{p.get('net_weekly_change', 0):+.2f}/wk"
|
|
417
|
+
if p["status"] == "converging":
|
|
418
|
+
rng = f"{p['target_zero_date_p10']} to {p['target_zero_date_p90']}"
|
|
419
|
+
else:
|
|
420
|
+
rng = "Not converging"
|
|
421
|
+
tgt = p.get("target_date") or "-"
|
|
422
|
+
t_status = status_labels.get(p.get("target_status"), "-")
|
|
423
|
+
lines.append(f"| {sev} | {cur} | {net} | {rng} | {tgt} | {t_status} |")
|
|
424
|
+
|
|
425
|
+
if len(scenarios) > 1:
|
|
426
|
+
lines += ["", "### What-If Scenarios", "", "| Scenario | Net Velocity | Projected Completion |", "|---|---|---|"]
|
|
427
|
+
for sc in scenarios:
|
|
428
|
+
lines.append(f"| {sc['description']} | {sc['net_weekly_change']:+.2f}/wk | {sc['projected_date_range']} |")
|
|
429
|
+
lines.append("")
|
|
430
|
+
|
|
431
|
+
return lines
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def render_forecast_html(forecast_doc: Dict[str, Any]) -> str:
|
|
435
|
+
"""Generate self-contained bilingual HTML card for security debt forecast."""
|
|
436
|
+
if not forecast_doc.get("has_enough_history"):
|
|
437
|
+
reason = html.escape(forecast_doc.get("reason_if_insufficient", "Insufficient scan history."))
|
|
438
|
+
return (
|
|
439
|
+
'<section class="card" aria-label="Outlook & Debt Forecast">'
|
|
440
|
+
'<h2 class="card-title" data-en="Outlook & Debt Forecast" data-ja="見通しとセキュリティ負債予測 (Outlook)">Outlook & Debt Forecast</h2>'
|
|
441
|
+
f'<div class="forecast-alert"><span class="badge badge-info">INFO</span> '
|
|
442
|
+
f'<span data-en="Requires at least 6 scans over 28 days for reliable projections. ({reason})" '
|
|
443
|
+
f'data-ja="信頼性の高い予測には最低6回・28日以上の履歴が必要です。({reason})">'
|
|
444
|
+
f'Requires at least 6 scans over 28 days for reliable projections. ({reason})</span></div>'
|
|
445
|
+
'</section>'
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
rates = forecast_doc.get("rates", {})
|
|
449
|
+
projections = forecast_doc.get("projections", [])
|
|
450
|
+
scenarios = forecast_doc.get("scenarios", [])
|
|
451
|
+
|
|
452
|
+
rows = []
|
|
453
|
+
for p in projections:
|
|
454
|
+
sev = html.escape(p["severity"].capitalize())
|
|
455
|
+
cur = p["current_open"]
|
|
456
|
+
net = f"{p.get('net_weekly_change', 0):+.2f}"
|
|
457
|
+
if p["status"] == "converging":
|
|
458
|
+
rng = f"{p['target_zero_date_p10']} → {p['target_zero_date_p90']}"
|
|
459
|
+
else:
|
|
460
|
+
rng = '<span class="badge badge-critical">Not converging</span>'
|
|
461
|
+
tgt = html.escape(p.get("target_date") or "-")
|
|
462
|
+
t_stat = p.get("target_status")
|
|
463
|
+
badge_cls = "badge-ok" if t_stat == "on_track" else ("badge-high" if t_stat == "at_risk" else "badge-critical")
|
|
464
|
+
stat_html = f'<span class="badge {badge_cls}">{html.escape(str(t_stat or "-"))}</span>'
|
|
465
|
+
rows.append(f"<tr><td><strong>{sev}</strong></td><td>{cur}</td><td>{net}/wk</td><td>{rng}</td><td>{tgt}</td><td>{stat_html}</td></tr>")
|
|
466
|
+
|
|
467
|
+
scenario_rows = "".join(
|
|
468
|
+
f"<tr><td>{html.escape(sc['description'])}</td><td>{sc['net_weekly_change']:+.2f}/wk</td><td><strong>{html.escape(sc['projected_date_range'] or '')}</strong></td></tr>"
|
|
469
|
+
for sc in scenarios
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
return (
|
|
473
|
+
'<section class="card" aria-label="Outlook & Debt Forecast">'
|
|
474
|
+
'<h2 class="card-title" data-en="Outlook & Debt Forecast" data-ja="見通しとセキュリティ負債予測 (Outlook)">Outlook & Debt Forecast</h2>'
|
|
475
|
+
'<div class="forecast-stats-grid">'
|
|
476
|
+
f'<div class="forecast-stat-box"><div class="stat-lbl" data-en="Arrival Rate" data-ja="新規・再発率">Arrival Rate</div><div class="stat-val">{rates.get("arrival_rate_per_week", 0)}/wk</div></div>'
|
|
477
|
+
f'<div class="forecast-stat-box"><div class="stat-lbl" data-en="Fix Velocity" data-ja="修正消化速度">Fix Velocity</div><div class="stat-val" style="color:var(--ok);">{rates.get("fix_rate_per_week", 0)}/wk</div></div>'
|
|
478
|
+
f'<div class="forecast-stat-box"><div class="stat-lbl" data-en="Net Velocity" data-ja="純増減速度">Net Velocity</div><div class="stat-val">{rates.get("net_rate_per_week", 0):+.2f}/wk</div></div>'
|
|
479
|
+
'</div>'
|
|
480
|
+
'<table><thead><tr>'
|
|
481
|
+
'<th data-en="Scope" data-ja="対象">Scope</th>'
|
|
482
|
+
'<th data-en="Open" data-ja="残件">Open</th>'
|
|
483
|
+
'<th data-en="Velocity" data-ja="純増減">Velocity</th>'
|
|
484
|
+
'<th data-en="Projected Zero Date (P10 - P90)" data-ja="完了予測レンジ (P10 - P90)">Projected Zero Date</th>'
|
|
485
|
+
'<th data-en="Target Date" data-ja="目標期限">Target Date</th>'
|
|
486
|
+
'<th data-en="Target Status" data-ja="進捗状況">Target Status</th>'
|
|
487
|
+
f'</tr></thead><tbody>{"".join(rows)}</tbody></table>'
|
|
488
|
+
+ (
|
|
489
|
+
'<h3>What-If Scenarios</h3>'
|
|
490
|
+
f'<table><thead><tr><th>Scenario</th><th>Net Velocity</th><th>Projected Date</th></tr></thead><tbody>{scenario_rows}</tbody></table>'
|
|
491
|
+
if len(scenarios) > 1 else ''
|
|
492
|
+
) +
|
|
493
|
+
'</section>'
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def main() -> int:
|
|
498
|
+
parser = argparse.ArgumentParser(description="Forecast security debt from scan history.")
|
|
499
|
+
parser.add_argument("out_dir", type=Path, help="Path to audit OUT directory.")
|
|
500
|
+
parser.add_argument("--baseline", type=Path, default=None, help="Path to baseline.json (defaults to OUT/baseline.json).")
|
|
501
|
+
parser.add_argument("--output", type=Path, default=None, help="Output forecast.json path.")
|
|
502
|
+
parser.add_argument("--target", action="append", help="Target deadline as scope=YYYY-MM-DD (e.g. critical=2027-04-01).")
|
|
503
|
+
parser.add_argument("--what-if", action="append", dest="what_ifs", help="What-if as fix-rate=+N (e.g. fix-rate=+2).")
|
|
504
|
+
args = parser.parse_args()
|
|
505
|
+
|
|
506
|
+
baseline_file = args.baseline or (args.out_dir / "baseline.json")
|
|
507
|
+
history = []
|
|
508
|
+
if baseline_file.is_file():
|
|
509
|
+
with open(baseline_file, encoding="utf-8") as fh:
|
|
510
|
+
b_doc = json.load(fh)
|
|
511
|
+
history = b_doc.get("history", [])
|
|
512
|
+
|
|
513
|
+
targets = {}
|
|
514
|
+
if args.target:
|
|
515
|
+
for t in args.target:
|
|
516
|
+
if "=" in t:
|
|
517
|
+
k, v = t.split("=", 1)
|
|
518
|
+
targets[k.strip()] = v.strip()
|
|
519
|
+
|
|
520
|
+
what_if_list = []
|
|
521
|
+
if args.what_ifs:
|
|
522
|
+
for w in args.what_ifs:
|
|
523
|
+
if "fix-rate=" in w:
|
|
524
|
+
val = float(w.split("fix-rate=")[1])
|
|
525
|
+
what_if_list.append({
|
|
526
|
+
"id": f"what_if_fix_rate_{val:+g}",
|
|
527
|
+
"fix_rate_delta": val,
|
|
528
|
+
"description": f"Remediation acceleration: {val:+g} fixes/week",
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
forecast_doc = project_debt(history, targets, what_if_list)
|
|
532
|
+
|
|
533
|
+
out_file = args.output or (args.out_dir / "forecast.json")
|
|
534
|
+
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
with open(out_file, "w", encoding="utf-8") as fh:
|
|
536
|
+
json.dump(forecast_doc, fh, ensure_ascii=False, indent=2)
|
|
537
|
+
fh.write("\n")
|
|
538
|
+
|
|
539
|
+
print(f"✔ Security debt forecast written to {out_file}")
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
if __name__ == "__main__":
|
|
544
|
+
sys.exit(main())
|
|
@@ -385,6 +385,20 @@ def main() -> int:
|
|
|
385
385
|
f.pop("_source", None)
|
|
386
386
|
write_json(out / "findings.json", {"generated_by": f"lsa merge_findings.py {KIT_VERSION}", "summary": summary, "findings": ordered})
|
|
387
387
|
write_json(out / "coverage.json", cov)
|
|
388
|
+
|
|
389
|
+
# Automatically generate attack chains from linked findings
|
|
390
|
+
try:
|
|
391
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
392
|
+
from attack_chains import build_attack_chains # noqa: E402
|
|
393
|
+
routes_list = []
|
|
394
|
+
if (out / "routes.json").exists():
|
|
395
|
+
r_doc = read_json(out / "routes.json")
|
|
396
|
+
routes_list = r_doc.get("routes", []) if isinstance(r_doc, dict) else []
|
|
397
|
+
chains_doc = build_attack_chains(ordered, routes_list)
|
|
398
|
+
write_json(out / "attack-chains.json", chains_doc)
|
|
399
|
+
except Exception:
|
|
400
|
+
pass
|
|
401
|
+
|
|
388
402
|
gaps = any(cov.get(k) for k in ("missing_from_inventory", "bola_routes_without_verdict",
|
|
389
403
|
"injection_candidates_without_verdict", "bfla_routes_without_verdict",
|
|
390
404
|
"mass_candidates_without_verdict", "validation_errors",
|