@hoanghainh/komagu 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assess/lsa/CHANGELOG.md +15 -0
  3. package/assess/lsa/scripts/attack_chains.py +573 -0
  4. package/assess/lsa/scripts/baseline.py +14 -0
  5. package/assess/lsa/scripts/detect_stack.py +2 -1
  6. package/assess/lsa/scripts/findings_to_sarif.py +11 -0
  7. package/assess/lsa/scripts/forecast.py +544 -0
  8. package/assess/lsa/scripts/lsa_common.py +1 -5
  9. package/assess/lsa/scripts/merge_findings.py +14 -0
  10. package/assess/lsa/scripts/redaction.py +118 -0
  11. package/assess/lsa/scripts/render_report.py +86 -0
  12. package/assess/lsa/tests/test_attack_chains.py +292 -0
  13. package/assess/lsa/tests/test_forecast.py +160 -0
  14. package/assess/lsa/tests/test_modern_stacks.py +13 -0
  15. package/contracts/schemas/api-inventory-diff.schema.json +213 -0
  16. package/contracts/schemas/attack-chain.schema.json +181 -0
  17. package/contracts/schemas/bas-scenario.schema.json +217 -0
  18. package/contracts/schemas/evidence-manifest.schema.json +117 -0
  19. package/contracts/schemas/finding.schema.json +7 -0
  20. package/contracts/schemas/forecast.schema.json +172 -0
  21. package/governance/scs/__init__.py +21 -0
  22. package/governance/scs/mapping.py +133 -0
  23. package/governance/scs/pack.py +354 -0
  24. package/operate/ai/context.py +22 -2
  25. package/operate/cli/__init__.py +1 -1
  26. package/operate/cli/commands/evidence.py +138 -0
  27. package/operate/cli/commands/inventory.py +187 -0
  28. package/operate/cli/commands/simulate.py +175 -58
  29. package/operate/cli/main.py +7 -3
  30. package/operate/inventory/__init__.py +22 -0
  31. package/operate/inventory/api_inventory.py +566 -0
  32. package/operate/simulate/__init__.py +16 -0
  33. package/operate/simulate/engine.py +370 -0
  34. package/operate/simulate/loader.py +158 -0
  35. package/operate/simulate/scenarios/idc-frontier-ransomware.json +47 -0
  36. package/operate/simulate/scenarios/metabase-cve-2026-72898.json +84 -0
  37. package/operate/simulate/scenarios/times-car-breach.json +74 -0
  38. package/operate/simulate/scenarios.py +52 -0
  39. package/operate/tests/test_ai_redaction.py +181 -0
  40. package/operate/tests/test_api_inventory.py +239 -0
  41. package/operate/tests/test_declarative_scenarios.py +169 -0
  42. package/operate/tests/test_evidence_pack.py +128 -0
  43. package/operate/tests/test_headless_ai.py +16 -1
  44. package/package.json +1 -1
@@ -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)
@@ -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 &amp; Debt Forecast">'
440
+ '<h2 class="card-title" data-en="Outlook &amp; Debt Forecast" data-ja="見通しとセキュリティ負債予測 (Outlook)">Outlook &amp; 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']} &rarr; {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 &amp; Debt Forecast">'
474
+ '<h2 class="card-title" data-en="Outlook &amp; Debt Forecast" data-ja="見通しとセキュリティ負債予測 (Outlook)">Outlook &amp; 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())
@@ -166,8 +166,4 @@ def snippet(lines: List[str], lineno: int, max_len: int = 220) -> str:
166
166
  return s if len(s) <= max_len else s[: max_len - 3] + "..."
167
167
 
168
168
 
169
- SECRET_RE = re.compile(r"(?i)(password|passwd|pwd|secret|api[_-]?key|token)\s*[=:]\s*['\"][^'\"]{4,}['\"]")
170
-
171
-
172
- def redact(s: str) -> str:
173
- return SECRET_RE.sub(lambda m: m.group(1) + "=[REDACTED]", s)
169
+ from redaction import redact, redact_lines # noqa: E402,F401 (re-exported for the Tier 1 scripts)
@@ -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",