@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.
- package/CHANGELOG.md +23 -0
- package/assess/lsa/CHANGELOG.md +15 -0
- package/assess/lsa/scripts/attack_chains.py +573 -0
- package/assess/lsa/scripts/baseline.py +14 -0
- package/assess/lsa/scripts/detect_stack.py +2 -1
- package/assess/lsa/scripts/findings_to_sarif.py +11 -0
- package/assess/lsa/scripts/forecast.py +544 -0
- package/assess/lsa/scripts/lsa_common.py +1 -5
- package/assess/lsa/scripts/merge_findings.py +14 -0
- package/assess/lsa/scripts/redaction.py +118 -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/assess/lsa/tests/test_modern_stacks.py +13 -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/ai/context.py +22 -2
- 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_ai_redaction.py +181 -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/operate/tests/test_headless_ai.py +16 -1
- 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,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Secrets are masked in every code snippet the headless agents send to the model (#350)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from operate.ai.context import _model_source, _read_snippet, _redact_lines, _verification_context
|
|
14
|
+
|
|
15
|
+
JAVA_DAO = '''package com.acme.dao;
|
|
16
|
+
public class AccountDao {
|
|
17
|
+
private static final String URL = "jdbc:sqlserver://db01;databaseName=ams;user=sa;password=Ams#2019!";
|
|
18
|
+
private static final String MONGO = "mongodb://svc:Mongo2020pw@mongo01:27017/ams";
|
|
19
|
+
private static final String ORA = "jdbc:oracle:thin:scott/Tiger2018@db02:1521/AMS";
|
|
20
|
+
// db.password=CommentPw7 (old config, kept for reference)
|
|
21
|
+
private String apiKey = "sk-live-abcdef123456";
|
|
22
|
+
public Account find(HttpServletRequest req) {
|
|
23
|
+
String password = req.getParameter("password");
|
|
24
|
+
String sql = "SELECT * FROM account WHERE id = " + req.getParameter("id");
|
|
25
|
+
return jdbc.query(sql);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
PROPERTIES = '''spring.datasource.url=jdbc:oracle:thin:@db02:1521/AMS
|
|
31
|
+
spring.datasource.username=ams_app
|
|
32
|
+
spring.datasource.password=Oracle#Pw2021
|
|
33
|
+
mail.smtp.password: smtpPw99
|
|
34
|
+
app.jwt.secret=c2VjcmV0LWtleS1mb3ItYW1z
|
|
35
|
+
app.token.placeholder=${APP_TOKEN}
|
|
36
|
+
'''
|
|
37
|
+
|
|
38
|
+
PRIVATE_KEY = '''-----BEGIN RSA PRIVATE KEY-----
|
|
39
|
+
MIIEowIBAAKCAQEAwJ1rdTw6kLqA
|
|
40
|
+
Zm9vYmFyYmF6cXV4cXV1eHF1dXg=
|
|
41
|
+
-----END RSA PRIVATE KEY-----
|
|
42
|
+
class Signer {}
|
|
43
|
+
'''
|
|
44
|
+
|
|
45
|
+
JSP = '''<%-- legacy: ldap.bind.password=LdapPw55 --%>
|
|
46
|
+
<%@ page import="java.sql.*" %>
|
|
47
|
+
<% String id = request.getParameter("id"); %>
|
|
48
|
+
'''
|
|
49
|
+
|
|
50
|
+
SECRETS = ("Ams#2019!", "Mongo2020pw", "Tiger2018", "CommentPw7", "sk-live-abcdef123456", "Oracle#Pw2021", "smtpPw99",
|
|
51
|
+
"c2VjcmV0LWtleS1mb3ItYW1z", "MIIEowIBAAKCAQEAwJ1rdTw6kLqA", "Zm9vYmFyYmF6cXV4cXV1eHF1dXg=", "LdapPw55")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TestSnippetRedaction(unittest.TestCase):
|
|
55
|
+
def setUp(self) -> None:
|
|
56
|
+
self.target = Path(tempfile.mkdtemp(prefix="komagu_redact_"))
|
|
57
|
+
files = {"src/com/acme/dao/AccountDao.java": JAVA_DAO, "src/main/resources/application.properties": PROPERTIES,
|
|
58
|
+
"src/com/acme/Signer.java": PRIVATE_KEY, "WebContent/user/detail.jsp": JSP}
|
|
59
|
+
for rel, text in files.items():
|
|
60
|
+
(self.target / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
(self.target / rel).write_text(text, encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
def tearDown(self) -> None:
|
|
64
|
+
shutil.rmtree(self.target, ignore_errors=True)
|
|
65
|
+
|
|
66
|
+
def assert_no_secret(self, text: str) -> None:
|
|
67
|
+
for secret in SECRETS:
|
|
68
|
+
self.assertNotIn(secret, text)
|
|
69
|
+
|
|
70
|
+
def test_java_source_snippet_masks_connection_strings_and_keys(self) -> None:
|
|
71
|
+
snip = _read_snippet(self.target, "src/com/acme/dao/AccountDao.java", 5)
|
|
72
|
+
self.assert_no_secret(snip)
|
|
73
|
+
self.assertIn("[REDACTED]", snip)
|
|
74
|
+
|
|
75
|
+
def test_code_the_auditor_needs_is_kept(self) -> None:
|
|
76
|
+
snip = _read_snippet(self.target, "src/com/acme/dao/AccountDao.java", 5)
|
|
77
|
+
self.assertIn('String password = req.getParameter("password");', snip)
|
|
78
|
+
self.assertIn('"SELECT * FROM account WHERE id = " + req.getParameter("id")', snip)
|
|
79
|
+
self.assertIn("jdbc:sqlserver://db01;databaseName=ams;user=sa;password=[REDACTED]", snip)
|
|
80
|
+
self.assertIn("mongodb://svc:[REDACTED]@mongo01:27017/ams", snip)
|
|
81
|
+
self.assertIn("jdbc:oracle:thin:scott/[REDACTED]@db02:1521/AMS", snip)
|
|
82
|
+
|
|
83
|
+
def test_config_line_inside_a_comment_is_masked(self) -> None:
|
|
84
|
+
snip = _read_snippet(self.target, "WebContent/user/detail.jsp", 2)
|
|
85
|
+
self.assert_no_secret(snip)
|
|
86
|
+
self.assertIn("ldap.bind.password=[REDACTED] --%>", snip)
|
|
87
|
+
self.assertIn('String id = request.getParameter("id");', snip)
|
|
88
|
+
|
|
89
|
+
def test_assignments_in_code_are_kept(self) -> None:
|
|
90
|
+
lines = ["user.password = req.body.password;", "if (token == null) return;", "token: req.headers.authorization,"]
|
|
91
|
+
(self.target / "app.js").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
92
|
+
snip = _read_snippet(self.target, "app.js", 2)
|
|
93
|
+
for line in lines:
|
|
94
|
+
self.assertIn(line, snip)
|
|
95
|
+
|
|
96
|
+
def test_unquoted_properties_values_are_masked(self) -> None:
|
|
97
|
+
snip = _read_snippet(self.target, "src/main/resources/application.properties", 3)
|
|
98
|
+
self.assert_no_secret(snip)
|
|
99
|
+
self.assertIn("spring.datasource.username=ams_app", snip)
|
|
100
|
+
self.assertIn("app.token.placeholder=${APP_TOKEN}", snip)
|
|
101
|
+
|
|
102
|
+
def test_private_key_block_is_masked_with_line_numbers_kept(self) -> None:
|
|
103
|
+
snip = _read_snippet(self.target, "src/com/acme/Signer.java", 3)
|
|
104
|
+
self.assert_no_secret(snip)
|
|
105
|
+
self.assertIn(" 5 | class Signer {}", snip)
|
|
106
|
+
|
|
107
|
+
def test_model_and_verifier_context_are_masked(self) -> None:
|
|
108
|
+
model = _model_source(self.target, "AccountDao", "src/com/acme/dao/AccountDao.java", 1)
|
|
109
|
+
self.assert_no_secret(model["code"])
|
|
110
|
+
ctx = _verification_context(self.target, {"location": {"file": "src/com/acme/dao/AccountDao.java", "start_line": 8},
|
|
111
|
+
"evidence": [{"file": "src/main/resources/application.properties", "line": 3}]})
|
|
112
|
+
self.assert_no_secret(str(ctx))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class TestJavaLegacySecretForms(unittest.TestCase):
|
|
116
|
+
"""Forms found in a security review of the first version (#350); each must hide Hunter2x."""
|
|
117
|
+
|
|
118
|
+
LEAKS = (
|
|
119
|
+
'<property name="password" value="Hunter2x"/>',
|
|
120
|
+
'<property name="jdbc.password"\n value="Hunter2x"/>',
|
|
121
|
+
"<param-name>db.password</param-name><param-value>Hunter2x</param-value>",
|
|
122
|
+
"<init-param>\n <param-name>password</param-name>\n <param-value>Hunter2x</param-value>",
|
|
123
|
+
"<password>Hunter2x</password>",
|
|
124
|
+
'conn.setPassword("Hunter2x");',
|
|
125
|
+
'props.setProperty("mail.smtp.password", "Hunter2x");',
|
|
126
|
+
'Connection c = DriverManager.getConnection(url, "sa", "Hunter2x");',
|
|
127
|
+
'return new PasswordAuthentication("svc", "Hunter2x".toCharArray());',
|
|
128
|
+
'String pw = "Hunter2x";',
|
|
129
|
+
'static final String DB_PASS = "Hunter2x";',
|
|
130
|
+
'<Connector port="8443" keystorePass="Hunter2x" truststorePass="Hunter2x"/>',
|
|
131
|
+
'{"password":"Hunter2x", "apikey" : "Hunter2x"}',
|
|
132
|
+
"$conf = array('password' => 'Hunter2x');",
|
|
133
|
+
"db_password: Hunter2x # prod",
|
|
134
|
+
"mail.smtp.pass=Hunter2x",
|
|
135
|
+
"ssl.key.passphrase=Hunter2x",
|
|
136
|
+
"jdbc:sqlserver://db;user=sa;password={Hunter2x;x}",
|
|
137
|
+
'conn.setRequestProperty("Authorization", "Basic SHVudGVyMng6eHl6");',
|
|
138
|
+
'h.put("Authorization", "Bearer Hunter2xHunter2x.eyJzdWIiOiIxIn0.sig");',
|
|
139
|
+
"-----BEGIN PRIVATE KEY----- Hunter2xMIIEvQIBADANBgkq -----END PRIVATE KEY-----",
|
|
140
|
+
)
|
|
141
|
+
TOKENS = ("AKIAIOSFODNN7EXAMPLE", "ghp_" + "a" * 36, "sk-ant-api03-" + "b" * 40, "AIza" + "c" * 35)
|
|
142
|
+
KEPT = (
|
|
143
|
+
'String password = req.getParameter("password");',
|
|
144
|
+
"String sql = \"SELECT * FROM users WHERE login='\" + u + \"' AND pwd='\" + p + \"'\";",
|
|
145
|
+
"<select id=\"login\">SELECT * FROM users WHERE pwd = #{password}</select>",
|
|
146
|
+
"spring.datasource.password=${DB_PASS}",
|
|
147
|
+
"user.password = req.body.password;",
|
|
148
|
+
"if (token == null) return;",
|
|
149
|
+
'String header = "Bearer " + token;',
|
|
150
|
+
'<property name="username" value="ams_app"/>',
|
|
151
|
+
"Passenger p = passengerDao.find(id);",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def redact(self, text: str) -> str:
|
|
155
|
+
return "\n".join(_redact_lines(text.split("\n")))
|
|
156
|
+
|
|
157
|
+
def test_java_legacy_forms_are_masked(self) -> None:
|
|
158
|
+
for sample in self.LEAKS:
|
|
159
|
+
with self.subTest(sample=sample):
|
|
160
|
+
self.assertNotIn("Hunter2x", self.redact(sample))
|
|
161
|
+
|
|
162
|
+
def test_cloud_and_vcs_tokens_are_masked(self) -> None:
|
|
163
|
+
for token in self.TOKENS:
|
|
164
|
+
with self.subTest(token=token[:8]):
|
|
165
|
+
self.assertNotIn(token, self.redact(f'String key = cfg.get("{token}");'))
|
|
166
|
+
|
|
167
|
+
def test_code_the_auditor_needs_is_kept(self) -> None:
|
|
168
|
+
for line in self.KEPT:
|
|
169
|
+
with self.subTest(line=line):
|
|
170
|
+
self.assertEqual(self.redact(line), line)
|
|
171
|
+
|
|
172
|
+
def test_long_lines_are_fast_and_not_sent(self) -> None:
|
|
173
|
+
lines = ["a.b" * 300000 + "password", "a-" * 500000 + "://", "a-b." * 250000, "x" * 1000000]
|
|
174
|
+
start = time.monotonic()
|
|
175
|
+
out = _redact_lines(lines)
|
|
176
|
+
self.assertLess(time.monotonic() - start, 2.0)
|
|
177
|
+
self.assertTrue(all(o.startswith("[line of ") for o in out))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
unittest.main()
|
|
@@ -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()
|