@hoanghainh/komagu 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assess/lsa/CHANGELOG.md +15 -0
  3. package/assess/lsa/scripts/attack_chains.py +573 -0
  4. package/assess/lsa/scripts/baseline.py +14 -0
  5. package/assess/lsa/scripts/detect_stack.py +2 -1
  6. package/assess/lsa/scripts/findings_to_sarif.py +11 -0
  7. package/assess/lsa/scripts/forecast.py +544 -0
  8. package/assess/lsa/scripts/lsa_common.py +1 -5
  9. package/assess/lsa/scripts/merge_findings.py +14 -0
  10. package/assess/lsa/scripts/redaction.py +118 -0
  11. package/assess/lsa/scripts/render_report.py +86 -0
  12. package/assess/lsa/tests/test_attack_chains.py +292 -0
  13. package/assess/lsa/tests/test_forecast.py +160 -0
  14. package/assess/lsa/tests/test_modern_stacks.py +13 -0
  15. package/contracts/schemas/api-inventory-diff.schema.json +213 -0
  16. package/contracts/schemas/attack-chain.schema.json +181 -0
  17. package/contracts/schemas/bas-scenario.schema.json +217 -0
  18. package/contracts/schemas/evidence-manifest.schema.json +117 -0
  19. package/contracts/schemas/finding.schema.json +7 -0
  20. package/contracts/schemas/forecast.schema.json +172 -0
  21. package/governance/scs/__init__.py +21 -0
  22. package/governance/scs/mapping.py +133 -0
  23. package/governance/scs/pack.py +354 -0
  24. package/operate/ai/context.py +22 -2
  25. package/operate/cli/__init__.py +1 -1
  26. package/operate/cli/commands/evidence.py +138 -0
  27. package/operate/cli/commands/inventory.py +187 -0
  28. package/operate/cli/commands/simulate.py +175 -58
  29. package/operate/cli/main.py +7 -3
  30. package/operate/inventory/__init__.py +22 -0
  31. package/operate/inventory/api_inventory.py +566 -0
  32. package/operate/simulate/__init__.py +16 -0
  33. package/operate/simulate/engine.py +370 -0
  34. package/operate/simulate/loader.py +158 -0
  35. package/operate/simulate/scenarios/idc-frontier-ransomware.json +47 -0
  36. package/operate/simulate/scenarios/metabase-cve-2026-72898.json +84 -0
  37. package/operate/simulate/scenarios/times-car-breach.json +74 -0
  38. package/operate/simulate/scenarios.py +52 -0
  39. package/operate/tests/test_ai_redaction.py +181 -0
  40. package/operate/tests/test_api_inventory.py +239 -0
  41. package/operate/tests/test_declarative_scenarios.py +169 -0
  42. package/operate/tests/test_evidence_pack.py +128 -0
  43. package/operate/tests/test_headless_ai.py +16 -1
  44. package/package.json +1 -1
