@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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/hoanghainh1188/komagu/main/contracts/schemas/bas-scenario.schema.json",
|
|
3
|
+
"id": "times-car-breach",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"title": "Times Car Mass BOLA / IDOR Scraping Simulation",
|
|
6
|
+
"status": "approved",
|
|
7
|
+
"approved_by": "sec-team@komagu.io",
|
|
8
|
+
"approved_at": "2026-03-15T00:00:00Z",
|
|
9
|
+
"incident": {
|
|
10
|
+
"name": "Times Car (Times Mobility) 2024 Mass Data Exposure",
|
|
11
|
+
"date": "2024",
|
|
12
|
+
"summary": "Simulates automated cross-user account/license harvesting by iterating through consecutive resource IDs with an unauthorized peer credential.",
|
|
13
|
+
"references": [
|
|
14
|
+
"https://scan.netsecurity.ne.jp/article/2024/04/17/50873.html",
|
|
15
|
+
"Times Car 2024 breach: 6.6M customer accounts and ID images exposed."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"mappings": {
|
|
19
|
+
"cwe": ["CWE-639", "CWE-284"],
|
|
20
|
+
"owasp_api": ["API1:2023"],
|
|
21
|
+
"mitre_attack": ["T1059", "T1552"]
|
|
22
|
+
},
|
|
23
|
+
"parameters": {
|
|
24
|
+
"endpoint_pattern": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"default": "/api/legacy-orders/{id}",
|
|
27
|
+
"description": "Target endpoint path containing {id} parameter."
|
|
28
|
+
},
|
|
29
|
+
"attacker_token": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"default": "mock-attacker-bob-token",
|
|
32
|
+
"description": "Bearer token used to test cross-tenant unauthorized access."
|
|
33
|
+
},
|
|
34
|
+
"sample_ids": {
|
|
35
|
+
"type": "list",
|
|
36
|
+
"default": ["1", "2", "3", "101", "102"],
|
|
37
|
+
"description": "List of sample resource IDs to probe."
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"steps": [
|
|
41
|
+
{
|
|
42
|
+
"name": "Iterate Cross-User Endpoints",
|
|
43
|
+
"description": "Requests resource IDs using attacker credential, expecting 403 or 404.",
|
|
44
|
+
"type": "http_request",
|
|
45
|
+
"destructive": false,
|
|
46
|
+
"repeat": {
|
|
47
|
+
"param_name": "sample_ids",
|
|
48
|
+
"as_var": "id"
|
|
49
|
+
},
|
|
50
|
+
"request": {
|
|
51
|
+
"method": "GET",
|
|
52
|
+
"path": "{endpoint_pattern}",
|
|
53
|
+
"headers": {
|
|
54
|
+
"Authorization": "Bearer {attacker_token}",
|
|
55
|
+
"User-Agent": "Komagu-BAS-TimesCarSimulator/1.0",
|
|
56
|
+
"Accept": "application/json"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"expect_secure": {
|
|
60
|
+
"status_codes": [403, 404]
|
|
61
|
+
},
|
|
62
|
+
"expect_vulnerable": {
|
|
63
|
+
"status_codes": [200, 201, 204]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"scoring": {
|
|
68
|
+
"strategy": "ratio",
|
|
69
|
+
"recommendations": {
|
|
70
|
+
"remediation": "Enforce Komagu Boilerplate 1 (Object-Level Authorization) with declarative @RequireOwnership decorators or integrate Kong perimeter WAF with strict 404 invariants.",
|
|
71
|
+
"passed": "Endpoint securely enforces tenant isolation (404 Not Found invariant maintained)."
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -107,6 +107,24 @@ class TimesCarBreachScenario(BaseScenario):
|
|
|
107
107
|
self.sample_ids = sample_ids or ["1", "2", "3", "101", "102"]
|
|
108
108
|
|
|
109
109
|
def run(self) -> ScenarioResult:
|
|
110
|
+
from operate.simulate.engine import DeclarativeScenario
|
|
111
|
+
from operate.simulate.loader import BUILTIN_SCENARIOS_DIR, load_scenario_file
|
|
112
|
+
|
|
113
|
+
scenario_path = BUILTIN_SCENARIOS_DIR / "times-car-breach.json"
|
|
114
|
+
if scenario_path.is_file():
|
|
115
|
+
data = load_scenario_file(scenario_path)
|
|
116
|
+
runner = DeclarativeScenario(
|
|
117
|
+
data,
|
|
118
|
+
target=self.target,
|
|
119
|
+
param_overrides={
|
|
120
|
+
"endpoint_pattern": self.endpoint_pattern,
|
|
121
|
+
"attacker_token": self.attacker_token,
|
|
122
|
+
"sample_ids": self.sample_ids,
|
|
123
|
+
},
|
|
124
|
+
timeout=self.timeout,
|
|
125
|
+
)
|
|
126
|
+
return runner.run()
|
|
127
|
+
|
|
110
128
|
total = len(self.sample_ids)
|
|
111
129
|
blocked = 0
|
|
112
130
|
leaked = 0
|
|
@@ -212,6 +230,24 @@ class MetabaseZeroDayScenario(BaseScenario):
|
|
|
212
230
|
]
|
|
213
231
|
|
|
214
232
|
def run(self) -> ScenarioResult:
|
|
233
|
+
from operate.simulate.engine import DeclarativeScenario
|
|
234
|
+
from operate.simulate.loader import BUILTIN_SCENARIOS_DIR, load_scenario_file
|
|
235
|
+
|
|
236
|
+
scenario_path = BUILTIN_SCENARIOS_DIR / "metabase-cve-2026-72898.json"
|
|
237
|
+
if scenario_path.is_file():
|
|
238
|
+
data = load_scenario_file(scenario_path)
|
|
239
|
+
runner = DeclarativeScenario(
|
|
240
|
+
data,
|
|
241
|
+
target=self.target,
|
|
242
|
+
param_overrides={
|
|
243
|
+
"query_path": self.query_path,
|
|
244
|
+
"target_param": self.target_param,
|
|
245
|
+
"payloads": self.payloads,
|
|
246
|
+
},
|
|
247
|
+
timeout=self.timeout,
|
|
248
|
+
)
|
|
249
|
+
return runner.run()
|
|
250
|
+
|
|
215
251
|
total = len(self.payloads)
|
|
216
252
|
blocked = 0
|
|
217
253
|
leaked = 0
|
|
@@ -315,6 +351,22 @@ class IDCFrontierRansomwareScenario(BaseScenario):
|
|
|
315
351
|
self.target_dir = Path(target_dir).resolve()
|
|
316
352
|
|
|
317
353
|
def run(self) -> ScenarioResult:
|
|
354
|
+
from operate.simulate.engine import DeclarativeScenario
|
|
355
|
+
from operate.simulate.loader import BUILTIN_SCENARIOS_DIR, load_scenario_file
|
|
356
|
+
|
|
357
|
+
scenario_path = BUILTIN_SCENARIOS_DIR / "idc-frontier-ransomware.json"
|
|
358
|
+
if scenario_path.is_file():
|
|
359
|
+
data = load_scenario_file(scenario_path)
|
|
360
|
+
runner = DeclarativeScenario(
|
|
361
|
+
data,
|
|
362
|
+
target=str(self.target_dir),
|
|
363
|
+
param_overrides={
|
|
364
|
+
"target_dir": str(self.target_dir),
|
|
365
|
+
},
|
|
366
|
+
timeout=self.timeout,
|
|
367
|
+
)
|
|
368
|
+
return runner.run()
|
|
369
|
+
|
|
318
370
|
total = 3
|
|
319
371
|
blocked = 0
|
|
320
372
|
leaked = 0
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for API Inventory Reconciliation & Shadow/Zombie API Detection (Issue #288).
|
|
3
|
+
|
|
4
|
+
Verifies:
|
|
5
|
+
1. api-inventory-diff.schema.json schema conformity.
|
|
6
|
+
2. Route path parameter normalization (Express, Flask, OpenAPI, integers, UUIDs).
|
|
7
|
+
3. 3-way reconciliation covering all 4 discrepancy cases:
|
|
8
|
+
- Shadow API: in code but not in spec.
|
|
9
|
+
- Shadow API: in live traffic but not in spec.
|
|
10
|
+
- Zombie API: marked deprecated in OpenAPI spec but still receiving traffic.
|
|
11
|
+
- Documented Missing in Code: in spec but not implemented.
|
|
12
|
+
- Dormant Code: implemented and documented, but zero traffic.
|
|
13
|
+
4. Kong HTTP-Log (JSON lines) and NGINX Combined access log parsing.
|
|
14
|
+
5. CLI execution and gating flags (--fail-on-shadow, --fail-on-zombie).
|
|
15
|
+
6. Markdown and HTML report generation.
|
|
16
|
+
|
|
17
|
+
Standard-library-only (Python 3.8+).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import tempfile
|
|
24
|
+
import unittest
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from assess.lsa.scripts.merge_findings import load_validator
|
|
28
|
+
from operate.cli.commands.inventory import run_inventory
|
|
29
|
+
from operate.inventory.api_inventory import (
|
|
30
|
+
diff_api_inventory,
|
|
31
|
+
load_code_routes,
|
|
32
|
+
load_openapi_spec,
|
|
33
|
+
load_traffic_log,
|
|
34
|
+
normalize_path,
|
|
35
|
+
normalize_route_key,
|
|
36
|
+
render_inventory_html,
|
|
37
|
+
render_inventory_md,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestApiInventory(unittest.TestCase):
|
|
42
|
+
def setUp(self) -> None:
|
|
43
|
+
self.validate_schema = load_validator("api-inventory-diff.schema.json")
|
|
44
|
+
|
|
45
|
+
def test_path_normalization(self) -> None:
|
|
46
|
+
"""Verify unified path normalization across diverse frameworks."""
|
|
47
|
+
self.assertEqual(normalize_path("/api/orders/{id}"), "/api/orders/{}")
|
|
48
|
+
self.assertEqual(normalize_path("/users/<int:userId>"), "/users/{}")
|
|
49
|
+
self.assertEqual(normalize_path("/accounts/:accId/profile"), "/accounts/{}/profile")
|
|
50
|
+
self.assertEqual(normalize_path("/api/orders/42"), "/api/orders/{}")
|
|
51
|
+
self.assertEqual(
|
|
52
|
+
normalize_path("/items/12345678-abcd-1234-abcd-123456789abc"),
|
|
53
|
+
"/items/{}"
|
|
54
|
+
)
|
|
55
|
+
self.assertEqual(normalize_path("/trailing/slash/"), "/trailing/slash")
|
|
56
|
+
self.assertEqual(normalize_route_key("get", "/api/v1/orders/{id}?limit=10"), "GET /api/v1/orders/{}")
|
|
57
|
+
|
|
58
|
+
def test_four_way_reconciliation_cases(self) -> None:
|
|
59
|
+
"""Verify detection of Shadow, Zombie, Dead Spec, and Dormant Code."""
|
|
60
|
+
# 1. Code routes
|
|
61
|
+
code_routes = [
|
|
62
|
+
{"method": "GET", "path": "/api/users/{id}", "location": "Controllers/Users.cs:10"}, # Normal
|
|
63
|
+
{"method": "POST", "path": "/api/internal/debug-dump", "location": "Controllers/Debug.cs:42"}, # Shadow (in code, not spec)
|
|
64
|
+
{"method": "GET", "path": "/api/legacy-v1/export", "location": "Controllers/Legacy.cs:15"}, # Zombie (deprecated in spec, receiving traffic)
|
|
65
|
+
{"method": "GET", "path": "/api/dormant-feature", "location": "Controllers/Dormant.cs:88"}, # Dormant (in code & spec, 0 traffic)
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# 2. OpenAPI Spec
|
|
69
|
+
spec_endpoints = [
|
|
70
|
+
{"method": "GET", "path": "/api/users/{id}", "normalized": "GET /api/users/{}", "deprecated": False},
|
|
71
|
+
{"method": "GET", "path": "/api/legacy-v1/export", "normalized": "GET /api/legacy-v1/export", "deprecated": True}, # Deprecated!
|
|
72
|
+
{"method": "GET", "path": "/api/dormant-feature", "normalized": "GET /api/dormant-feature", "deprecated": False},
|
|
73
|
+
{"method": "DELETE", "path": "/api/planned-ghost", "normalized": "DELETE /api/planned-ghost", "deprecated": False, "summary": "Ghost spec"}, # Missing in code
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
# 3. Traffic Data
|
|
77
|
+
traffic_data = {
|
|
78
|
+
"GET /api/users/{}": {"method": "GET", "path": "/api/users/123", "normalized": "GET /api/users/{}", "traffic_hits": 50},
|
|
79
|
+
"GET /api/legacy-v1/export": {"method": "GET", "path": "/api/legacy-v1/export", "normalized": "GET /api/legacy-v1/export", "traffic_hits": 12},
|
|
80
|
+
"POST /api/untracked-admin": {"method": "POST", "path": "/api/untracked-admin", "normalized": "POST /api/untracked-admin", "traffic_hits": 5}, # Shadow in traffic only
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
diff = diff_api_inventory(code_routes, spec_endpoints, traffic_data)
|
|
84
|
+
errors = self.validate_schema(diff)
|
|
85
|
+
self.assertEqual(errors, [], f"Schema validation errors: {errors}")
|
|
86
|
+
|
|
87
|
+
s = diff["summary"]
|
|
88
|
+
self.assertEqual(s["shadow_count"], 2) # debug-dump (code_only) + untracked-admin (traffic_only)
|
|
89
|
+
self.assertEqual(s["zombie_count"], 1) # legacy-v1/export
|
|
90
|
+
self.assertEqual(s["missing_code_count"], 1) # planned-ghost
|
|
91
|
+
self.assertEqual(s["dormant_count"], 1) # dormant-feature
|
|
92
|
+
|
|
93
|
+
# Verify Shadow details
|
|
94
|
+
shadow_keys = {sh["normalized"]: sh["discovered_in"] for sh in diff["shadow_apis"]}
|
|
95
|
+
self.assertEqual(shadow_keys.get("POST /api/internal/debug-dump"), "code_only")
|
|
96
|
+
self.assertEqual(shadow_keys.get("POST /api/untracked-admin"), "traffic_only")
|
|
97
|
+
|
|
98
|
+
# Verify Zombie details
|
|
99
|
+
zombie_keys = [z["normalized"] for z in diff["zombie_apis"]]
|
|
100
|
+
self.assertIn("GET /api/legacy-v1/export", zombie_keys)
|
|
101
|
+
self.assertEqual(diff["zombie_apis"][0]["traffic_hits"], 12)
|
|
102
|
+
|
|
103
|
+
# Verify Missing code details
|
|
104
|
+
missing_keys = [m["normalized"] for m in diff["documented_missing_code"]]
|
|
105
|
+
self.assertIn("DELETE /api/planned-ghost", missing_keys)
|
|
106
|
+
|
|
107
|
+
# Verify Dormant code details
|
|
108
|
+
dormant_keys = [d["normalized"] for d in diff["dormant_code"]]
|
|
109
|
+
self.assertIn("GET /api/dormant-feature", dormant_keys)
|
|
110
|
+
|
|
111
|
+
def test_log_parsers(self) -> None:
|
|
112
|
+
"""Verify parsing Kong JSON lines and Combined Nginx log formats."""
|
|
113
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
114
|
+
tmp = Path(tmpdir)
|
|
115
|
+
|
|
116
|
+
# 1. Kong HTTP-Log NDJSON
|
|
117
|
+
kong_log = tmp / "kong.log"
|
|
118
|
+
kong_log.write_text(
|
|
119
|
+
'{"request": {"method": "GET", "uri": "/api/orders/101"}, "started_at": 1730000000}\n'
|
|
120
|
+
'{"request": {"method": "GET", "uri": "/api/orders/102"}, "started_at": 1730000001}\n'
|
|
121
|
+
'{"request": {"method": "POST", "uri": "/api/checkout"}, "started_at": 1730000002}\n'
|
|
122
|
+
)
|
|
123
|
+
parsed_kong = load_traffic_log(kong_log)
|
|
124
|
+
self.assertEqual(parsed_kong["GET /api/orders/{}"]["traffic_hits"], 2)
|
|
125
|
+
self.assertEqual(parsed_kong["POST /api/checkout"]["traffic_hits"], 1)
|
|
126
|
+
|
|
127
|
+
# 2. Combined NGINX log
|
|
128
|
+
nginx_log = tmp / "nginx.log"
|
|
129
|
+
nginx_log.write_text(
|
|
130
|
+
'192.168.1.1 - - [01/Nov/2026:12:00:00 +0000] "GET /api/products/42 HTTP/1.1" 200 450\n'
|
|
131
|
+
'192.168.1.2 - - [01/Nov/2026:12:00:01 +0000] "DELETE /api/cart HTTP/1.1" 204 0\n'
|
|
132
|
+
)
|
|
133
|
+
parsed_nginx = load_traffic_log(nginx_log)
|
|
134
|
+
self.assertEqual(parsed_nginx["GET /api/products/{}"]["traffic_hits"], 1)
|
|
135
|
+
self.assertEqual(parsed_nginx["DELETE /api/cart"]["traffic_hits"], 1)
|
|
136
|
+
|
|
137
|
+
def test_cli_execution_and_gating(self) -> None:
|
|
138
|
+
"""Verify komagu inventory command execution and gate checks."""
|
|
139
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
140
|
+
tmp = Path(tmpdir)
|
|
141
|
+
routes_file = tmp / "routes.json"
|
|
142
|
+
routes_file.write_text(json.dumps({
|
|
143
|
+
"routes": [
|
|
144
|
+
{"method": "GET", "path": "/api/users/{id}", "location": "app.py:10"},
|
|
145
|
+
{"method": "POST", "path": "/api/shadow-login", "location": "auth.py:50"},
|
|
146
|
+
]
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
spec_file = tmp / "openapi.json"
|
|
150
|
+
spec_file.write_text(json.dumps({
|
|
151
|
+
"openapi": "3.0.0",
|
|
152
|
+
"paths": {
|
|
153
|
+
"/api/users/{id}": {"get": {"summary": "Get user"}},
|
|
154
|
+
"/api/old-v0/auth": {"post": {"deprecated": True, "summary": "Old auth"}},
|
|
155
|
+
}
|
|
156
|
+
}))
|
|
157
|
+
|
|
158
|
+
traffic_file = tmp / "traffic.log"
|
|
159
|
+
traffic_file.write_text('{"request": {"method": "POST", "uri": "/api/old-v0/auth"}}\n')
|
|
160
|
+
|
|
161
|
+
out_file = tmp / "diff.json"
|
|
162
|
+
|
|
163
|
+
args = argparse.Namespace(
|
|
164
|
+
routes=routes_file,
|
|
165
|
+
openapi=spec_file,
|
|
166
|
+
traffic_log=traffic_file,
|
|
167
|
+
out=out_file,
|
|
168
|
+
fail_on_shadow=False,
|
|
169
|
+
fail_on_zombie=False,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Normal run passes with exit code 0
|
|
173
|
+
rc = run_inventory(args)
|
|
174
|
+
self.assertEqual(rc, 0)
|
|
175
|
+
self.assertTrue(out_file.is_file())
|
|
176
|
+
|
|
177
|
+
diff_data = json.loads(out_file.read_text())
|
|
178
|
+
self.assertEqual(diff_data["summary"]["shadow_count"], 1)
|
|
179
|
+
self.assertEqual(diff_data["summary"]["zombie_count"], 1)
|
|
180
|
+
|
|
181
|
+
# Fail on shadow gate trips exit code 1
|
|
182
|
+
args_shadow_gate = argparse.Namespace(
|
|
183
|
+
routes=routes_file,
|
|
184
|
+
openapi=spec_file,
|
|
185
|
+
traffic_log=traffic_file,
|
|
186
|
+
out=out_file,
|
|
187
|
+
fail_on_shadow=True,
|
|
188
|
+
fail_on_zombie=False,
|
|
189
|
+
)
|
|
190
|
+
self.assertEqual(run_inventory(args_shadow_gate), 1)
|
|
191
|
+
|
|
192
|
+
# Fail on zombie gate trips exit code 1
|
|
193
|
+
args_zombie_gate = argparse.Namespace(
|
|
194
|
+
routes=routes_file,
|
|
195
|
+
openapi=spec_file,
|
|
196
|
+
traffic_log=traffic_file,
|
|
197
|
+
out=out_file,
|
|
198
|
+
fail_on_shadow=False,
|
|
199
|
+
fail_on_zombie=True,
|
|
200
|
+
)
|
|
201
|
+
self.assertEqual(run_inventory(args_zombie_gate), 1)
|
|
202
|
+
|
|
203
|
+
def test_report_rendering(self) -> None:
|
|
204
|
+
"""Verify Markdown and HTML report generation for API inventory diff."""
|
|
205
|
+
mock_diff = {
|
|
206
|
+
"summary": {
|
|
207
|
+
"total_code_routes": 10,
|
|
208
|
+
"total_spec_endpoints": 8,
|
|
209
|
+
"total_traffic_endpoints": 9,
|
|
210
|
+
"shadow_count": 2,
|
|
211
|
+
"zombie_count": 1,
|
|
212
|
+
"missing_code_count": 0,
|
|
213
|
+
"dormant_count": 1,
|
|
214
|
+
},
|
|
215
|
+
"shadow_apis": [
|
|
216
|
+
{"method": "POST", "path": "/api/secret", "normalized": "POST /api/secret", "discovered_in": "code_only", "traffic_hits": 0, "location": "secret.py:1"}
|
|
217
|
+
],
|
|
218
|
+
"zombie_apis": [
|
|
219
|
+
{"method": "GET", "path": "/api/v1/old", "normalized": "GET /api/v1/old", "location": "old.py:5", "traffic_hits": 15, "last_seen": "2026-11-01"}
|
|
220
|
+
],
|
|
221
|
+
"documented_missing_code": [],
|
|
222
|
+
"dormant_code": [],
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
md_en = "\n".join(render_inventory_md(mock_diff, "en"))
|
|
226
|
+
self.assertIn("API Inventory Reconciliation", md_en)
|
|
227
|
+
self.assertIn("POST /api/secret", md_en)
|
|
228
|
+
|
|
229
|
+
md_ja = "\n".join(render_inventory_md(mock_diff, "ja"))
|
|
230
|
+
self.assertIn("APIインベントリ照合", md_ja)
|
|
231
|
+
self.assertIn("Shadow API", md_ja)
|
|
232
|
+
|
|
233
|
+
html_card = render_inventory_html(mock_diff)
|
|
234
|
+
self.assertIn("API Inventory & Shadow / Zombie API Detection", html_card)
|
|
235
|
+
self.assertIn("POST /api/secret", html_card)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
unittest.main()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Comprehensive unit tests for Declarative Incident Scenarios (#317).
|
|
3
|
+
|
|
4
|
+
Validates:
|
|
5
|
+
1. bas-scenario.schema.json schema validation on built-in and custom scenarios.
|
|
6
|
+
2. Discovery of scenarios from files, directories, and built-ins.
|
|
7
|
+
3. DeclarativeScenario execution of HTTP and storage steps.
|
|
8
|
+
4. Governance safety gates: draft scenarios blocked without --allow-draft.
|
|
9
|
+
5. Destructive step safety gates: destructive steps blocked without --allow-destructive.
|
|
10
|
+
6. Parameter substitutions and CLI --set overrides.
|
|
11
|
+
7. METI SCS audit evidence generation (#271).
|
|
12
|
+
8. CLI commands: --list, --validate, --scenario-file, --evidence.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
import unittest
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
25
|
+
if str(PROJECT_ROOT) not in sys.path:
|
|
26
|
+
sys.path.insert(0, str(PROJECT_ROOT))
|
|
27
|
+
|
|
28
|
+
from operate.cli.main import main as cli_main
|
|
29
|
+
from operate.cross_user_tester.mock_test_server import start_mock_server
|
|
30
|
+
from operate.simulate.engine import (
|
|
31
|
+
DeclarativeScenario,
|
|
32
|
+
generate_execution_evidence,
|
|
33
|
+
)
|
|
34
|
+
from operate.simulate.loader import (
|
|
35
|
+
BUILTIN_SCENARIOS_DIR,
|
|
36
|
+
discover_scenarios,
|
|
37
|
+
load_scenario_file,
|
|
38
|
+
load_validator,
|
|
39
|
+
validate_scenario,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TestDeclarativeScenarios(unittest.TestCase):
|
|
44
|
+
@classmethod
|
|
45
|
+
def setUpClass(cls) -> None:
|
|
46
|
+
cls.server, cls.thread, cls.base_url = start_mock_server(port=0)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def tearDownClass(cls) -> None:
|
|
50
|
+
cls.server.server_close()
|
|
51
|
+
|
|
52
|
+
def test_schema_validates_builtin_scenarios(self) -> None:
|
|
53
|
+
"""All 3 built-in scenario files must strictly conform to bas-scenario.schema.json."""
|
|
54
|
+
validator = load_validator("bas-scenario.schema.json")
|
|
55
|
+
for sc_name in ["times-car-breach.json", "metabase-cve-2026-72898.json", "idc-frontier-ransomware.json"]:
|
|
56
|
+
path = BUILTIN_SCENARIOS_DIR / sc_name
|
|
57
|
+
self.assertTrue(path.is_file(), f"Missing built-in scenario: {sc_name}")
|
|
58
|
+
data = load_scenario_file(path)
|
|
59
|
+
errs = validator(data)
|
|
60
|
+
self.assertEqual(errs, [], f"Validation errors for {sc_name}: {errs}")
|
|
61
|
+
|
|
62
|
+
def test_schema_validation_error_on_invalid_scenario(self) -> None:
|
|
63
|
+
"""Validator must report missing required properties."""
|
|
64
|
+
invalid_data = {
|
|
65
|
+
"id": "invalid-scenario",
|
|
66
|
+
"title": "Invalid",
|
|
67
|
+
# missing status, version, incident, mappings, etc.
|
|
68
|
+
}
|
|
69
|
+
errs = validate_scenario(invalid_data)
|
|
70
|
+
self.assertTrue(len(errs) > 0)
|
|
71
|
+
self.assertTrue(any("required property missing" in e or "is a required property" in e for e in errs))
|
|
72
|
+
|
|
73
|
+
def test_discovery_scenarios(self) -> None:
|
|
74
|
+
"""discover_scenarios must locate all built-in scenarios and custom ones."""
|
|
75
|
+
scenarios = discover_scenarios(include_builtins=True)
|
|
76
|
+
self.assertIn("times-car-breach", scenarios)
|
|
77
|
+
self.assertIn("metabase-cve-2026-72898", scenarios)
|
|
78
|
+
self.assertIn("idc-frontier-ransomware", scenarios)
|
|
79
|
+
|
|
80
|
+
# Test custom dir discovery
|
|
81
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
82
|
+
custom_path = Path(tmp_dir) / "custom-sc.json"
|
|
83
|
+
base_sc = dict(scenarios["times-car-breach"])
|
|
84
|
+
base_sc["id"] = "custom-test-scenario"
|
|
85
|
+
custom_path.write_text(json.dumps(base_sc), encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
discovered = discover_scenarios(scenario_dirs=[tmp_dir], include_builtins=False)
|
|
88
|
+
self.assertIn("custom-test-scenario", discovered)
|
|
89
|
+
|
|
90
|
+
def test_draft_scenario_blocked_without_override(self) -> None:
|
|
91
|
+
"""Scenario with status != 'approved' must fail execution unless allow_draft is set."""
|
|
92
|
+
data = dict(load_scenario_file(BUILTIN_SCENARIOS_DIR / "times-car-breach.json"))
|
|
93
|
+
data["status"] = "draft"
|
|
94
|
+
runner = DeclarativeScenario(data, self.base_url, allow_draft=False)
|
|
95
|
+
with self.assertRaises(PermissionError):
|
|
96
|
+
runner.run()
|
|
97
|
+
|
|
98
|
+
# Overriding with allow_draft=True must succeed
|
|
99
|
+
runner_override = DeclarativeScenario(data, self.base_url, allow_draft=True)
|
|
100
|
+
res = runner_override.run()
|
|
101
|
+
self.assertIsNotNone(res)
|
|
102
|
+
|
|
103
|
+
def test_destructive_step_blocked_without_override(self) -> None:
|
|
104
|
+
"""Destructive steps must be skipped unless allow_destructive is set."""
|
|
105
|
+
data = dict(load_scenario_file(BUILTIN_SCENARIOS_DIR / "times-car-breach.json"))
|
|
106
|
+
data["steps"] = [dict(data["steps"][0])]
|
|
107
|
+
data["steps"][0]["destructive"] = True
|
|
108
|
+
|
|
109
|
+
runner_safe = DeclarativeScenario(data, self.base_url, allow_destructive=False)
|
|
110
|
+
res_safe = runner_safe.run()
|
|
111
|
+
self.assertTrue(any(d.get("skipped") for d in res_safe.details))
|
|
112
|
+
|
|
113
|
+
runner_destr = DeclarativeScenario(data, self.base_url, allow_destructive=True)
|
|
114
|
+
res_destr = runner_destr.run()
|
|
115
|
+
self.assertFalse(any(d.get("skipped") for d in res_destr.details))
|
|
116
|
+
|
|
117
|
+
def test_parameter_override_and_substitution(self) -> None:
|
|
118
|
+
"""Custom parameter overrides must take precedence over defaults."""
|
|
119
|
+
data = load_scenario_file(BUILTIN_SCENARIOS_DIR / "times-car-breach.json")
|
|
120
|
+
runner = DeclarativeScenario(
|
|
121
|
+
data,
|
|
122
|
+
self.base_url,
|
|
123
|
+
param_overrides={
|
|
124
|
+
"endpoint_pattern": "/api/orders/{id}",
|
|
125
|
+
"sample_ids": ["999"],
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
res = runner.run()
|
|
129
|
+
self.assertEqual(res.total_attempts, 1)
|
|
130
|
+
self.assertTrue(res.passed)
|
|
131
|
+
|
|
132
|
+
def test_evidence_generation(self) -> None:
|
|
133
|
+
"""generate_execution_evidence produces complete audit record conforming to METI SCS."""
|
|
134
|
+
data = load_scenario_file(BUILTIN_SCENARIOS_DIR / "times-car-breach.json")
|
|
135
|
+
runner = DeclarativeScenario(data, self.base_url)
|
|
136
|
+
res = runner.run()
|
|
137
|
+
|
|
138
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
139
|
+
ev_path = Path(tmp_dir) / "evidence.json"
|
|
140
|
+
ev = generate_execution_evidence([runner], [res], self.base_url, output_path=ev_path)
|
|
141
|
+
self.assertTrue(ev_path.is_file())
|
|
142
|
+
self.assertEqual(ev["evidence_type"], "breach_and_attack_simulation_audit")
|
|
143
|
+
self.assertEqual(len(ev["audit_records"]), 1)
|
|
144
|
+
self.assertTrue(ev["audit_records"][0]["scenario_hash"].startswith("sha256:"))
|
|
145
|
+
|
|
146
|
+
def test_cli_list_and_validate(self) -> None:
|
|
147
|
+
"""Test CLI flags --list and --validate."""
|
|
148
|
+
# --list
|
|
149
|
+
exit_code = cli_main(["simulate", "--list"])
|
|
150
|
+
self.assertEqual(exit_code, 0)
|
|
151
|
+
|
|
152
|
+
# --validate valid
|
|
153
|
+
valid_file = str(BUILTIN_SCENARIOS_DIR / "times-car-breach.json")
|
|
154
|
+
exit_code = cli_main(["simulate", "--validate", valid_file])
|
|
155
|
+
self.assertEqual(exit_code, 0)
|
|
156
|
+
|
|
157
|
+
# --validate invalid
|
|
158
|
+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
|
|
159
|
+
f.write("{\"id\": \"incomplete\"}")
|
|
160
|
+
inv_file = f.name
|
|
161
|
+
try:
|
|
162
|
+
exit_code = cli_main(["simulate", "--validate", inv_file])
|
|
163
|
+
self.assertEqual(exit_code, 2)
|
|
164
|
+
finally:
|
|
165
|
+
os.unlink(inv_file)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
unittest.main()
|