@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/assess/lsa/scripts/attack_chains.py +573 -0
  3. package/assess/lsa/scripts/baseline.py +14 -0
  4. package/assess/lsa/scripts/findings_to_sarif.py +11 -0
  5. package/assess/lsa/scripts/forecast.py +544 -0
  6. package/assess/lsa/scripts/merge_findings.py +14 -0
  7. package/assess/lsa/scripts/render_report.py +86 -0
  8. package/assess/lsa/tests/test_attack_chains.py +292 -0
  9. package/assess/lsa/tests/test_forecast.py +160 -0
  10. package/contracts/schemas/api-inventory-diff.schema.json +213 -0
  11. package/contracts/schemas/attack-chain.schema.json +181 -0
  12. package/contracts/schemas/bas-scenario.schema.json +217 -0
  13. package/contracts/schemas/evidence-manifest.schema.json +117 -0
  14. package/contracts/schemas/finding.schema.json +7 -0
  15. package/contracts/schemas/forecast.schema.json +172 -0
  16. package/governance/scs/__init__.py +21 -0
  17. package/governance/scs/mapping.py +133 -0
  18. package/governance/scs/pack.py +354 -0
  19. package/operate/cli/__init__.py +1 -1
  20. package/operate/cli/commands/evidence.py +138 -0
  21. package/operate/cli/commands/inventory.py +187 -0
  22. package/operate/cli/commands/simulate.py +175 -58
  23. package/operate/cli/main.py +7 -3
  24. package/operate/inventory/__init__.py +22 -0
  25. package/operate/inventory/api_inventory.py +566 -0
  26. package/operate/simulate/__init__.py +16 -0
  27. package/operate/simulate/engine.py +370 -0
  28. package/operate/simulate/loader.py +158 -0
  29. package/operate/simulate/scenarios/idc-frontier-ransomware.json +47 -0
  30. package/operate/simulate/scenarios/metabase-cve-2026-72898.json +84 -0
  31. package/operate/simulate/scenarios/times-car-breach.json +74 -0
  32. package/operate/simulate/scenarios.py +52 -0
  33. package/operate/tests/test_api_inventory.py +239 -0
  34. package/operate/tests/test_declarative_scenarios.py +169 -0
  35. package/operate/tests/test_evidence_pack.py +128 -0
  36. 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 &amp; 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()