@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,370 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Declarative Simulation Engine for Komagu BAS (#317).
|
|
3
|
+
|
|
4
|
+
Executes declarative scenario JSON definitions using stdlib urllib and filesystem assertions.
|
|
5
|
+
Enforces governance safeguards:
|
|
6
|
+
- Only status == "approved" runs without --allow-draft.
|
|
7
|
+
- destructive == True steps are blocked without --allow-destructive.
|
|
8
|
+
- Signed execution evidence generation for METI SCS evidence (#271).
|
|
9
|
+
|
|
10
|
+
Standard library only (Python 3.8+) per ADR-0006.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import stat
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import urllib.request
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
26
|
+
|
|
27
|
+
from operate.simulate.loader import validate_scenario
|
|
28
|
+
from operate.simulate.scenarios import BaseScenario, ScenarioResult
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DeclarativeScenario(BaseScenario):
|
|
32
|
+
"""Executes a declarative scenario loaded from bas-scenario.schema.json."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
scenario_data: Dict[str, Any],
|
|
37
|
+
target: str,
|
|
38
|
+
param_overrides: Optional[Dict[str, Any]] = None,
|
|
39
|
+
allow_draft: bool = False,
|
|
40
|
+
allow_destructive: bool = False,
|
|
41
|
+
timeout: float = 10.0,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(target, timeout)
|
|
44
|
+
self.raw_data = scenario_data
|
|
45
|
+
self.scenario_id = scenario_data["id"]
|
|
46
|
+
self.scenario_name = scenario_data["title"]
|
|
47
|
+
self.version = scenario_data.get("version", "1.0.0")
|
|
48
|
+
self.status = scenario_data.get("status", "draft")
|
|
49
|
+
self.description = scenario_data.get("incident", {}).get("summary", "")
|
|
50
|
+
self.incident_reference = (
|
|
51
|
+
f"{scenario_data.get('incident', {}).get('name', '')}: "
|
|
52
|
+
f"{' '.join(scenario_data.get('incident', {}).get('references', []))}"
|
|
53
|
+
)
|
|
54
|
+
self.param_overrides = param_overrides or {}
|
|
55
|
+
self.allow_draft = allow_draft
|
|
56
|
+
self.allow_destructive = allow_destructive
|
|
57
|
+
|
|
58
|
+
# Initialize parameters from defaults merged with overrides
|
|
59
|
+
self.params: Dict[str, Any] = {}
|
|
60
|
+
for p_name, p_def in scenario_data.get("parameters", {}).items():
|
|
61
|
+
self.params[p_name] = p_def.get("default")
|
|
62
|
+
self.params.update(self.param_overrides)
|
|
63
|
+
|
|
64
|
+
# Compute scenario definition content hash for audit evidence
|
|
65
|
+
scenario_bytes = json.dumps(scenario_data, sort_keys=True).encode("utf-8")
|
|
66
|
+
self.file_hash = f"sha256:{hashlib.sha256(scenario_bytes).hexdigest()}"
|
|
67
|
+
|
|
68
|
+
def run(self) -> ScenarioResult:
|
|
69
|
+
# Governance check: only approved scenarios can run unless allow_draft is set
|
|
70
|
+
if self.status != "approved" and not self.allow_draft:
|
|
71
|
+
raise PermissionError(
|
|
72
|
+
f"Scenario '{self.scenario_id}' has status '{self.status}'. "
|
|
73
|
+
"Only 'approved' scenarios can execute (use --allow-draft to override)."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
details: List[Dict[str, Any]] = []
|
|
77
|
+
total_attempts = 0
|
|
78
|
+
breaches_blocked = 0
|
|
79
|
+
breaches_leaked = 0
|
|
80
|
+
|
|
81
|
+
steps = self.raw_data.get("steps", [])
|
|
82
|
+
for step_idx, step in enumerate(steps):
|
|
83
|
+
is_destructive = step.get("destructive", False)
|
|
84
|
+
if is_destructive and not self.allow_destructive:
|
|
85
|
+
details.append({
|
|
86
|
+
"step_name": step.get("name", f"Step {step_idx + 1}"),
|
|
87
|
+
"blocked": True,
|
|
88
|
+
"skipped": True,
|
|
89
|
+
"note": "Destructive step skipped (requires --allow-destructive)",
|
|
90
|
+
})
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
step_type = step.get("type")
|
|
94
|
+
repeat_cfg = step.get("repeat")
|
|
95
|
+
|
|
96
|
+
if repeat_cfg:
|
|
97
|
+
param_name = repeat_cfg["param_name"]
|
|
98
|
+
as_var = repeat_cfg["as_var"]
|
|
99
|
+
items = self.params.get(param_name, [])
|
|
100
|
+
if not isinstance(items, list):
|
|
101
|
+
items = [items]
|
|
102
|
+
|
|
103
|
+
for it in items:
|
|
104
|
+
iter_vars = dict(self.params)
|
|
105
|
+
iter_vars[as_var] = str(it)
|
|
106
|
+
attempts, blocked, leaked, det = self._execute_single_step(step, iter_vars)
|
|
107
|
+
total_attempts += attempts
|
|
108
|
+
breaches_blocked += blocked
|
|
109
|
+
breaches_leaked += leaked
|
|
110
|
+
details.extend(det)
|
|
111
|
+
else:
|
|
112
|
+
attempts, blocked, leaked, det = self._execute_single_step(step, self.params)
|
|
113
|
+
total_attempts += attempts
|
|
114
|
+
breaches_blocked += blocked
|
|
115
|
+
breaches_leaked += leaked
|
|
116
|
+
details.extend(det)
|
|
117
|
+
|
|
118
|
+
scoring_cfg = self.raw_data.get("scoring", {})
|
|
119
|
+
recs = scoring_cfg.get("recommendations", {})
|
|
120
|
+
strategy = scoring_cfg.get("strategy", "ratio")
|
|
121
|
+
|
|
122
|
+
if total_attempts > 0:
|
|
123
|
+
if strategy == "all_or_nothing":
|
|
124
|
+
resilience_score = 100.0 if breaches_leaked == 0 else 0.0
|
|
125
|
+
else:
|
|
126
|
+
resilience_score = (breaches_blocked / total_attempts) * 100.0
|
|
127
|
+
else:
|
|
128
|
+
resilience_score = 0.0
|
|
129
|
+
|
|
130
|
+
passed = (breaches_leaked == 0) and (total_attempts > 0)
|
|
131
|
+
recommendation = recs.get("passed", "Secure.") if passed else recs.get("remediation", "Remediate vulnerabilities.")
|
|
132
|
+
|
|
133
|
+
return ScenarioResult(
|
|
134
|
+
scenario_id=self.scenario_id,
|
|
135
|
+
scenario_name=self.scenario_name,
|
|
136
|
+
description=self.description,
|
|
137
|
+
incident_reference=self.incident_reference,
|
|
138
|
+
total_attempts=total_attempts,
|
|
139
|
+
breaches_blocked=breaches_blocked,
|
|
140
|
+
breaches_leaked=breaches_leaked,
|
|
141
|
+
resilience_score=resilience_score,
|
|
142
|
+
passed=passed,
|
|
143
|
+
details=details,
|
|
144
|
+
recommendation=recommendation,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _substitute(self, template: str, context: Dict[str, Any]) -> str:
|
|
148
|
+
"""Replace placeholders {var} with values from context."""
|
|
149
|
+
res = template
|
|
150
|
+
for k, v in context.items():
|
|
151
|
+
res = res.replace(f"{{{k}}}", str(v))
|
|
152
|
+
return res
|
|
153
|
+
|
|
154
|
+
def _execute_single_step(
|
|
155
|
+
self, step: Dict[str, Any], context: Dict[str, Any]
|
|
156
|
+
) -> Tuple[int, int, int, List[Dict[str, Any]]]:
|
|
157
|
+
step_type = step.get("type")
|
|
158
|
+
if step_type == "http_request":
|
|
159
|
+
return self._execute_http_step(step, context)
|
|
160
|
+
elif step_type == "storage_worm_check":
|
|
161
|
+
return self._execute_storage_worm_step(step, context)
|
|
162
|
+
else:
|
|
163
|
+
return 0, 0, 0, [{
|
|
164
|
+
"step_name": step.get("name"),
|
|
165
|
+
"blocked": False,
|
|
166
|
+
"note": f"Unsupported step type: {step_type}",
|
|
167
|
+
}]
|
|
168
|
+
|
|
169
|
+
def _execute_http_step(
|
|
170
|
+
self, step: Dict[str, Any], context: Dict[str, Any]
|
|
171
|
+
) -> Tuple[int, int, int, List[Dict[str, Any]]]:
|
|
172
|
+
req_cfg = step.get("request", {})
|
|
173
|
+
method = req_cfg.get("method", "GET").upper()
|
|
174
|
+
raw_path = self._substitute(req_cfg.get("path", "/"), context)
|
|
175
|
+
# Substitute any residual path tokens
|
|
176
|
+
raw_path = self._substitute(raw_path, context)
|
|
177
|
+
|
|
178
|
+
# Build query string
|
|
179
|
+
query_map = req_cfg.get("query", {})
|
|
180
|
+
if query_map:
|
|
181
|
+
sub_query = {}
|
|
182
|
+
for qk, qv in query_map.items():
|
|
183
|
+
qk_sub = self._substitute(qk, context)
|
|
184
|
+
qv_sub = self._substitute(qv, context)
|
|
185
|
+
sub_query[qk_sub] = qv_sub
|
|
186
|
+
sep = "&" if "?" in raw_path else "?"
|
|
187
|
+
full_path = f"{raw_path}{sep}{urllib.parse.urlencode(sub_query)}"
|
|
188
|
+
else:
|
|
189
|
+
full_path = raw_path
|
|
190
|
+
|
|
191
|
+
url = f"{self.target}{full_path}"
|
|
192
|
+
|
|
193
|
+
headers = {}
|
|
194
|
+
for hk, hv in req_cfg.get("headers", {}).items():
|
|
195
|
+
headers[self._substitute(hk, context)] = self._substitute(hv, context)
|
|
196
|
+
|
|
197
|
+
body_bytes = None
|
|
198
|
+
if "body" in req_cfg:
|
|
199
|
+
body_bytes = self._substitute(req_cfg["body"], context).encode("utf-8")
|
|
200
|
+
|
|
201
|
+
req = urllib.request.Request(url, data=body_bytes, headers=headers, method=method)
|
|
202
|
+
|
|
203
|
+
status_code = 0
|
|
204
|
+
is_leaked = False
|
|
205
|
+
response_sample = ""
|
|
206
|
+
|
|
207
|
+
# Audit URL scheme to only permit HTTP/HTTPS per Bandit B310
|
|
208
|
+
parsed_url = urllib.parse.urlparse(url)
|
|
209
|
+
if parsed_url.scheme.lower() not in ("http", "https"):
|
|
210
|
+
raise ValueError(f"Unsupported URL scheme: {parsed_url.scheme}. Only http and https are permitted.")
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp: # nosec B310
|
|
214
|
+
status_code = resp.status
|
|
215
|
+
body_text = resp.read().decode("utf-8", errors="replace")[:300]
|
|
216
|
+
response_sample = body_text
|
|
217
|
+
except urllib.error.HTTPError as err:
|
|
218
|
+
status_code = err.code
|
|
219
|
+
body_text = err.read().decode("utf-8", errors="replace")[:300]
|
|
220
|
+
response_sample = body_text
|
|
221
|
+
except Exception as ex:
|
|
222
|
+
status_code = 0
|
|
223
|
+
body_text = str(ex)
|
|
224
|
+
response_sample = body_text
|
|
225
|
+
|
|
226
|
+
# Evaluate assertions
|
|
227
|
+
expect_vulnerable = step.get("expect_vulnerable", {})
|
|
228
|
+
expect_secure = step.get("expect_secure", {})
|
|
229
|
+
|
|
230
|
+
# Check for vulnerability indicators
|
|
231
|
+
vuln_status_codes = expect_vulnerable.get("status_codes", [])
|
|
232
|
+
vuln_body_contains = expect_vulnerable.get("body_contains", [])
|
|
233
|
+
vuln_regex = expect_vulnerable.get("body_matches_regex")
|
|
234
|
+
|
|
235
|
+
if vuln_status_codes and status_code in vuln_status_codes:
|
|
236
|
+
is_leaked = True
|
|
237
|
+
elif vuln_body_contains and any(c in body_text for c in vuln_body_contains):
|
|
238
|
+
is_leaked = True
|
|
239
|
+
elif vuln_regex and re.search(vuln_regex, body_text, re.IGNORECASE):
|
|
240
|
+
is_leaked = True
|
|
241
|
+
else:
|
|
242
|
+
# Check secure indicators
|
|
243
|
+
sec_status_codes = expect_secure.get("status_codes", [])
|
|
244
|
+
sec_not_contains = expect_secure.get("body_not_contains", [])
|
|
245
|
+
if sec_status_codes and status_code in sec_status_codes:
|
|
246
|
+
is_leaked = False
|
|
247
|
+
elif sec_not_contains and any(c in body_text for c in sec_not_contains):
|
|
248
|
+
is_leaked = True
|
|
249
|
+
else:
|
|
250
|
+
is_leaked = False
|
|
251
|
+
|
|
252
|
+
blocked = 0 if is_leaked else 1
|
|
253
|
+
leaked = 1 if is_leaked else 0
|
|
254
|
+
|
|
255
|
+
detail_entry: Dict[str, Any] = {
|
|
256
|
+
"step": step.get("name"),
|
|
257
|
+
"url": url,
|
|
258
|
+
"status_code": status_code,
|
|
259
|
+
"leaked": is_leaked,
|
|
260
|
+
"response_sample": response_sample,
|
|
261
|
+
}
|
|
262
|
+
for k in ("target_id", "id", "payload"):
|
|
263
|
+
if k in context:
|
|
264
|
+
detail_entry[k] = context[k]
|
|
265
|
+
|
|
266
|
+
return 1, blocked, leaked, [detail_entry]
|
|
267
|
+
|
|
268
|
+
def _execute_storage_worm_step(
|
|
269
|
+
self, step: Dict[str, Any], context: Dict[str, Any]
|
|
270
|
+
) -> Tuple[int, int, int, List[Dict[str, Any]]]:
|
|
271
|
+
target_dir = Path(context.get("target_dir", self.target)).resolve()
|
|
272
|
+
ops = step.get("storage_check", {}).get("operations", ["overwrite", "truncate", "delete"])
|
|
273
|
+
|
|
274
|
+
total = len(ops)
|
|
275
|
+
blocked = 0
|
|
276
|
+
leaked = 0
|
|
277
|
+
details: List[Dict[str, Any]] = []
|
|
278
|
+
|
|
279
|
+
test_file = target_dir / "audit_archive_sample.bak"
|
|
280
|
+
try:
|
|
281
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
test_file.write_text("Komagu Immutable WORM Backup - Read Only", encoding="utf-8")
|
|
283
|
+
# Mark file read-only for owner (simulating local WORM compliance mode without world permissions)
|
|
284
|
+
os.chmod(str(test_file), stat.S_IRUSR)
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
for op in ops:
|
|
289
|
+
if op == "overwrite":
|
|
290
|
+
try:
|
|
291
|
+
with open(test_file, "w", encoding="utf-8") as f:
|
|
292
|
+
f.write("ENCRYPTED_RANSOMWARE_PAYLOAD")
|
|
293
|
+
leaked += 1
|
|
294
|
+
details.append({"operation": "Overwrite Archive", "blocked": False, "note": "File was writable without WORM lock"})
|
|
295
|
+
except (PermissionError, OSError):
|
|
296
|
+
blocked += 1
|
|
297
|
+
details.append({"operation": "Overwrite Archive", "blocked": True, "note": "Protected by write-protection / WORM lock"})
|
|
298
|
+
elif op == "truncate":
|
|
299
|
+
try:
|
|
300
|
+
with open(test_file, "a", encoding="utf-8") as f:
|
|
301
|
+
f.truncate(0)
|
|
302
|
+
leaked += 1
|
|
303
|
+
details.append({"operation": "Truncate File", "blocked": False, "note": "Truncation allowed"})
|
|
304
|
+
except (PermissionError, OSError):
|
|
305
|
+
blocked += 1
|
|
306
|
+
details.append({"operation": "Truncate File", "blocked": True, "note": "Truncation rejected by lock"})
|
|
307
|
+
elif op == "delete":
|
|
308
|
+
try:
|
|
309
|
+
test_file.unlink()
|
|
310
|
+
leaked += 1
|
|
311
|
+
details.append({"operation": "Delete File", "blocked": False, "note": "Deletion succeeded"})
|
|
312
|
+
except (PermissionError, OSError):
|
|
313
|
+
blocked += 1
|
|
314
|
+
details.append({"operation": "Delete File", "blocked": True, "note": "Deletion prevented by filesystem / WORM policy"})
|
|
315
|
+
|
|
316
|
+
# Restore owner write permission before cleanup
|
|
317
|
+
try:
|
|
318
|
+
os.chmod(str(test_file), stat.S_IRUSR | stat.S_IWUSR)
|
|
319
|
+
test_file.unlink(missing_ok=True)
|
|
320
|
+
except Exception:
|
|
321
|
+
pass
|
|
322
|
+
|
|
323
|
+
return total, blocked, leaked, details
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def generate_execution_evidence(
|
|
327
|
+
scenarios: List[DeclarativeScenario],
|
|
328
|
+
results: List[ScenarioResult],
|
|
329
|
+
target: str,
|
|
330
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
331
|
+
) -> Dict[str, Any]:
|
|
332
|
+
"""Generate signed audit evidence record conforming to METI SCS evidence requirements (#271)."""
|
|
333
|
+
evidence_doc = {
|
|
334
|
+
"$schema": "https://raw.githubusercontent.com/hoanghainh1188/komagu/main/contracts/schemas/bas-scenario.schema.json",
|
|
335
|
+
"evidence_type": "breach_and_attack_simulation_audit",
|
|
336
|
+
"target": target,
|
|
337
|
+
"executed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
338
|
+
"overall_resilience_score": round(
|
|
339
|
+
sum(r.resilience_score for r in results) / len(results) if results else 0.0, 1
|
|
340
|
+
),
|
|
341
|
+
"scenarios_executed": len(results),
|
|
342
|
+
"audit_records": [],
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
sc_map = {s.scenario_id: s for s in scenarios}
|
|
346
|
+
|
|
347
|
+
for r in results:
|
|
348
|
+
sc = sc_map.get(r.scenario_id)
|
|
349
|
+
record = {
|
|
350
|
+
"scenario_id": r.scenario_id,
|
|
351
|
+
"scenario_name": r.scenario_name,
|
|
352
|
+
"version": sc.version if sc else "1.0.0",
|
|
353
|
+
"scenario_hash": sc.file_hash if sc else "sha256:unknown",
|
|
354
|
+
"status": sc.status if sc else "unknown",
|
|
355
|
+
"parameters": sc.params if sc else {},
|
|
356
|
+
"total_attempts": r.total_attempts,
|
|
357
|
+
"breaches_blocked": r.breaches_blocked,
|
|
358
|
+
"breaches_leaked": r.breaches_leaked,
|
|
359
|
+
"resilience_score": r.resilience_score,
|
|
360
|
+
"passed": r.passed,
|
|
361
|
+
"recommendation": r.recommendation,
|
|
362
|
+
}
|
|
363
|
+
evidence_doc["audit_records"].append(record)
|
|
364
|
+
|
|
365
|
+
if output_path:
|
|
366
|
+
p = Path(output_path).resolve()
|
|
367
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
p.write_text(json.dumps(evidence_doc, indent=2), encoding="utf-8")
|
|
369
|
+
|
|
370
|
+
return evidence_doc
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Declarative Scenario Loader and Validator for Komagu BAS (#317).
|
|
3
|
+
|
|
4
|
+
Standard library only (Python 3.8+) per ADR-0006.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
14
|
+
|
|
15
|
+
SCHEMAS_DIR = Path(__file__).resolve().parent.parent.parent / "contracts" / "schemas"
|
|
16
|
+
BUILTIN_SCENARIOS_DIR = Path(__file__).resolve().parent / "scenarios"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_validator(schema_name: str = "bas-scenario.schema.json") -> Callable[[Any], List[str]]:
|
|
20
|
+
"""Loads a JSON schema validator for bas-scenario schema."""
|
|
21
|
+
schema_path = SCHEMAS_DIR / schema_name
|
|
22
|
+
if not schema_path.is_file():
|
|
23
|
+
return lambda obj: [f"Schema file not found: {schema_path}"]
|
|
24
|
+
|
|
25
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
26
|
+
try:
|
|
27
|
+
import jsonschema
|
|
28
|
+
cls = getattr(jsonschema, "Draft202012Validator", None) or getattr(jsonschema, "Draft7Validator")
|
|
29
|
+
schema_clean = schema if cls.__name__ == "Draft202012Validator" else {k: v for k, v in schema.items() if k != "$schema"}
|
|
30
|
+
v = cls(schema_clean)
|
|
31
|
+
return lambda obj: [f"{'/'.join(map(str, e.path))}: {e.message}" for e in v.iter_errors(obj)]
|
|
32
|
+
except (ImportError, AttributeError):
|
|
33
|
+
def fallback(obj: Any) -> List[str]:
|
|
34
|
+
errs: List[str] = []
|
|
35
|
+
_validate_node(obj, schema, schema, "", errs)
|
|
36
|
+
return errs
|
|
37
|
+
return fallback
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validate_node(node: Any, spec: Dict[str, Any], root: Dict[str, Any], path: str, errs: List[str]) -> None:
|
|
41
|
+
"""Recursive stdlib fallback schema validation."""
|
|
42
|
+
if not isinstance(spec, dict):
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
expected_type = spec.get("type")
|
|
46
|
+
if expected_type == "object":
|
|
47
|
+
if not isinstance(node, dict):
|
|
48
|
+
errs.append(f"{path or '/'}: expected object, got {type(node).__name__}")
|
|
49
|
+
return
|
|
50
|
+
for req in spec.get("required", []):
|
|
51
|
+
if req not in node:
|
|
52
|
+
errs.append(f"{path}/{req}: required property missing")
|
|
53
|
+
props = spec.get("properties", {})
|
|
54
|
+
for k, v in node.items():
|
|
55
|
+
if k in props:
|
|
56
|
+
_validate_node(v, props[k], root, f"{path}/{k}", errs)
|
|
57
|
+
elif spec.get("additionalProperties") is False:
|
|
58
|
+
errs.append(f"{path}/{k}: unexpected additional property")
|
|
59
|
+
elif isinstance(spec.get("additionalProperties"), dict):
|
|
60
|
+
_validate_node(v, spec["additionalProperties"], root, f"{path}/{k}", errs)
|
|
61
|
+
|
|
62
|
+
elif expected_type == "array":
|
|
63
|
+
if not isinstance(node, list):
|
|
64
|
+
errs.append(f"{path or '/'}: expected array, got {type(node).__name__}")
|
|
65
|
+
return
|
|
66
|
+
min_items = spec.get("minItems")
|
|
67
|
+
if min_items is not None and len(node) < min_items:
|
|
68
|
+
errs.append(f"{path}: array has {len(node)} items, expected at least {min_items}")
|
|
69
|
+
items_spec = spec.get("items")
|
|
70
|
+
if items_spec:
|
|
71
|
+
for idx, item in enumerate(node):
|
|
72
|
+
_validate_node(item, items_spec, root, f"{path}/{idx}", errs)
|
|
73
|
+
|
|
74
|
+
elif expected_type == "string":
|
|
75
|
+
if not isinstance(node, str):
|
|
76
|
+
errs.append(f"{path}: expected string, got {type(node).__name__}")
|
|
77
|
+
return
|
|
78
|
+
pattern = spec.get("pattern")
|
|
79
|
+
if pattern and not re.search(pattern, node):
|
|
80
|
+
errs.append(f"{path}: '{node}' does not match pattern '{pattern}'")
|
|
81
|
+
enum = spec.get("enum")
|
|
82
|
+
if enum and node not in enum:
|
|
83
|
+
errs.append(f"{path}: '{node}' is not one of {enum}")
|
|
84
|
+
min_len = spec.get("minLength")
|
|
85
|
+
if min_len is not None and len(node) < min_len:
|
|
86
|
+
errs.append(f"{path}: string length {len(node)} is less than minLength {min_len}")
|
|
87
|
+
|
|
88
|
+
elif expected_type == "integer":
|
|
89
|
+
if not isinstance(node, int) or isinstance(node, bool):
|
|
90
|
+
errs.append(f"{path}: expected integer, got {type(node).__name__}")
|
|
91
|
+
|
|
92
|
+
elif expected_type == "boolean":
|
|
93
|
+
if not isinstance(node, bool):
|
|
94
|
+
errs.append(f"{path}: expected boolean, got {type(node).__name__}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_scenario(scenario_data: Dict[str, Any]) -> List[str]:
|
|
98
|
+
"""Validate a scenario dictionary against bas-scenario.schema.json."""
|
|
99
|
+
validator = load_validator("bas-scenario.schema.json")
|
|
100
|
+
return validator(scenario_data)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_scenario_file(file_path: Union[str, Path]) -> Dict[str, Any]:
|
|
104
|
+
"""Read and parse a JSON scenario file."""
|
|
105
|
+
p = Path(file_path).resolve()
|
|
106
|
+
if not p.is_file():
|
|
107
|
+
raise FileNotFoundError(f"Scenario file not found: {p}")
|
|
108
|
+
content = p.read_text(encoding="utf-8")
|
|
109
|
+
data = json.loads(content)
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def discover_scenarios(
|
|
114
|
+
scenario_files: Optional[List[str]] = None,
|
|
115
|
+
scenario_dirs: Optional[List[str]] = None,
|
|
116
|
+
include_builtins: bool = True,
|
|
117
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
118
|
+
"""Discover all scenarios from built-in directory, custom files, and custom directories.
|
|
119
|
+
|
|
120
|
+
Returns a dict mapping scenario_id -> scenario_dict.
|
|
121
|
+
"""
|
|
122
|
+
discovered: Dict[str, Dict[str, Any]] = {}
|
|
123
|
+
|
|
124
|
+
# 1. Built-in scenarios
|
|
125
|
+
if include_builtins and BUILTIN_SCENARIOS_DIR.is_dir():
|
|
126
|
+
for f in BUILTIN_SCENARIOS_DIR.glob("*.json"):
|
|
127
|
+
try:
|
|
128
|
+
data = load_scenario_file(f)
|
|
129
|
+
sid = data.get("id")
|
|
130
|
+
if sid:
|
|
131
|
+
discovered[sid] = data
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
# 2. Custom directories
|
|
136
|
+
if scenario_dirs:
|
|
137
|
+
for d in scenario_dirs:
|
|
138
|
+
dp = Path(d).resolve()
|
|
139
|
+
if dp.is_dir():
|
|
140
|
+
for f in sorted(dp.glob("*.json")):
|
|
141
|
+
try:
|
|
142
|
+
data = load_scenario_file(f)
|
|
143
|
+
sid = data.get("id")
|
|
144
|
+
if sid:
|
|
145
|
+
discovered[sid] = data
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
# 3. Explicit scenario files
|
|
150
|
+
if scenario_files:
|
|
151
|
+
for f in scenario_files:
|
|
152
|
+
fp = Path(f).resolve()
|
|
153
|
+
data = load_scenario_file(fp)
|
|
154
|
+
sid = data.get("id")
|
|
155
|
+
if sid:
|
|
156
|
+
discovered[sid] = data
|
|
157
|
+
|
|
158
|
+
return discovered
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/hoanghainh1188/komagu/main/contracts/schemas/bas-scenario.schema.json",
|
|
3
|
+
"id": "idc-frontier-ransomware",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"title": "IDC Frontier Cloud Storage Ransomware Simulation",
|
|
6
|
+
"status": "approved",
|
|
7
|
+
"approved_by": "sec-team@komagu.io",
|
|
8
|
+
"approved_at": "2026-03-15T00:00:00Z",
|
|
9
|
+
"incident": {
|
|
10
|
+
"name": "IDC Frontier Cloud Storage Ransomware Disaster",
|
|
11
|
+
"date": "2024",
|
|
12
|
+
"summary": "Simulates in-flight malicious tamper/deletion attempts against storage archives to verify Write-Once-Read-Many (WORM) and S3 Object Lock compliance.",
|
|
13
|
+
"references": [
|
|
14
|
+
"IDC Frontier East Japan ransomware disaster: 495 enterprise tenants suffered unrecoverable cloud data loss."
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"mappings": {
|
|
18
|
+
"cwe": ["CWE-284"],
|
|
19
|
+
"owasp_api": ["API1:2023"],
|
|
20
|
+
"mitre_attack": ["T1485", "T1486"]
|
|
21
|
+
},
|
|
22
|
+
"parameters": {
|
|
23
|
+
"target_dir": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"default": "/tmp/backup-dir",
|
|
26
|
+
"description": "Path to local directory or storage mount point being evaluated for immutable WORM protection."
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"steps": [
|
|
30
|
+
{
|
|
31
|
+
"name": "WORM Tamper & Deletion Compliance Check",
|
|
32
|
+
"description": "Performs overwrite, truncate, and delete operations against test archive file.",
|
|
33
|
+
"type": "storage_worm_check",
|
|
34
|
+
"destructive": false,
|
|
35
|
+
"storage_check": {
|
|
36
|
+
"operations": ["overwrite", "truncate", "delete"]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"scoring": {
|
|
41
|
+
"strategy": "ratio",
|
|
42
|
+
"recommendations": {
|
|
43
|
+
"remediation": "Deploy Komagu Boilerplate 3 (Immutable Backup & DR) with AWS S3 Object Lock in COMPLIANCE mode and cross-region replication to prevent unrecoverable cloud ransomware wiping.",
|
|
44
|
+
"passed": "Storage assets strictly adhere to immutable WORM policy."
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/hoanghainh1188/komagu/main/contracts/schemas/bas-scenario.schema.json",
|
|
3
|
+
"id": "metabase-cve-2026-72898",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"title": "Metabase CVE-2026-72898 Zero-Day SQLi Simulation",
|
|
6
|
+
"status": "approved",
|
|
7
|
+
"approved_by": "sec-team@komagu.io",
|
|
8
|
+
"approved_at": "2026-03-15T00:00:00Z",
|
|
9
|
+
"incident": {
|
|
10
|
+
"name": "Metabase Zero-Day Remote SQL Injection",
|
|
11
|
+
"date": "2024",
|
|
12
|
+
"summary": "Simulates unauthenticated remote SQL injection probes targeting query and search endpoints to measure WAF and AST parameterization defenses.",
|
|
13
|
+
"references": [
|
|
14
|
+
"CVE-2026-72898 (CVSS 10.0 unauthenticated SQLi)",
|
|
15
|
+
"Metabase zero-day leading to JGC Digital breach."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"mappings": {
|
|
19
|
+
"cwe": ["CWE-89"],
|
|
20
|
+
"owasp_api": ["API8:2023"],
|
|
21
|
+
"mitre_attack": ["T1190"]
|
|
22
|
+
},
|
|
23
|
+
"parameters": {
|
|
24
|
+
"query_path": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"default": "/api/products",
|
|
27
|
+
"description": "Path to the query/search API endpoint."
|
|
28
|
+
},
|
|
29
|
+
"target_param": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"default": "q",
|
|
32
|
+
"description": "Query parameter name vulnerable to injection."
|
|
33
|
+
},
|
|
34
|
+
"payloads": {
|
|
35
|
+
"type": "list",
|
|
36
|
+
"default": [
|
|
37
|
+
"' OR '1'='1",
|
|
38
|
+
"1 OR 1=1 --",
|
|
39
|
+
"' UNION SELECT 1, 'komagu-cve-probe', 3 --",
|
|
40
|
+
"admin' --",
|
|
41
|
+
"1; SELECT pg_sleep(0) --"
|
|
42
|
+
],
|
|
43
|
+
"description": "SQL injection payloads to probe."
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"steps": [
|
|
47
|
+
{
|
|
48
|
+
"name": "Probe Unauthenticated SQL Injection",
|
|
49
|
+
"description": "Tests query endpoint with SQL injection payloads, checking for SQL error reflection or bypass.",
|
|
50
|
+
"type": "http_request",
|
|
51
|
+
"destructive": false,
|
|
52
|
+
"repeat": {
|
|
53
|
+
"param_name": "payloads",
|
|
54
|
+
"as_var": "payload"
|
|
55
|
+
},
|
|
56
|
+
"request": {
|
|
57
|
+
"method": "GET",
|
|
58
|
+
"path": "{query_path}",
|
|
59
|
+
"query": {
|
|
60
|
+
"{target_param}": "{payload}"
|
|
61
|
+
},
|
|
62
|
+
"headers": {
|
|
63
|
+
"User-Agent": "Komagu-BAS-MetabaseSimulator/1.0",
|
|
64
|
+
"Accept": "application/json"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"expect_secure": {
|
|
68
|
+
"status_codes": [400, 403],
|
|
69
|
+
"body_not_contains": ["komagu-cve-probe"]
|
|
70
|
+
},
|
|
71
|
+
"expect_vulnerable": {
|
|
72
|
+
"body_contains": ["komagu-cve-probe"],
|
|
73
|
+
"body_matches_regex": "(syntax error|sql|mysql|sqlite|postgresql|ora-)"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
],
|
|
77
|
+
"scoring": {
|
|
78
|
+
"strategy": "ratio",
|
|
79
|
+
"recommendations": {
|
|
80
|
+
"remediation": "Deploy Komagu Coraza WAF virtual patches (SecRule id:1100001+) and execute 'komagu fix' to rewrite dynamic SQL concatenations into parameterized prepared statements.",
|
|
81
|
+
"passed": "Perimeter WAF and parameterized queries successfully mitigated all injection payloads."
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|