@hoanghainh/komagu 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assess/lsa/CHANGELOG.md +15 -0
  3. package/assess/lsa/scripts/attack_chains.py +573 -0
  4. package/assess/lsa/scripts/baseline.py +14 -0
  5. package/assess/lsa/scripts/detect_stack.py +2 -1
  6. package/assess/lsa/scripts/findings_to_sarif.py +11 -0
  7. package/assess/lsa/scripts/forecast.py +544 -0
  8. package/assess/lsa/scripts/lsa_common.py +1 -5
  9. package/assess/lsa/scripts/merge_findings.py +14 -0
  10. package/assess/lsa/scripts/redaction.py +118 -0
  11. package/assess/lsa/scripts/render_report.py +86 -0
  12. package/assess/lsa/tests/test_attack_chains.py +292 -0
  13. package/assess/lsa/tests/test_forecast.py +160 -0
  14. package/assess/lsa/tests/test_modern_stacks.py +13 -0
  15. package/contracts/schemas/api-inventory-diff.schema.json +213 -0
  16. package/contracts/schemas/attack-chain.schema.json +181 -0
  17. package/contracts/schemas/bas-scenario.schema.json +217 -0
  18. package/contracts/schemas/evidence-manifest.schema.json +117 -0
  19. package/contracts/schemas/finding.schema.json +7 -0
  20. package/contracts/schemas/forecast.schema.json +172 -0
  21. package/governance/scs/__init__.py +21 -0
  22. package/governance/scs/mapping.py +133 -0
  23. package/governance/scs/pack.py +354 -0
  24. package/operate/ai/context.py +22 -2
  25. package/operate/cli/__init__.py +1 -1
  26. package/operate/cli/commands/evidence.py +138 -0
  27. package/operate/cli/commands/inventory.py +187 -0
  28. package/operate/cli/commands/simulate.py +175 -58
  29. package/operate/cli/main.py +7 -3
  30. package/operate/inventory/__init__.py +22 -0
  31. package/operate/inventory/api_inventory.py +566 -0
  32. package/operate/simulate/__init__.py +16 -0
  33. package/operate/simulate/engine.py +370 -0
  34. package/operate/simulate/loader.py +158 -0
  35. package/operate/simulate/scenarios/idc-frontier-ransomware.json +47 -0
  36. package/operate/simulate/scenarios/metabase-cve-2026-72898.json +84 -0
  37. package/operate/simulate/scenarios/times-car-breach.json +74 -0
  38. package/operate/simulate/scenarios.py +52 -0
  39. package/operate/tests/test_ai_redaction.py +181 -0
  40. package/operate/tests/test_api_inventory.py +239 -0
  41. package/operate/tests/test_declarative_scenarios.py +169 -0
  42. package/operate/tests/test_evidence_pack.py +128 -0
  43. package/operate/tests/test_headless_ai.py +16 -1
  44. package/package.json +1 -1
@@ -0,0 +1,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 &amp; Shadow / Zombie API Detection", html_card)
235
+ self.assertIn("POST /api/secret", html_card)
236
+
237
+
238
+ if __name__ == "__main__":
239
+ unittest.main()