@@ -0,0 +1,566 @@
1
+ #!/usr/bin/env python3
2
+ """API Inventory & Shadow/Zombie API Detection Engine.
3
+
4
+ Standard-library-only (Python 3.8+).
5
+
6
+ Compares three sources:
7
+ 1. Routes extracted from source code (routes.json / routes.candidates.json from LSA),
8
+ 2. OpenAPI / Swagger specification published by the team,
9
+ 3. Live traffic logs captured at the API gateway (Kong HTTP-Log / NGINX access log).
10
+
11
+ Detects:
12
+ - Shadow APIs: Endpoints in code or active traffic missing from OpenAPI spec.
13
+ - Zombie APIs: Endpoints marked deprecated in OpenAPI spec but still receiving traffic.
14
+ - Documented Missing in Code: Declared in spec but not implemented.
15
+ - Dormant Code: Documented code endpoints receiving zero traffic.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import datetime
21
+ import html
22
+ import json
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional, Set, Tuple
27
+
28
+ # Path parameter patterns for normalization (shared semantics with fingerprint.py)
29
+ _PATH_PARAM_RES = (
30
+ re.compile(r"\{[^/}]*\}"), # /orders/{id}
31
+ re.compile(r"<[^/>]*>"), # Flask /orders/<int:id>
32
+ re.compile(r"(?<=/):[A-Za-z_][A-Za-z0-9_]*"), # Express /orders/:orderId
33
+ re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"), # UUIDs
34
+ re.compile(r"(?<=/)[0-9a-fA-F]{24,}(?=/|$)"), # MongoDB ObjectId / hex hashes
35
+ re.compile(r"(?<=/)[0-9]+(?=/|$)"), # Concrete integer IDs /orders/123
36
+ )
37
+
38
+ # Combined / Common Log Format (Nginx / Kong standard)
39
+ _COMBINED_LOG_RE = re.compile(
40
+ r'^[^\s]+\s+[^\s]+\s+[^\s]+\s+\[(?P<time>[^\]]+)\]\s+"(?P<method>[A-Z]+)\s+(?P<uri>[^\s\?]+)(?:\?[^\s"]*)?\s+HTTP/[0-9\.]+"\s+(?P<status>\d{3})'
41
+ )
42
+
43
+ HTTP_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}
44
+
45
+
46
+ def normalize_path(path: str) -> str:
47
+ """Strip query params and replace variable route parameters with {}."""
48
+ if not path:
49
+ return "/"
50
+ p = path.split("?")[0].strip()
51
+ if not p.startswith("/"):
52
+ p = "/" + p
53
+ # Remove trailing slash except for root
54
+ if len(p) > 1 and p.endswith("/"):
55
+ p = p[:-1]
56
+
57
+ for pat in _PATH_PARAM_RES:
58
+ p = pat.sub("{}", p)
59
+ return p
60
+
61
+
62
+ def normalize_route_key(method: str, path: str) -> str:
63
+ """Format canonical uppercase method and normalized path: 'GET /api/orders/{}'."""
64
+ return f"{method.upper().strip()} {normalize_path(path)}"
65
+
66
+
67
+ def route_pattern_to_regex(normalized_key: str) -> re.Pattern:
68
+ """Convert a normalized route key (e.g. 'GET /api/orders/{}') into a matching regex."""
69
+ parts = normalized_key.split(" ", 1)
70
+ if len(parts) != 2:
71
+ return re.compile(r"^$")
72
+ method, path_templ = parts
73
+ # Escape regex specials except {}
74
+ escaped = re.escape(path_templ).replace(r"\{\}", r"[^/]+")
75
+ return re.compile(f"^{re.escape(method)}\\s+{escaped}$")
76
+
77
+
78
+ def load_code_routes(routes_path: Path) -> List[Dict[str, Any]]:
79
+ """Parse routes from LSA routes.json or routes.candidates.json."""
80
+ if not routes_path.is_file():
81
+ return []
82
+ with open(routes_path, encoding="utf-8") as fh:
83
+ doc = json.load(fh)
84
+
85
+ raw_list = doc.get("routes", []) if isinstance(doc, dict) else (doc if isinstance(doc, list) else [])
86
+ parsed = []
87
+ for r in raw_list:
88
+ if not isinstance(r, dict):
89
+ continue
90
+ m = (r.get("method") or "GET").upper()
91
+ p = r.get("path") or "/"
92
+ loc = r.get("location") or r.get("file") or ""
93
+ norm = normalize_route_key(m, p)
94
+ parsed.append({
95
+ "method": m,
96
+ "path": p,
97
+ "normalized": norm,
98
+ "location": loc,
99
+ "route_id": r.get("route_id", ""),
100
+ "raw": r,
101
+ })
102
+ return parsed
103
+
104
+
105
+ def load_openapi_spec(spec_path: Path) -> List[Dict[str, Any]]:
106
+ """Parse endpoints and deprecation status from OpenAPI/Swagger JSON or simple YAML."""
107
+ if not spec_path.is_file():
108
+ return []
109
+
110
+ content = spec_path.read_text(encoding="utf-8").strip()
111
+ doc = None
112
+ if content.startswith("{"):
113
+ try:
114
+ doc = json.loads(content)
115
+ except json.JSONDecodeError:
116
+ pass
117
+
118
+ if doc is None:
119
+ # Fallback basic YAML parser for paths/methods
120
+ doc = _parse_simple_openapi_yaml(content)
121
+
122
+ if not isinstance(doc, dict):
123
+ return []
124
+
125
+ endpoints = []
126
+ paths_dict = doc.get("paths", {})
127
+ if isinstance(paths_dict, dict):
128
+ for path_key, item in paths_dict.items():
129
+ if not isinstance(item, dict):
130
+ continue
131
+ path_deprecated = bool(item.get("deprecated", False))
132
+ for m_key, op in item.items():
133
+ m_upper = m_key.upper()
134
+ if m_upper in HTTP_METHODS and isinstance(op, dict):
135
+ is_dep = path_deprecated or bool(op.get("deprecated", False))
136
+ summary = op.get("summary") or op.get("description") or ""
137
+ endpoints.append({
138
+ "method": m_upper,
139
+ "path": path_key,
140
+ "normalized": normalize_route_key(m_upper, path_key),
141
+ "deprecated": is_dep,
142
+ "summary": summary,
143
+ })
144
+ return endpoints
145
+
146
+
147
+ def _parse_simple_openapi_yaml(yaml_text: str) -> Dict[str, Any]:
148
+ """Lightweight pure stdlib parser for OpenAPI YAML path structures."""
149
+ # When PyYAML is not in standard library, parse basic indented paths structure
150
+ paths: Dict[str, Any] = {}
151
+ current_path = None
152
+ current_method = None
153
+
154
+ for raw_line in yaml_text.splitlines():
155
+ line = raw_line.rstrip()
156
+ if not line or line.strip().startswith("#"):
157
+ continue
158
+
159
+ indent = len(line) - len(line.lstrip())
160
+ stripped = line.strip()
161
+
162
+ # Path level (e.g. " /api/orders/{id}:")
163
+ if stripped.startswith("/") and stripped.endswith(":"):
164
+ current_path = stripped[:-1].strip()
165
+ paths[current_path] = {}
166
+ current_method = None
167
+ # Method level (e.g. " get:")
168
+ elif current_path and ":" in stripped and indent >= 4:
169
+ key, val = stripped.split(":", 1)
170
+ key = key.strip().lower()
171
+ if key in {"get", "post", "put", "delete", "patch", "options", "head"}:
172
+ current_method = key
173
+ paths[current_path][current_method] = {}
174
+ elif current_method and key == "deprecated":
175
+ paths[current_path][current_method]["deprecated"] = val.strip().lower() in ("true", "yes")
176
+
177
+ return {"paths": paths}
178
+
179
+
180
+ def load_traffic_log(log_path: Optional[Path]) -> Dict[str, Dict[str, Any]]:
181
+ """Parse traffic logs from Kong HTTP-Log (JSON lines) or NGINX Combined Log Format."""
182
+ if not log_path or not log_path.is_file():
183
+ return {}
184
+
185
+ traffic: Dict[str, Dict[str, Any]] = {}
186
+
187
+ with open(log_path, encoding="utf-8", errors="replace") as fh:
188
+ for line in fh:
189
+ line = line.strip()
190
+ if not line:
191
+ continue
192
+
193
+ method, uri, timestamp = None, None, None
194
+
195
+ # Try JSON (Kong HTTP-Log plugin / NDJSON)
196
+ if line.startswith("{") and line.endswith("}"):
197
+ try:
198
+ obj = json.loads(line)
199
+ req = obj.get("request", {}) if isinstance(obj.get("request"), dict) else obj
200
+ method = (req.get("method") or "").upper()
201
+ uri = req.get("uri") or req.get("url") or req.get("path") or ""
202
+ # strip scheme/host if full URL
203
+ if "://" in uri:
204
+ uri = "/" + uri.split("://", 1)[1].split("/", 1)[-1]
205
+ timestamp = str(obj.get("started_at") or obj.get("timestamp") or "")
206
+ except json.JSONDecodeError:
207
+ pass
208
+
209
+ # Try NGINX / Kong Combined Log Format
210
+ if not method or not uri:
211
+ m = _COMBINED_LOG_RE.match(line)
212
+ if m:
213
+ method = m.group("method").upper()
214
+ uri = m.group("uri")
215
+ timestamp = m.group("time")
216
+
217
+ if method and uri and method in HTTP_METHODS:
218
+ norm_key = normalize_route_key(method, uri)
219
+ if norm_key not in traffic:
220
+ traffic[norm_key] = {
221
+ "method": method,
222
+ "path": uri,
223
+ "normalized": norm_key,
224
+ "traffic_hits": 0,
225
+ "last_seen": timestamp or "unknown",
226
+ }
227
+ traffic[norm_key]["traffic_hits"] += 1
228
+ if timestamp:
229
+ traffic[norm_key]["last_seen"] = timestamp
230
+
231
+ return traffic
232
+
233
+
234
+ def match_request_to_templates(req_norm_key: str, template_keys: Set[str]) -> Optional[str]:
235
+ """Match a normalized concrete request against known route templates."""
236
+ if req_norm_key in template_keys:
237
+ return req_norm_key
238
+
239
+ # Check parameter wildcards
240
+ req_parts = req_norm_key.split(" ", 1)
241
+ if len(req_parts) != 2:
242
+ return None
243
+ req_m, req_p = req_parts
244
+
245
+ for tpl in template_keys:
246
+ tpl_parts = tpl.split(" ", 1)
247
+ if len(tpl_parts) != 2:
248
+ continue
249
+ tpl_m, tpl_p = tpl_parts
250
+ if req_m != tpl_m:
251
+ continue
252
+ # Compare segment lengths
253
+ r_segs = [s for s in req_p.split("/") if s]
254
+ t_segs = [s for s in tpl_p.split("/") if s]
255
+ if len(r_segs) != len(t_segs):
256
+ continue
257
+ matched = True
258
+ for rs, ts in zip(r_segs, t_segs):
259
+ if ts == "{}":
260
+ continue
261
+ if rs != ts:
262
+ matched = False
263
+ break
264
+ if matched:
265
+ return tpl
266
+ return None
267
+
268
+
269
+ def diff_api_inventory(
270
+ code_routes: List[Dict[str, Any]],
271
+ spec_endpoints: List[Dict[str, Any]],
272
+ traffic_data: Dict[str, Dict[str, Any]],
273
+ routes_file_name: Optional[str] = None,
274
+ openapi_file_name: Optional[str] = None,
275
+ traffic_file_name: Optional[str] = None,
276
+ ) -> Dict[str, Any]:
277
+ """Execute 3-way reconciliation to discover Shadow, Zombie, Dead, and Dormant APIs."""
278
+ gen_time = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
279
+
280
+ # Normalize code routes if not pre-normalized
281
+ norm_code_routes = []
282
+ for r in code_routes:
283
+ if "normalized" not in r:
284
+ m = (r.get("method") or "GET").upper()
285
+ p = r.get("path") or "/"
286
+ norm_code_routes.append({**r, "method": m, "path": p, "normalized": normalize_route_key(m, p)})
287
+ else:
288
+ norm_code_routes.append(r)
289
+
290
+ # Normalize spec endpoints if not pre-normalized
291
+ norm_spec_endpoints = []
292
+ for s in spec_endpoints:
293
+ if "normalized" not in s:
294
+ m = (s.get("method") or "GET").upper()
295
+ p = s.get("path") or "/"
296
+ norm_spec_endpoints.append({**s, "method": m, "path": p, "normalized": normalize_route_key(m, p)})
297
+ else:
298
+ norm_spec_endpoints.append(s)
299
+
300
+ # Map specs by normalized key
301
+ spec_map: Dict[str, Dict[str, Any]] = {e["normalized"]: e for e in norm_spec_endpoints}
302
+ # Map code routes by normalized key
303
+ code_map: Dict[str, Dict[str, Any]] = {r["normalized"]: r for r in norm_code_routes}
304
+
305
+ spec_keys = set(spec_map.keys())
306
+ code_keys = set(code_map.keys())
307
+
308
+ # Map traffic hits to code and spec templates
309
+ traffic_matched_spec: Dict[str, int] = {}
310
+ traffic_matched_code: Dict[str, int] = {}
311
+ unmatched_traffic: List[Dict[str, Any]] = []
312
+
313
+ for req_key, t_info in traffic_data.items():
314
+ matched_spec_key = match_request_to_templates(req_key, spec_keys)
315
+ if matched_spec_key:
316
+ traffic_matched_spec[matched_spec_key] = traffic_matched_spec.get(matched_spec_key, 0) + t_info["traffic_hits"]
317
+
318
+ matched_code_key = match_request_to_templates(req_key, code_keys)
319
+ if matched_code_key:
320
+ traffic_matched_code[matched_code_key] = traffic_matched_code.get(matched_code_key, 0) + t_info["traffic_hits"]
321
+
322
+ if not matched_spec_key:
323
+ unmatched_traffic.append({
324
+ **t_info,
325
+ "matched_code": matched_code_key,
326
+ })
327
+
328
+ # 1. Shadow APIs: Present in code or traffic, but absent from OpenAPI spec
329
+ shadow_apis: List[Dict[str, Any]] = []
330
+ seen_shadow_keys: Set[str] = set()
331
+
332
+ for r_key, r_info in code_map.items():
333
+ if not match_request_to_templates(r_key, spec_keys):
334
+ seen_shadow_keys.add(r_key)
335
+ t_hits = traffic_matched_code.get(r_key, 0)
336
+ discovered = "code_and_traffic" if t_hits > 0 else "code_only"
337
+ shadow_apis.append({
338
+ "method": r_info["method"],
339
+ "path": r_info["path"],
340
+ "normalized": r_key,
341
+ "discovered_in": discovered,
342
+ "location": r_info.get("location", ""),
343
+ "traffic_hits": t_hits,
344
+ })
345
+
346
+ # Traffic requests never documented and not matched by code routes
347
+ for u_req in unmatched_traffic:
348
+ u_key = u_req["normalized"]
349
+ if u_key not in seen_shadow_keys and not u_req.get("matched_code"):
350
+ seen_shadow_keys.add(u_key)
351
+ shadow_apis.append({
352
+ "method": u_req["method"],
353
+ "path": u_req["path"],
354
+ "normalized": u_key,
355
+ "discovered_in": "traffic_only",
356
+ "location": "Gateway Traffic",
357
+ "traffic_hits": u_req["traffic_hits"],
358
+ })
359
+
360
+ # 2. Zombie APIs: Deprecated in OpenAPI spec, but receiving traffic
361
+ zombie_apis: List[Dict[str, Any]] = []
362
+ for s_key, s_info in spec_map.items():
363
+ if s_info.get("deprecated"):
364
+ hits = traffic_matched_spec.get(s_key, 0)
365
+ if hits > 0:
366
+ loc = code_map.get(s_key, {}).get("location", "Documented in Spec")
367
+ zombie_apis.append({
368
+ "method": s_info["method"],
369
+ "path": s_info["path"],
370
+ "normalized": s_key,
371
+ "location": loc,
372
+ "traffic_hits": hits,
373
+ "last_seen": gen_time,
374
+ })
375
+
376
+ # 3. Documented Missing in Code: Declared in spec, not in code
377
+ missing_code: List[Dict[str, Any]] = []
378
+ for s_key, s_info in spec_map.items():
379
+ if not match_request_to_templates(s_key, code_keys):
380
+ missing_code.append({
381
+ "method": s_info["method"],
382
+ "path": s_info["path"],
383
+ "normalized": s_key,
384
+ "summary": s_info.get("summary", ""),
385
+ })
386
+
387
+ # 4. Dormant Code: In code & spec, but 0 traffic (only if traffic log was supplied)
388
+ dormant_code: List[Dict[str, Any]] = []
389
+ if traffic_data:
390
+ for r_key, r_info in code_map.items():
391
+ if match_request_to_templates(r_key, spec_keys):
392
+ hits = traffic_matched_code.get(r_key, 0)
393
+ if hits == 0:
394
+ dormant_code.append({
395
+ "method": r_info["method"],
396
+ "path": r_info["path"],
397
+ "normalized": r_key,
398
+ "location": r_info.get("location", ""),
399
+ })
400
+
401
+ # Sort outputs
402
+ shadow_apis.sort(key=lambda x: (-x.get("traffic_hits", 0), x["normalized"]))
403
+ zombie_apis.sort(key=lambda x: (-x.get("traffic_hits", 0), x["normalized"]))
404
+ missing_code.sort(key=lambda x: x["normalized"])
405
+ dormant_code.sort(key=lambda x: x["normalized"])
406
+
407
+ return {
408
+ "version": 1,
409
+ "generated_at": gen_time,
410
+ "sources": {
411
+ "routes_file": routes_file_name,
412
+ "openapi_file": openapi_file_name,
413
+ "traffic_log_file": traffic_file_name,
414
+ },
415
+ "summary": {
416
+ "total_code_routes": len(code_routes),
417
+ "total_spec_endpoints": len(spec_endpoints),
418
+ "total_traffic_endpoints": len(traffic_data),
419
+ "shadow_count": len(shadow_apis),
420
+ "zombie_count": len(zombie_apis),
421
+ "missing_code_count": len(missing_code),
422
+ "dormant_count": len(dormant_code),
423
+ },
424
+ "shadow_apis": shadow_apis,
425
+ "zombie_apis": zombie_apis,
426
+ "documented_missing_code": missing_code,
427
+ "dormant_code": dormant_code,
428
+ }
429
+
430
+
431
+ def render_inventory_md(diff_doc: Dict[str, Any], lang: str = "en") -> List[str]:
432
+ """Generate Markdown report section for API inventory diff (EN / JA)."""
433
+ s = diff_doc.get("summary", {})
434
+ shadows = diff_doc.get("shadow_apis", [])
435
+ zombies = diff_doc.get("zombie_apis", [])
436
+ missing = diff_doc.get("documented_missing_code", [])
437
+
438
+ if lang == "ja":
439
+ lines = [
440
+ "## APIインベントリ照合・Shadow / Zombie API検出 (API Inventory Diff)",
441
+ "",
442
+ "ソースコード (`routes.json`)、公開OpenAPI仕様、およびゲートウェイの通信ログを3者照合し、仕様外エンドポイントや放置APIを特定しました。",
443
+ "",
444
+ "| 項目 | 件数 | 評価 |",
445
+ "|---|---|---|",
446
+ f"| 実装コードAPI数 | {s.get('total_code_routes', 0)} | コードベース内の有効ルート |",
447
+ f"| OpenAPI仕様API数 | {s.get('total_spec_endpoints', 0)} | 公開ドキュメント定義 |",
448
+ f"| 通信確認エンドポイント数 | {s.get('total_traffic_endpoints', 0)} | ゲートウェイ実測トラフィック |",
449
+ f"| **Shadow API (仕様未記載)** | **{s.get('shadow_count', 0)}** | **要確認(認可不備・情報漏洩リスク)** |",
450
+ f"| **Zombie API (非推奨だが稼働中)** | **{s.get('zombie_count', 0)}** | **要廃止(ゾンビAPIトラフィック検知)** |",
451
+ f"| 仕様記載・未実装 (Ghost) | {s.get('missing_code_count', 0)} | 実装欠落または仕様陳腐化 |",
452
+ "",
453
+ ]
454
+ if shadows:
455
+ lines += [
456
+ "### Shadow APIs(ドキュメント未記載・隠しエンドポイント)",
457
+ "",
458
+ "| メソッド & パス | 発見元 | 該当箇所 / 状況 | トラフィック実績 |",
459
+ "|---|---|---|---|",
460
+ ]
461
+ for sh in shadows[:15]:
462
+ lines.append(f"| `{sh['normalized']}` | {sh['discovered_in']} | `{sh.get('location', '-')}` | {sh.get('traffic_hits', 0)} hits |")
463
+ lines.append("")
464
+
465
+ if zombies:
466
+ lines += [
467
+ "### Zombie APIs(非推奨指定にも関わらずトラフィックを受信中のAPI)",
468
+ "",
469
+ "| メソッド & パス | 該当コード箇所 | トラフィック実績 | 最終確認日時 |",
470
+ "|---|---|---|---|",
471
+ ]
472
+ for z in zombies[:10]:
473
+ lines.append(f"| `{z['normalized']}` | `{z.get('location', '-')}` | **{z.get('traffic_hits', 0)} hits** | {z.get('last_seen', '-')} |")
474
+ lines.append("")
475
+ else:
476
+ lines = [
477
+ "## API Inventory Reconciliation & Shadow/Zombie API Detection",
478
+ "",
479
+ "Reconciles three independent sources: implementation routes from code, published OpenAPI specification, and live API gateway traffic.",
480
+ "",
481
+ "| Metric | Count | Description |",
482
+ "|---|---|---|",
483
+ f"| Code Routes | {s.get('total_code_routes', 0)} | Endpoints discovered in source code |",
484
+ f"| OpenAPI Endpoints | {s.get('total_spec_endpoints', 0)} | Formally documented endpoints |",
485
+ f"| Traffic Endpoints | {s.get('total_traffic_endpoints', 0)} | Active endpoints observed in gateway traffic |",
486
+ f"| **Shadow APIs (Undocumented)** | **{s.get('shadow_count', 0)}** | **Action required: undocumented attack surface** |",
487
+ f"| **Zombie APIs (Deprecated Active)** | **{s.get('zombie_count', 0)}** | **Action required: deprecated route still receiving traffic** |",
488
+ f"| Documented but Missing in Code | {s.get('missing_code_count', 0)} | Outdated spec or dead documentation |",
489
+ "",
490
+ ]
491
+ if shadows:
492
+ lines += [
493
+ "### Shadow APIs (Undocumented Endpoints)",
494
+ "",
495
+ "| Method & Path | Discovered In | Code Location | Gateway Hits |",
496
+ "|---|---|---|---|",
497
+ ]
498
+ for sh in shadows[:15]:
499
+ lines.append(f"| `{sh['normalized']}` | {sh['discovered_in']} | `{sh.get('location', '-')}` | {sh.get('traffic_hits', 0)} hits |")
500
+ lines.append("")
501
+
502
+ if zombies:
503
+ lines += [
504
+ "### Zombie APIs (Deprecated Endpoints Receiving Traffic)",
505
+ "",
506
+ "| Method & Path | Code Location | Gateway Hits | Last Seen |",
507
+ "|---|---|---|---|",
508
+ ]
509
+ for z in zombies[:10]:
510
+ lines.append(f"| `{z['normalized']}` | `{z.get('location', '-')}` | **{z.get('traffic_hits', 0)} hits** | {z.get('last_seen', '-')} |")
511
+ lines.append("")
512
+
513
+ return lines
514
+
515
+
516
+ def render_inventory_html(diff_doc: Dict[str, Any]) -> str:
517
+ """Generate self-contained bilingual HTML card for API inventory diff."""
518
+ s = diff_doc.get("summary", {})
519
+ shadows = diff_doc.get("shadow_apis", [])
520
+ zombies = diff_doc.get("zombie_apis", [])
521
+
522
+ shadow_rows = "".join(
523
+ f"<tr><td><code>{html.escape(sh['normalized'])}</code></td>"
524
+ f"<td><span class=\"badge badge-high\">{html.escape(sh['discovered_in'])}</span></td>"
525
+ f"<td><code>{html.escape(sh.get('location', '-'))}</code></td>"
526
+ f"<td><strong>{sh.get('traffic_hits', 0)}</strong></td></tr>"
527
+ for sh in shadows[:15]
528
+ ) or "<tr><td colspan=\"4\">No shadow APIs detected.</td></tr>"
529
+
530
+ zombie_rows = "".join(
531
+ f"<tr><td><code>{html.escape(z['normalized'])}</code></td>"
532
+ f"<td><code>{html.escape(z.get('location', '-'))}</code></td>"
533
+ f"<td><span class=\"badge badge-critical\">{z.get('traffic_hits', 0)} hits</span></td>"
534
+ f"<td>{html.escape(z.get('last_seen', '-'))}</td></tr>"
535
+ for z in zombies[:10]
536
+ ) or "<tr><td colspan=\"4\">No zombie APIs detected.</td></tr>"
537
+
538
+ return (
539
+ '<section class="card" aria-label="API Inventory &amp; Shadow/Zombie Detection">'
540
+ '<h2 class="card-title" data-en="API Inventory &amp; Shadow / Zombie API Detection" '
541
+ 'data-ja="APIインベントリ照合・Shadow / Zombie API検出 (Inventory Diff)">API Inventory &amp; Shadow / Zombie API Detection</h2>'
542
+ '<div class="forecast-stats-grid">'
543
+ f'<div class="forecast-stat-box"><div class="stat-lbl" data-en="Code Routes" data-ja="実装コードAPI">Code Routes</div><div class="stat-val">{s.get("total_code_routes", 0)}</div></div>'
544
+ f'<div class="forecast-stat-box"><div class="stat-lbl" data-en="OpenAPI Spec" data-ja="OpenAPI仕様">OpenAPI Spec</div><div class="stat-val">{s.get("total_spec_endpoints", 0)}</div></div>'
545
+ f'<div class="forecast-stat-box"><div class="stat-lbl" data-en="Shadow APIs" data-ja="Shadow API (未記載)">Shadow APIs</div><div class="stat-val" style="color:var(--high);">{s.get("shadow_count", 0)}</div></div>'
546
+ f'<div class="forecast-stat-box"><div class="stat-lbl" data-en="Zombie APIs" data-ja="Zombie API (非推奨稼働)">Zombie APIs</div><div class="stat-val" style="color:var(--crit);">{s.get("zombie_count", 0)}</div></div>'
547
+ '</div>'
548
+ '<h3 data-en="Shadow APIs (Undocumented Endpoints)" data-ja="Shadow APIs(ドキュメント未記載エンドポイント)">Shadow APIs</h3>'
549
+ '<table><thead><tr>'
550
+ '<th data-en="Endpoint" data-ja="エンドポイント">Endpoint</th>'
551
+ '<th data-en="Discovered In" data-ja="発見元">Discovered In</th>'
552
+ '<th data-en="Location" data-ja="該当箇所">Location</th>'
553
+ '<th data-en="Traffic Hits" data-ja="トラフィック数">Traffic Hits</th>'
554
+ f'</tr></thead><tbody>{shadow_rows}</tbody></table>'
555
+ + (
556
+ '<h3 data-en="Zombie APIs (Deprecated but Active Traffic)" data-ja="Zombie APIs(非推奨だがトラフィックを受信中)">Zombie APIs</h3>'
557
+ '<table><thead><tr>'
558
+ '<th data-en="Endpoint" data-ja="エンドポイント">Endpoint</th>'
559
+ '<th data-en="Location" data-ja="該当箇所">Location</th>'
560
+ '<th data-en="Traffic Hits" data-ja="トラフィック数">Traffic Hits</th>'
561
+ '<th data-en="Last Seen" data-ja="最終確認">Last Seen</th>'
562
+ f'</tr></thead><tbody>{zombie_rows}</tbody></table>'
563
+ if zombies else ''
564
+ ) +
565
+ '</section>'
566
+ )
@@ -2,6 +2,16 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from operate.simulate.engine import (
6
+ DeclarativeScenario,
7
+ generate_execution_evidence,
8
+ )
9
+ from operate.simulate.loader import (
10
+ discover_scenarios,
11
+ load_scenario_file,
12
+ load_validator,
13
+ validate_scenario,
14
+ )
5
15
  from operate.simulate.scenarios import (
6
16
  BaseScenario,
7
17
  ExecutiveReportGenerator,
@@ -18,4 +28,10 @@ __all__ = [
18
28
  "MetabaseZeroDayScenario",
19
29
  "IDCFrontierRansomwareScenario",
20
30
  "ExecutiveReportGenerator",
31
+ "DeclarativeScenario",
32
+ "generate_execution_evidence",
33
+ "discover_scenarios",
34
+ "load_scenario_file",
35
+ "load_validator",
36
+ "validate_scenario",
21
37
  ]