@hoanghainh/komagu 1.8.0 → 2.0.1

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 (210) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/assess/lsa/CHANGELOG.md +6 -0
  4. package/assess/lsa/VERSION +1 -1
  5. package/assess/lsa/adapters/build_adapters.py +16 -8
  6. package/assess/lsa/adapters/codex/README.md +1 -1
  7. package/assess/lsa/adapters/generic/README.md +1 -1
  8. package/assess/lsa/examples/eval-run-4/ground-truth.json +823 -0
  9. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/ai-run-stats.json +58 -0
  10. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/coverage.json +85 -0
  11. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-001.json +822 -0
  12. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-002.json +393 -0
  13. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings.json +4477 -0
  14. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/mass-assignment.candidates.json +232 -0
  15. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-001.json +425 -0
  16. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-002.json +333 -0
  17. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-003.json +400 -0
  18. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-004.json +403 -0
  19. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-005.json +398 -0
  20. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-006.json +63 -0
  21. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/score.json +120 -0
  22. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/ai-run-stats.json +55 -0
  23. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/coverage.json +50 -0
  24. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-001.json +613 -0
  25. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-002.json +287 -0
  26. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings.json +4870 -0
  27. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/mass-assignment.candidates.json +232 -0
  28. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-001.json +425 -0
  29. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-002.json +333 -0
  30. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-003.json +400 -0
  31. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-004.json +403 -0
  32. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-005.json +398 -0
  33. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-006.json +63 -0
  34. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/score.json +107 -0
  35. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/ai-run-stats.json +58 -0
  36. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/coverage.json +92 -0
  37. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-001.json +645 -0
  38. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-002.json +222 -0
  39. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings.json +4859 -0
  40. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/mass-assignment.candidates.json +232 -0
  41. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-001.json +425 -0
  42. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-002.json +333 -0
  43. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-003.json +400 -0
  44. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-004.json +403 -0
  45. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-005.json +398 -0
  46. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-006.json +63 -0
  47. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/score.json +121 -0
  48. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/coverage.json +111 -0
  49. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-001.json +6 -0
  50. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-002.json +344 -0
  51. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings.json +3544 -0
  52. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/mass-assignment.candidates.json +232 -0
  53. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-001.json +425 -0
  54. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-002.json +333 -0
  55. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-003.json +400 -0
  56. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-004.json +403 -0
  57. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-005.json +398 -0
  58. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-006.json +63 -0
  59. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/score.json +123 -0
  60. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/ai-run-stats.json +50 -0
  61. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/coverage.json +42 -0
  62. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-001.json +540 -0
  63. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-002.json +238 -0
  64. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.json +4383 -0
  65. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.sarif +8729 -0
  66. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/mass-assignment.candidates.json +232 -0
  67. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.ja.md +1911 -0
  68. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.md +1911 -0
  69. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-001.json +425 -0
  70. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-002.json +333 -0
  71. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-003.json +400 -0
  72. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-004.json +403 -0
  73. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-005.json +398 -0
  74. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-006.json +63 -0
  75. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/score.json +101 -0
  76. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/stack-profile.json +226 -0
  77. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/ai-run-stats.json +54 -0
  78. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/coverage.json +66 -0
  79. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-001.json +440 -0
  80. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-002.json +229 -0
  81. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings.json +3424 -0
  82. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/mass-assignment.candidates.json +232 -0
  83. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-001.json +425 -0
  84. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-002.json +333 -0
  85. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-003.json +400 -0
  86. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-004.json +403 -0
  87. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-005.json +398 -0
  88. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-006.json +63 -0
  89. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/score.json +120 -0
  90. package/assess/lsa/examples/eval-run-5/gptoss-a/ai-run-stats.json +88 -0
  91. package/assess/lsa/examples/eval-run-5/gptoss-a/coverage.json +36 -0
  92. package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-001.json +530 -0
  93. package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-002.json +248 -0
  94. package/assess/lsa/examples/eval-run-5/gptoss-a/findings.json +4766 -0
  95. package/assess/lsa/examples/eval-run-5/gptoss-a/findings.sarif +9326 -0
  96. package/assess/lsa/examples/eval-run-5/gptoss-a/mass-assignment.candidates.json +232 -0
  97. package/assess/lsa/examples/eval-run-5/gptoss-a/report.ja.md +2011 -0
  98. package/assess/lsa/examples/eval-run-5/gptoss-a/report.md +2011 -0
  99. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-001.json +425 -0
  100. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-002.json +333 -0
  101. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-003.json +400 -0
  102. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-004.json +403 -0
  103. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-005.json +398 -0
  104. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-006.json +63 -0
  105. package/assess/lsa/examples/eval-run-5/gptoss-a/score.json +80 -0
  106. package/assess/lsa/examples/eval-run-5/gptoss-a/stack-profile.json +226 -0
  107. package/assess/lsa/examples/eval-run-5/gptoss-b/ai-run-stats.json +83 -0
  108. package/assess/lsa/examples/eval-run-5/gptoss-b/coverage.json +44 -0
  109. package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-001.json +579 -0
  110. package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-002.json +268 -0
  111. package/assess/lsa/examples/eval-run-5/gptoss-b/findings.json +3913 -0
  112. package/assess/lsa/examples/eval-run-5/gptoss-b/mass-assignment.candidates.json +232 -0
  113. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-001.json +425 -0
  114. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-002.json +333 -0
  115. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-003.json +400 -0
  116. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-004.json +403 -0
  117. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-005.json +398 -0
  118. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-006.json +63 -0
  119. package/assess/lsa/examples/eval-run-5/gptoss-b/score.json +94 -0
  120. package/assess/lsa/examples/eval-run-5/ground-truth.json +823 -0
  121. package/assess/lsa/knowledge/severity-and-cwe.md +1 -1
  122. package/assess/lsa/prompts/35-privilege-auditor.md +3 -1
  123. package/assess/lsa/scripts/baseline.py +5 -2
  124. package/assess/lsa/scripts/findings_to_sarif.py +24 -1
  125. package/assess/lsa/tests/EVAL-RESULTS.md +156 -0
  126. package/assess/lsa/tests/score_findings.py +58 -0
  127. package/assess/lsa/tests/test_adapters.py +21 -2
  128. package/assess/lsa/tests/test_baseline.py +8 -0
  129. package/assess/lsa/tests/test_contract_groundwork.py +7 -7
  130. package/assess/lsa/tests/test_eval_run_4.py +73 -0
  131. package/assess/lsa/tests/test_eval_run_5.py +73 -0
  132. package/assess/lsa/tests/test_privilege_tier2.py +30 -0
  133. package/operate/ai/anchoring.py +130 -0
  134. package/operate/ai/client.py +13 -0
  135. package/operate/ai/context.py +105 -0
  136. package/operate/ai/runner.py +197 -69
  137. package/operate/cli/__init__.py +1 -1
  138. package/operate/cli/commands/scan.py +3 -8
  139. package/operate/cli/commands/test.py +1 -1
  140. package/operate/cli/env.py +4 -20
  141. package/operate/cross_user_tester/persona_matrix.py +5 -1
  142. package/operate/devsecops/pipeline.py +5 -5
  143. package/operate/exploit/generator.py +5 -1
  144. package/operate/lsa_sarif/lsa_to_sarif.py +24 -2
  145. package/operate/lsa_sarif/sarif_findings.py +96 -0
  146. package/operate/remediator/runner.py +1 -5
  147. package/operate/remediator/strategies.py +1 -1
  148. package/operate/tests/test_ai_anchoring.py +184 -0
  149. package/operate/tests/test_cli_baseline.py +1 -9
  150. package/operate/tests/test_cli_env.py +8 -29
  151. package/operate/tests/test_cross_user_pipeline.py +2 -2
  152. package/operate/tests/test_exploit_and_virtual_patch.py +3 -3
  153. package/operate/tests/test_headless_ai.py +148 -1
  154. package/operate/tests/test_npm_package.py +2 -16
  155. package/operate/tests/test_sarif_from_scan.py +156 -0
  156. package/package.json +2 -4
  157. package/remediate/authz/README.md +1 -1
  158. package/remediate/authz/dotnet/src/Komagu.Security.Authorization/DefaultKomaguAuthorizationService.cs +1 -1
  159. package/remediate/authz/dotnet/src/Komagu.Security.Authorization/SecurityAuditEvent.cs +2 -2
  160. package/remediate/authz/golang/authz.go +1 -1
  161. package/remediate/authz/nodejs/src/service.js +1 -1
  162. package/remediate/authz/nodejs/tests/test-authz.js +1 -1
  163. package/remediate/authz/patcher.py +3 -4
  164. package/remediate/authz/python/komagu_authz/models.py +2 -2
  165. package/remediate/authz/python/tests/test_fastapi_adapter.py +1 -1
  166. package/remediate/authz/python/tests/test_fastapi_d5_crosstest.py +1 -1
  167. package/remediate/authz/python/tests/test_python_authz.py +3 -3
  168. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +1 -1
  169. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/SecurityAuditEvent.kt +1 -2
  170. package/remediate/authz/tests/test_bola_patcher.py +1 -1
  171. package/remediate/edge/coraza/rules/coraza-crs-custom.conf +3 -4
  172. package/remediate/edge/coraza/virtual_patcher.py +4 -6
  173. package/remediate/edge/docker-compose.yml +1 -1
  174. package/remediate/ekyc/server/jpki_verifier.py +2 -8
  175. package/remediate/ekyc/server/tokenization.py +2 -8
  176. package/remediate/identity/README.md +5 -5
  177. package/remediate/identity/dotnet/src/Komagu.Security.Identity/KomaguOidcOptions.cs +2 -2
  178. package/remediate/identity/dotnet/tests/Komagu.Security.Identity.Tests/TestTokenGenerator.cs +1 -1
  179. package/remediate/identity/dotnet/tests/Komagu.Security.Identity.Tests/TokenValidationTests.cs +1 -1
  180. package/remediate/identity/python/komagu_identity/oidc_validator.py +2 -8
  181. package/remediate/identity/python/tests/test_oidc_validator.py +1 -1
  182. package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguOidcProperties.kt +2 -2
  183. package/remediate/identity/spring/src/test/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfigurationTest.kt +2 -2
  184. package/remediate/identity/spring/src/test/kotlin/io/komagu/security/identity/TestJwtFactory.kt +1 -1
  185. package/remediate/identity/spring/src/test/kotlin/io/komagu/security/identity/TokenValidationIntegrationTest.kt +1 -1
  186. package/remediate/identity/test-env/realm-komagu.json +1 -1
  187. package/remediate/legacy_shims/coldfusion/KomaguSecurityShim.cfc +5 -5
  188. package/remediate/query/README.md +1 -1
  189. package/remediate/query/dotnet/src/Komagu.Security.Query.Analyzers/DiagnosticIds.cs +1 -1
  190. package/remediate/query/dotnet/src/Komagu.Security.Query.Analyzers/Komagu.Security.Query.Analyzers.csproj +1 -1
  191. package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/detekt/KomaguRuleSetProvider.kt +1 -1
  192. package/remediate/resilience/dr/dr_manager.py +2 -8
  193. package/remediate/resilience/dr/runbook.md +2 -2
  194. package/remediate/resilience/terraform/variables.tf +1 -1
  195. package/remediate/tenancy/audit/audit_trail.py +2 -8
  196. package/remediate/tenancy/crypto/byok_manager.py +2 -8
  197. package/remediate/tenancy/middleware/tenant_context.py +2 -8
  198. package/remediate/tenancy/sql/rls_isolation.sql +11 -11
  199. package/remediate/tenancy/sql/schema_per_tenant.sql +7 -7
  200. package/remediate/tenancy/test_sql_postgres.py +1 -1
  201. package/remediate/tenancy/tests/test_tenancy.py +4 -4
  202. package/bin/komainu +0 -16
  203. package/bin/komainu-legacy.js +0 -16
  204. package/remediate/ekyc/tests/test_legacy_env.py +0 -33
  205. package/remediate/identity/python/tests/test_legacy_env.py +0 -33
  206. package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/LegacyKomainuPropertiesPostProcessor.kt +0 -60
  207. package/remediate/identity/spring/src/main/resources/META-INF/spring.factories +0 -2
  208. package/remediate/identity/spring/src/test/kotlin/io/komagu/security/identity/LegacyKomainuPropertiesPostProcessorTest.kt +0 -86
  209. package/remediate/resilience/tests/test_legacy_env.py +0 -33
  210. package/remediate/tenancy/tests/test_legacy_env.py +0 -49
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """Read LSA SARIF results back as findings.json-style entries (#289).
3
+
4
+ `komagu test --from-scan` and `--export-poc` work on findings.json entries. This lets them take
5
+ the SARIF that `komagu scan` writes instead. Both LSA exporters (assess/lsa/scripts/findings_to_sarif.py
6
+ and operate/lsa_sarif/lsa_to_sarif.py) put finding_id, category, endpoint, attack_vector and
7
+ consensus_score in each result's properties; older SARIF without them falls back to the rule id,
8
+ the OWASP rule tags and the "[METHOD /path]" part of the message.
9
+
10
+ Python standard library only.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ # OWASP API Top 10 rule tags written by findings_to_sarif.py, for SARIF whose ruleId has no category
19
+ _TAG_CATEGORY = {
20
+ "owasp-api1:2023-bola": "bola",
21
+ "owasp-api5:2023-bfla": "bfla",
22
+ "owasp-api3:2023-bopla": "mass_assignment",
23
+ }
24
+ # Rule id prefixes written by operate/lsa_sarif/lsa_to_sarif.py (KOMAGU-<PREFIX>-CWE<n>;
25
+ # 1.x SARIF files use KOMAINU-, still read for one major release, #312)
26
+ _LEGACY_PREFIX_CATEGORY = {"BOLA": "bola", "BFLA": "bfla", "MASS": "mass_assignment", "INJ": "sql_injection"}
27
+ _ENDPOINT_IN_MESSAGE = re.compile(r"\[(GET|POST|PUT|PATCH|DELETE)(?:\|[A-Z]+)*\s+(/[^\]\s]*)\]")
28
+
29
+
30
+ def is_sarif(data: Any) -> bool:
31
+ return isinstance(data, dict) and isinstance(data.get("runs"), list)
32
+
33
+
34
+ def _rule_tags(run: Dict[str, Any]) -> Dict[str, List[str]]:
35
+ rules = ((run.get("tool") or {}).get("driver") or {}).get("rules") or []
36
+ return {
37
+ str(r.get("id", "")): [str(t).lower() for t in ((r.get("properties") or {}).get("tags") or [])]
38
+ for r in rules
39
+ if isinstance(r, dict)
40
+ }
41
+
42
+
43
+ def _category(rule_id: str, props: Dict[str, Any], tags: List[str]) -> Optional[str]:
44
+ if props.get("category"):
45
+ return str(props["category"]).lower()
46
+ if rule_id.startswith("lsa/"):
47
+ parts = rule_id.split("/")
48
+ if len(parts) > 1 and parts[1]:
49
+ return parts[1].lower()
50
+ m = re.match(r"(?:KOMAINU|KOMAGU)-([A-Z]+)-", rule_id)
51
+ if m and m.group(1) in _LEGACY_PREFIX_CATEGORY:
52
+ return _LEGACY_PREFIX_CATEGORY[m.group(1)]
53
+ for tag in tags:
54
+ if tag in _TAG_CATEGORY:
55
+ return _TAG_CATEGORY[tag]
56
+ return None
57
+
58
+
59
+ def findings_from_sarif(data: Dict[str, Any]) -> List[Dict[str, Any]]:
60
+ """Return one findings.json-style dict per SARIF result that names an endpoint."""
61
+ findings: List[Dict[str, Any]] = []
62
+ for run in data.get("runs") or []:
63
+ if not isinstance(run, dict):
64
+ continue
65
+ tags_by_rule = _rule_tags(run)
66
+ for result in run.get("results") or []:
67
+ if not isinstance(result, dict):
68
+ continue
69
+ rule_id = str(result.get("ruleId", ""))
70
+ props = result.get("properties") or {}
71
+ category = _category(rule_id, props, tags_by_rule.get(rule_id, []))
72
+ if category is None:
73
+ continue
74
+ message = str((result.get("message") or {}).get("text", ""))
75
+ endpoint = props.get("endpoint") if isinstance(props.get("endpoint"), dict) else None
76
+ if not endpoint:
77
+ m = _ENDPOINT_IN_MESSAGE.search(message)
78
+ endpoint = {"method": m.group(1), "path": m.group(2)} if m else None
79
+ attack_vector = props.get("attack_vector") if isinstance(props.get("attack_vector"), dict) else None
80
+ if not endpoint and not (attack_vector and attack_vector.get("path")):
81
+ continue
82
+ finding_id = props.get("finding_id") or (result.get("partialFingerprints") or {}).get("lsaFindingId") or rule_id
83
+ finding: Dict[str, Any] = {
84
+ "finding_id": str(finding_id),
85
+ "category": category,
86
+ "title": message.split(" [", 1)[0] or rule_id,
87
+ "status": props.get("status", "confirmed"),
88
+ }
89
+ if endpoint:
90
+ finding["endpoint"] = dict(endpoint)
91
+ if attack_vector:
92
+ finding["attack_vector"] = dict(attack_vector)
93
+ if isinstance(props.get("consensus_score"), (int, float)):
94
+ finding["consensus"] = {"score": props["consensus_score"]}
95
+ findings.append(finding)
96
+ return findings
@@ -191,10 +191,6 @@ class RemediationRunner:
191
191
  self.target_dir / "out" / "findings.json",
192
192
  self.target_dir / ".komagu-devsecops" / "sca.sarif",
193
193
  self.target_dir / ".komagu-devsecops" / "komagu-devsecops.sarif",
194
- # Pre-rename output locations (#290)
195
- self.target_dir / "komainu-out" / "findings.json",
196
- self.target_dir / ".komainu-devsecops" / "sca.sarif",
197
- self.target_dir / ".komainu-devsecops" / "komainu-devsecops.sarif",
198
194
  ]
199
195
  for candidate in candidates:
200
196
  if candidate.is_file():
@@ -236,7 +232,7 @@ class RemediationRunner:
236
232
  rule_upper = rule_id.upper()
237
233
 
238
234
  if (
239
- rule_upper.startswith(("CVE-", "GHSA-", "OSV-", "KOMAINU-SCA-", "DEP-"))
235
+ rule_upper.startswith(("CVE-", "GHSA-", "OSV-", "KOMAGU-SCA-", "DEP-"))
240
236
  or base_name in self.MANIFEST_FILENAMES
241
237
  or base_name.endswith(".csproj")
242
238
  or any(k in driver_name for k in ("trivy", "sca", "dependency", "osv", "pip-audit", "govulncheck"))
@@ -139,7 +139,7 @@ class DependencyVulnerabilityRemediationStrategy(BaseRemediationStrategy):
139
139
  return True
140
140
  if any(code in cwe for code in ("CWE-1395", "CWE-1104", "CWE-937")):
141
141
  return True
142
- if finding_id.startswith(("CVE-", "GHSA-", "OSV-", "KOMAINU-SCA-", "DEP-")):
142
+ if finding_id.startswith(("CVE-", "GHSA-", "OSV-", "KOMAGU-SCA-", "DEP-")):
143
143
  return True
144
144
  if base_name in self.MANIFEST_FILES or base_name.endswith(".csproj"):
145
145
  return True
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """Finding locations anchored to Tier 1 routes / candidates, and one retry on empty replies (#319)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import shutil
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ from operate.ai.anchoring import AnchorReport, anchor_findings, load_anchors
13
+ from operate.ai.runner import HeadlessAIRunner
14
+
15
+
16
+ def _write(path: Path, data: object) -> None:
17
+ path.parent.mkdir(parents=True, exist_ok=True)
18
+ path.write_text(json.dumps(data) if not isinstance(data, str) else data, encoding="utf-8")
19
+
20
+
21
+ class TestAnchoring(unittest.TestCase):
22
+ def setUp(self) -> None:
23
+ self.tmp = Path(tempfile.mkdtemp(prefix="komagu_anchor_"))
24
+ self.target, self.out = self.tmp / "repo", self.tmp / "out"
25
+ _write(self.target / "node-express/routes/admin.js", "\n".join(f"// line {i}" for i in range(1, 21)) + "\n")
26
+ _write(self.target / "python-flask/profile.py", "\n".join(f"# line {i}" for i in range(1, 31)) + "\n")
27
+ _write(self.out / "routes/routes-001.json", {"routes": [
28
+ {"route_id": "R-1", "handler": {"file": "node-express/routes/admin.js", "line": 8}},
29
+ {"route_id": "R-2", "handler": {"file": "node-express/routes/admin.js", "line": 12}},
30
+ {"route_id": "R-3", "handler": {"file": "node-express/routes/admin.js", "line": 16}}]})
31
+ _write(self.out / "mass-assignment.candidates.json", {"candidates": [
32
+ {"candidate_id": "M-14", "file": "python-flask/profile.py", "line": 10}]})
33
+ self.anchors = load_anchors(self.out, self.target)
34
+
35
+ def tearDown(self) -> None:
36
+ shutil.rmtree(self.tmp, ignore_errors=True)
37
+
38
+ def run_one(self, finding: dict) -> tuple:
39
+ report = AnchorReport()
40
+ return anchor_findings([finding], self.anchors, self.target, report)[0], report
41
+
42
+ def test_handler_spans_end_before_the_next_route(self) -> None:
43
+ self.assertEqual((self.anchors["R-1"].line, self.anchors["R-1"].end_line), (8, 11))
44
+ self.assertEqual((self.anchors["R-3"].line, self.anchors["R-3"].end_line), (16, 20))
45
+
46
+ def test_wrong_path_with_line_in_span_keeps_the_line(self) -> None:
47
+ f, report = self.run_one({"finding_id": "BFLA-R-1-1", "route_id": "R-1",
48
+ "location": {"file": "routes.js", "start_line": 9},
49
+ "evidence": [{"file": "routes.js", "line": 9, "why": "no role check"}]})
50
+ self.assertEqual(f["location"], {"file": "node-express/routes/admin.js", "start_line": 9})
51
+ self.assertEqual(f["evidence"], [{"file": "node-express/routes/admin.js", "line": 9, "why": "no role check"}])
52
+ self.assertEqual(report.moved, ["BFLA-R-1-1"])
53
+
54
+ def test_line_outside_span_falls_back_to_handler(self) -> None:
55
+ f, _ = self.run_one({"finding_id": "BFLA-R-1-1", "route_id": "R-1",
56
+ "location": {"file": "node-express/routes/admin.js", "start_line": 17}})
57
+ self.assertEqual(f["location"]["start_line"], 8)
58
+
59
+ def test_candidate_finding_takes_candidate_site(self) -> None:
60
+ f, _ = self.run_one({"finding_id": "MASS-M-14-1", "candidate_id": "M-14",
61
+ "location": {"file": "app.py", "start_line": 8, "end_line": 9},
62
+ "evidence": [{"file": "app.py", "line": 8, "why": "binds request body"}]})
63
+ self.assertEqual(f["location"], {"file": "python-flask/profile.py", "start_line": 10})
64
+ self.assertEqual(f["evidence"][0]["file"], "python-flask/profile.py")
65
+
66
+ def test_correct_location_is_not_reported_as_moved(self) -> None:
67
+ f, report = self.run_one({"finding_id": "BFLA-R-2-1", "route_id": "R-2",
68
+ "location": {"file": "node-express/routes/admin.js", "start_line": 13}})
69
+ self.assertEqual(f["location"]["start_line"], 13)
70
+ self.assertEqual((report.moved, report.unanchored), ([], []))
71
+
72
+ def test_unknown_reference_is_unanchored_and_low_confidence(self) -> None:
73
+ f, report = self.run_one({"finding_id": "BFLA-R-9-1", "route_id": "R-9", "confidence": "high",
74
+ "location": {"file": "x.js", "start_line": 1}})
75
+ self.assertEqual(f["location"], {"file": "x.js", "start_line": 1})
76
+ self.assertEqual(f["confidence"], "low")
77
+ self.assertEqual(report.unanchored, ["BFLA-R-9-1"])
78
+
79
+ def test_input_is_not_mutated(self) -> None:
80
+ original = {"finding_id": "BFLA-R-1-1", "route_id": "R-1", "location": {"file": "routes.js", "start_line": 9}}
81
+ snapshot = json.dumps(original, sort_keys=True)
82
+ self.run_one(original)
83
+ self.assertEqual(json.dumps(original, sort_keys=True), snapshot)
84
+
85
+
86
+ class TestModelSource(unittest.TestCase):
87
+ """The privilege auditor sees the bound model's fields, from the same app and language (#319)."""
88
+
89
+ def setUp(self) -> None:
90
+ from operate.ai.runner import _model_source
91
+ self.model_source = _model_source
92
+ self.tmp = Path(tempfile.mkdtemp(prefix="komagu_model_"))
93
+ _write(self.tmp / "java-legacy/src/model/Account.java", "public class Account {\n private String role;\n}\n")
94
+ _write(self.tmp / "java-legacy/src/web/ProfileServlet.java",
95
+ "class ProfileServlet {\n void post() {\n Account account = new Account();\n BeanUtils.populate(account, params);\n }\n}\n")
96
+ _write(self.tmp / "php-laravel/app/Models/User.php", "<?php\nclass User extends Model {\n protected $guarded = [];\n}\n")
97
+ _write(self.tmp / "node-express/routes/profile.js", "router.post('/p', (req, res) => User.update(req.body));\n")
98
+
99
+ def tearDown(self) -> None:
100
+ shutil.rmtree(self.tmp, ignore_errors=True)
101
+
102
+ def test_target_class_is_found_with_its_fields(self) -> None:
103
+ m = self.model_source(self.tmp, "Account", "java-legacy/src/web/ProfileServlet.java", 4)
104
+ self.assertEqual(m["file"], "java-legacy/src/model/Account.java")
105
+ self.assertIn("private String role", m["code"])
106
+
107
+ def test_class_is_inferred_from_new_near_the_binding(self) -> None:
108
+ m = self.model_source(self.tmp, None, "java-legacy/src/web/ProfileServlet.java", 4)
109
+ self.assertEqual((m["class"], m["file"]), ("Account", "java-legacy/src/model/Account.java"))
110
+
111
+ def test_other_app_or_language_is_not_used(self) -> None:
112
+ m = self.model_source(self.tmp, "User", "node-express/routes/profile.js", 1)
113
+ self.assertEqual((m["class"], m["file"]), ("User", None))
114
+ self.assertIn("not found", m["code"])
115
+
116
+ def test_unknown_target_without_new_gives_none(self) -> None:
117
+ self.assertIsNone(self.model_source(self.tmp, None, "node-express/routes/profile.js", 1))
118
+ self.assertIsNone(self.model_source(self.tmp, "../etc", "node-express/routes/profile.js", 1))
119
+
120
+
121
+ class _ScriptedClient:
122
+ """Returns the scripted replies in order; an Exception item is raised."""
123
+
124
+ model = "scripted"
125
+ endpoint = "test://"
126
+
127
+ def __init__(self, replies: list) -> None:
128
+ self.replies = list(replies)
129
+ self.calls: list = []
130
+
131
+ def chat(self, messages, temperature=0.1): # noqa: ANN001
132
+ self.calls.append(messages)
133
+ item = self.replies.pop(0)
134
+ if isinstance(item, Exception):
135
+ raise item
136
+ return item
137
+
138
+ @staticmethod
139
+ def extract_json(content: str): # noqa: ANN205
140
+ from operate.ai.client import LLMClient
141
+ return LLMClient.extract_json(content)
142
+
143
+
144
+ class TestRetry(unittest.TestCase):
145
+ def setUp(self) -> None:
146
+ self.tmp = Path(tempfile.mkdtemp(prefix="komagu_retry_"))
147
+
148
+ def tearDown(self) -> None:
149
+ shutil.rmtree(self.tmp, ignore_errors=True)
150
+
151
+ def runner(self, replies: list) -> HeadlessAIRunner:
152
+ return HeadlessAIRunner(self.tmp, self.tmp / "out", _ScriptedClient(replies))
153
+
154
+ def test_empty_reply_is_retried_once(self) -> None:
155
+ r = self.runner(["", '{"findings": []}'])
156
+ self.assertEqual(r._chat("4a-bola-auditor", "routes-001", [{"role": "user", "content": "audit"}]), '{"findings": []}')
157
+ self.assertEqual(r.retries, {"4a-bola-auditor": ["routes-001"]})
158
+ retry = r.client.calls[1]
159
+ self.assertEqual([m["role"] for m in retry], ["user", "user"]) # no empty assistant turn
160
+ self.assertIn("JSON object only", retry[-1]["content"])
161
+
162
+ def test_unparseable_reply_is_retried_with_its_text(self) -> None:
163
+ r = self.runner(["looks fine to me", '{"findings": []}'])
164
+ r._chat("4c-privilege-auditor", "privilege-001", [{"role": "user", "content": "audit"}])
165
+ self.assertEqual([m["role"] for m in r.client.calls[1]], ["user", "assistant", "user"])
166
+
167
+ def test_second_bad_reply_keeps_the_first_text(self) -> None:
168
+ r = self.runner(["looks fine to me", "still not json"])
169
+ self.assertEqual(r._chat("5-finding-verifier", "verified-1", [{"role": "user", "content": "v"}]), "looks fine to me")
170
+
171
+ def test_good_reply_is_not_retried(self) -> None:
172
+ r = self.runner(['{"findings": []}'])
173
+ r._chat("4a-bola-auditor", "routes-001", [{"role": "user", "content": "audit"}])
174
+ self.assertEqual((len(r.client.calls), r.retries), (1, {}))
175
+
176
+ def test_failed_call_is_not_retried(self) -> None:
177
+ r = self.runner([RuntimeError("timed out")])
178
+ self.assertIsNone(r._chat("4b-injection-auditor", "injection-001", [{"role": "user", "content": "i"}]))
179
+ self.assertEqual((len(r.client.calls), r.retries), (1, {}))
180
+ self.assertEqual(r.call_errors, {"4b-injection-auditor": {"injection-001": "timed out"}})
181
+
182
+
183
+ if __name__ == "__main__":
184
+ unittest.main()
@@ -58,7 +58,7 @@ class TestScanBaseline(unittest.TestCase):
58
58
  def test_default_scan_adds_fingerprint_and_passes(self) -> None:
59
59
  res = self.scan()
60
60
  self.assertEqual(res.returncode, 0, res.stderr + res.stdout)
61
- self.assertTrue(self.sarif_result()["partialFingerprints"]["komainu/v1"].startswith("kfp1:"))
61
+ self.assertTrue(self.sarif_result()["partialFingerprints"]["komagu/v1"].startswith("kfp1:"))
62
62
 
63
63
  def test_fail_on_new_without_baseline_fails(self) -> None:
64
64
  res = self.scan("--fail-on", "new")
@@ -117,14 +117,6 @@ class TestScanSuppressions(unittest.TestCase):
117
117
  sarif = json.loads((self.out / "findings.sarif").read_text(encoding="utf-8"))
118
118
  self.assertEqual(sarif["runs"][0]["results"][0]["suppressions"][0]["status"], "accepted")
119
119
 
120
- def test_legacy_komainu_suppressions_dir_still_used(self) -> None:
121
- self.write_suppressions(self.target / ".komainu" / "suppressions.json", "2999-12-31")
122
- res = self.scan("--fail-on", "new")
123
- self.assertEqual(res.returncode, 0, res.stderr + res.stdout)
124
- self.assertIn(".komainu/suppressions.json is deprecated", res.stderr)
125
- sarif = json.loads((self.out / "findings.sarif").read_text(encoding="utf-8"))
126
- self.assertEqual(sarif["runs"][0]["results"][0]["suppressions"][0]["status"], "accepted")
127
-
128
120
  def test_expired_explicit_suppression_fails_gate_and_warns(self) -> None:
129
121
  path = self.out.parent / "suppressions.json"
130
122
  self.write_suppressions(path, "2020-01-01")
@@ -1,44 +1,23 @@
1
1
  #!/usr/bin/env python3
2
- """KOMAGU_* environment variables with the deprecated KOMAINU_* fallback (#290)."""
2
+ """KOMAGU_* environment variables; the pre-rename KOMAINU_* names are no longer read (#312)."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- import contextlib
7
- import io
8
6
  import unittest
9
7
 
10
8
  from operate.cli import env as cli_env
11
9
 
12
10
 
13
11
  class TestGetenv(unittest.TestCase):
14
- def setUp(self) -> None:
15
- cli_env._warned.clear()
16
-
17
- def lookup(self, environ, default=None):
18
- stderr = io.StringIO()
19
- with contextlib.redirect_stderr(stderr):
20
- value = cli_env.getenv("AI_MODEL", default, env=environ)
21
- return value, stderr.getvalue()
22
-
23
- def test_new_name_wins_without_warning(self) -> None:
24
- value, err = self.lookup({"KOMAGU_AI_MODEL": "new", "KOMAINU_AI_MODEL": "old"})
25
- self.assertEqual(value, "new")
26
- self.assertEqual(err, "")
27
-
28
- def test_legacy_name_is_read_with_warning(self) -> None:
29
- value, err = self.lookup({"KOMAINU_AI_MODEL": "old"})
30
- self.assertEqual(value, "old")
31
- self.assertIn("KOMAINU_AI_MODEL is deprecated, set KOMAGU_AI_MODEL", err)
32
-
33
- def test_legacy_warning_is_printed_once(self) -> None:
34
- self.lookup({"KOMAINU_AI_MODEL": "old"})
35
- _, err = self.lookup({"KOMAINU_AI_MODEL": "old"})
36
- self.assertEqual(err, "")
12
+ def test_reads_komagu_name(self) -> None:
13
+ self.assertEqual(cli_env.getenv("AI_MODEL", env={"KOMAGU_AI_MODEL": "new"}), "new")
14
+
15
+ def test_legacy_name_is_ignored(self) -> None:
16
+ self.assertEqual(cli_env.getenv("AI_MODEL", "llama3.1", env={"KOMAINU_AI_MODEL": "old"}), "llama3.1")
37
17
 
38
18
  def test_default_when_unset(self) -> None:
39
- value, err = self.lookup({}, default="llama3.1")
40
- self.assertEqual(value, "llama3.1")
41
- self.assertEqual(err, "")
19
+ self.assertEqual(cli_env.getenv("AI_MODEL", "llama3.1", env={}), "llama3.1")
20
+ self.assertIsNone(cli_env.getenv("AI_MODEL", env={}))
42
21
 
43
22
 
44
23
  if __name__ == "__main__":
@@ -208,7 +208,7 @@ class TestCrossUserPipeline(unittest.TestCase):
208
208
 
209
209
  # Check BOLA result mapping
210
210
  bola_res = run["results"][0]
211
- self.assertEqual(bola_res["ruleId"], "KOMAINU-BOLA-CWE639")
211
+ self.assertEqual(bola_res["ruleId"], "KOMAGU-BOLA-CWE639")
212
212
  self.assertEqual(bola_res["level"], "error")
213
213
  loc = bola_res["locations"][0]["physicalLocation"]
214
214
  self.assertEqual(loc["artifactLocation"]["uri"], "legacy/orders.php")
@@ -217,7 +217,7 @@ class TestCrossUserPipeline(unittest.TestCase):
217
217
 
218
218
  # Check SQLi result mapping
219
219
  sqli_res = run["results"][1]
220
- self.assertEqual(sqli_res["ruleId"], "KOMAINU-INJ-CWE89")
220
+ self.assertEqual(sqli_res["ruleId"], "KOMAGU-INJ-CWE89")
221
221
  self.assertEqual(sqli_res["level"], "error")
222
222
 
223
223
  def test_sarif_cli_execution(self) -> None:
@@ -111,9 +111,9 @@ class TestExploitAndVirtualPatcher(unittest.TestCase):
111
111
  self.assertIn("SecRule REQUEST_URI", conf_content)
112
112
  self.assertIn("id:1100001", conf_content)
113
113
  self.assertIn("id:1100002", conf_content)
114
- self.assertIn("tag:'komainu-virtual-patch'", conf_content)
115
- self.assertIn("tag:'komainu-bola'", conf_content)
116
- self.assertIn("tag:'komainu-sqli'", conf_content)
114
+ self.assertIn("tag:'komagu-virtual-patch'", conf_content)
115
+ self.assertIn("tag:'komagu-bola'", conf_content)
116
+ self.assertIn("tag:'komagu-sqli'", conf_content)
117
117
 
118
118
  def test_cli_protect_with_virtual_patch(self) -> None:
119
119
  """komagu protect --virtual-patch must synthesize rules via CLI."""
@@ -74,6 +74,67 @@ class TestLLMClient(unittest.TestCase):
74
74
  self.assertIn("Failed to connect to LLM endpoint", str(ctx.exception))
75
75
 
76
76
 
77
+ class TestSanitizeFinding(unittest.TestCase):
78
+ """Format repairs for model output that would otherwise fail finding.schema.json (#286)."""
79
+
80
+ def test_impact_list_becomes_string(self) -> None:
81
+ f = HeadlessAIRunner._sanitize_finding({"category": "mass_assignment", "subtype": "whole_object_binding",
82
+ "impact": ["role escalation", "balance change"]})
83
+ self.assertEqual(f["impact"], "role escalation; balance change")
84
+
85
+ def test_verdict_in_subtype_moves_to_status(self) -> None:
86
+ f = HeadlessAIRunner._sanitize_finding({"category": "mass_assignment", "subtype": "needs_review", "status": "unverified"})
87
+ self.assertEqual(f["subtype"], "whole_object_binding")
88
+ self.assertEqual(f["status"], "needs_review")
89
+
90
+ def test_unknown_bfla_subtype_falls_back(self) -> None:
91
+ f = HeadlessAIRunner._sanitize_finding({"category": "bfla", "subtype": "admin_only", "status": "unverified"})
92
+ self.assertEqual((f["subtype"], f["status"]), ("missing_role_check", "unverified"))
93
+
94
+ def test_null_evidence_line_and_missing_status_are_repaired(self) -> None:
95
+ f = HeadlessAIRunner._sanitize_finding({"category": "mass_assignment", "subtype": "whole_object_binding",
96
+ "location": {"file": "a.py", "start_line": 7},
97
+ "evidence": [{"file": "a.py", "line": None, "why": "binds body"}]})
98
+ self.assertEqual(f["evidence"][0]["line"], 7)
99
+ self.assertEqual(f["status"], "unverified")
100
+
101
+ def test_valid_subtype_is_kept(self) -> None:
102
+ f = HeadlessAIRunner._sanitize_finding({"category": "bfla", "subtype": "role_from_request"})
103
+ self.assertEqual(f["subtype"], "role_from_request")
104
+
105
+
106
+ class TestVerificationContext(unittest.TestCase):
107
+ """The headless verifier gets the code it judges, including server-side includes (#286)."""
108
+
109
+ def setUp(self) -> None:
110
+ self.tmp = Path(tempfile.mkdtemp(prefix="komagu_verify_ctx_"))
111
+ (self.tmp / "inc").mkdir()
112
+ (self.tmp / "admin").mkdir()
113
+ (self.tmp / "inc" / "auth.php").write_text("<?php\n// Login check only\nsession_start();\n", encoding="utf-8")
114
+ (self.tmp / "admin" / "delete_user.php").write_text(
115
+ "<?php\nrequire_once '../inc/auth.php';\n$db->prepare('DELETE FROM users WHERE id = ?');\n", encoding="utf-8")
116
+
117
+ def tearDown(self) -> None:
118
+ shutil.rmtree(self.tmp, ignore_errors=True)
119
+
120
+ def test_included_files_are_resolved(self) -> None:
121
+ from operate.ai.runner import _included_files
122
+ self.assertEqual(_included_files(self.tmp, "admin/delete_user.php"), ["inc/auth.php"])
123
+ self.assertEqual(_included_files(self.tmp, "missing.php"), [])
124
+
125
+ def test_context_has_site_and_included_code(self) -> None:
126
+ from operate.ai.runner import _verification_context
127
+ ctx = _verification_context(self.tmp, {"location": {"file": "admin/delete_user.php", "start_line": 3}})
128
+ self.assertIn("DELETE FROM users", ctx["location"])
129
+ self.assertEqual(ctx["included_files"][0]["file"], "inc/auth.php")
130
+ self.assertIn("Login check only", ctx["included_files"][0]["code"])
131
+
132
+ def test_include_outside_target_is_ignored(self) -> None:
133
+ from operate.ai.runner import _included_files
134
+ (self.tmp / "x.php").write_text("<?php include '../../etc/passwd';\n", encoding="utf-8")
135
+ self.assertEqual(_included_files(self.tmp, "x.php"), [])
136
+
137
+
77
138
  class TestHeadlessAIRunner(unittest.TestCase):
78
139
  """End-to-end tests for HeadlessAIRunner and multi-agent pipeline."""
79
140
 
@@ -109,7 +170,7 @@ module.exports = app;
109
170
  # Mock LLM response dispatcher
110
171
  def mock_agent_responses(messages, model):
111
172
  user_msg = messages[-1]["content"]
112
- if "Auth Model" in user_msg or "Pre-scan Stack Profile" in user_msg:
173
+ if ("Auth Model" in user_msg or "Pre-scan Stack Profile" in user_msg) and "Findings to Verify" not in user_msg:
113
174
  return "# Mock Application Auth Model\n\n- Auth: Express session\n- Ownership: missing on /api/orders/:id\n"
114
175
  if "dynamic query string concatenation or interpolation" in user_msg:
115
176
  return """```json
@@ -196,6 +257,7 @@ module.exports = app;
196
257
  ```"""
197
258
  return '{"status": "ok"}'
198
259
 
260
+ self.mock_agent_responses = mock_agent_responses
199
261
  self.server, self.thread, self.base_url = start_mock_llm_server(
200
262
  port=0, response_generator=mock_agent_responses
201
263
  )
@@ -204,6 +266,91 @@ module.exports = app;
204
266
  self.server.server_close()
205
267
  shutil.rmtree(self.temp_dir, ignore_errors=True)
206
268
 
269
+ def _prescan(self) -> None:
270
+ import subprocess
271
+
272
+ prescan_script = PROJECT_ROOT / "assess" / "lsa" / "scripts" / "run_prescan.py"
273
+ res = subprocess.run(
274
+ [sys.executable, str(prescan_script), str(self.target_dir), str(self.out_dir)],
275
+ capture_output=True,
276
+ text=True,
277
+ )
278
+ self.assertEqual(res.returncode, 0, f"Pre-scan failed: {res.stderr}")
279
+
280
+ def test_run_stats_record_tokens_and_phases(self) -> None:
281
+ self._prescan()
282
+ client = LLMClient(endpoint=self.base_url, model="mock-model", api_key="test")
283
+ HeadlessAIRunner(self.target_dir, self.out_dir, client).run()
284
+
285
+ stats = json.loads((self.out_dir / "ai-run-stats.json").read_text(encoding="utf-8"))
286
+ self.assertEqual(stats["model"], "mock-model")
287
+ self.assertGreater(stats["totals"]["calls"], 0)
288
+ # the mock server reports 100 prompt / 50 completion tokens per call
289
+ self.assertEqual(stats["totals"]["prompt_tokens"], 100 * stats["totals"]["calls"])
290
+ self.assertEqual(stats["totals"]["completion_tokens"], 50 * stats["totals"]["calls"])
291
+ self.assertEqual(sum(ph["calls"] for ph in stats["phases"].values()), stats["totals"]["calls"])
292
+ self.assertIn("5-finding-verifier", stats["phases"])
293
+ # the mock answers the BOLA prompt with the auth-model markdown; the retry gets plain JSON (#319)
294
+ self.assertEqual(stats["retries"], {"4a-bola-auditor": ["routes-001"]})
295
+ self.assertEqual(stats["parse_failures"], {})
296
+ self.assertEqual(stats["anchoring"]["unanchored"], [])
297
+
298
+ def test_unparseable_verifier_reply_leaves_findings_unverified(self) -> None:
299
+ """A broken verifier reply must not confirm findings (#286)."""
300
+ self.server.server_close()
301
+
302
+ verifier_prompts = []
303
+
304
+ def broken_verifier(messages, model):
305
+ if any("Findings to Verify & Challenge" in m["content"] for m in messages):
306
+ verifier_prompts.append(messages[-1]["content"])
307
+ return "I think these all look vulnerable."
308
+ return self.mock_agent_responses(messages, model)
309
+
310
+ self.server, self.thread, self.base_url = start_mock_llm_server(port=0, response_generator=broken_verifier)
311
+ self._prescan()
312
+ client = LLMClient(endpoint=self.base_url, model="mock-model", api_key="test")
313
+ logs = []
314
+ HeadlessAIRunner(self.target_dir, self.out_dir, client, step_callback=lambda st, msg: logs.append((st, msg))).run()
315
+
316
+ findings = json.loads((self.out_dir / "findings.json").read_text(encoding="utf-8"))["findings"]
317
+ self.assertGreaterEqual(len(findings), 1)
318
+ self.assertNotIn("confirmed", [f.get("status") for f in findings])
319
+ coverage = json.loads((self.out_dir / "coverage.json").read_text(encoding="utf-8"))
320
+ self.assertEqual(sorted(coverage["findings_without_verification"]), sorted(f["finding_id"] for f in findings))
321
+ stats = json.loads((self.out_dir / "ai-run-stats.json").read_text(encoding="utf-8"))
322
+ self.assertEqual(stats["parse_failures"]["5-finding-verifier"], ["verified-1"])
323
+ self.assertEqual(stats["retries"]["5-finding-verifier"], ["verified-1"])
324
+ # the verifier sees the code and the auth model, and is told not to assume unseen controls
325
+ self.assertEqual(len(verifier_prompts), 2, "one retry after the unparseable reply")
326
+ self.assertIn("Answer again with the JSON object only", verifier_prompts[1])
327
+ self.assertIn("code_context", verifier_prompts[0])
328
+ self.assertIn("Mock Application Auth Model", verifier_prompts[0])
329
+ self.assertIn("Never assume a control", verifier_prompts[0])
330
+ raw = self.out_dir / "ai-raw" / "5-finding-verifier-verified-1.txt"
331
+ self.assertEqual(raw.read_text(encoding="utf-8"), "I think these all look vulnerable.")
332
+ self.assertTrue(any(st == "warn" and "verified-1" in msg for st, msg in logs))
333
+
334
+ def test_failed_batch_call_is_recorded_and_run_continues(self) -> None:
335
+ """A timeout on one batch must not abort the run or lose earlier phases (#286)."""
336
+
337
+ class TimeoutOnInjection(LLMClient):
338
+ def chat(self, messages, temperature=0.1, max_tokens=None): # noqa: ANN001
339
+ if "dynamic query string concatenation or interpolation" in messages[-1]["content"]:
340
+ raise RuntimeError("timed out")
341
+ return super().chat(messages, temperature=temperature, max_tokens=max_tokens)
342
+
343
+ self._prescan()
344
+ client = TimeoutOnInjection(endpoint=self.base_url, model="mock-model", api_key="test")
345
+ result = HeadlessAIRunner(self.target_dir, self.out_dir, client).run()
346
+ self.assertEqual(result["status"], "success")
347
+
348
+ stats = json.loads((self.out_dir / "ai-run-stats.json").read_text(encoding="utf-8"))
349
+ self.assertEqual(stats["call_errors"], {"4b-injection-auditor": {"injection-001": "timed out"}})
350
+ self.assertNotIn("4b-injection-auditor", stats["parse_failures"])
351
+ self.assertIn("5-finding-verifier", stats["phases"])
352
+ self.assertTrue((self.out_dir / "findings.json").is_file())
353
+
207
354
  def test_headless_ai_runner_full_cycle(self) -> None:
208
355
  # 1. Run deterministic pre-scan to populate OUT
209
356
  prescan_script = PROJECT_ROOT / "assess" / "lsa" / "scripts" / "run_prescan.py"
@@ -14,7 +14,6 @@ from pathlib import Path
14
14
  REPO_ROOT = Path(__file__).resolve().parents[2]
15
15
  PACKAGE_JSON = REPO_ROOT / "package.json"
16
16
  BIN_KOMAGU_JS = REPO_ROOT / "bin" / "komagu.js"
17
- BIN_LEGACY_JS = REPO_ROOT / "bin" / "komainu-legacy.js"
18
17
  WORKFLOW_FILE = REPO_ROOT / ".github" / "workflows" / "npm-publish.yml"
19
18
  RELEASE_PLEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release-please.yml"
20
19
  RELEASE_PLEASE_CONFIG = REPO_ROOT / "release-please-config.json"
@@ -35,9 +34,8 @@ class TestNPMPackage(unittest.TestCase):
35
34
  self.assertEqual(data.get("publishConfig", {}).get("access"), "public")
36
35
  self.assertIn("version", data)
37
36
  self.assertEqual(data.get("bin", {}).get("komagu"), "bin/komagu.js")
38
- # Deprecated pre-rename aliases (#290)
39
- self.assertEqual(data.get("bin", {}).get("komainu"), "bin/komainu-legacy.js")
40
- self.assertEqual(data.get("bin", {}).get("komainu-guard"), "bin/komainu-legacy.js")
37
+ # Pre-rename aliases were removed in 2.0 (#312)
38
+ self.assertEqual(set(data.get("bin", {})), {"komagu"})
41
39
  self.assertEqual(data.get("license"), "Apache-2.0")
42
40
 
43
41
  # Whitelisted files must include platform modules
@@ -72,18 +70,6 @@ class TestNPMPackage(unittest.TestCase):
72
70
  self.assertIn("test", res_h.stdout)
73
71
  self.assertIn("demo", res_h.stdout)
74
72
 
75
- def test_legacy_bin_alias_warns_and_delegates(self) -> None:
76
- """Deprecated komainu / komainu-guard bins must print a rename notice and still run (#290)."""
77
- node_bin = shutil.which("node")
78
- if not node_bin:
79
- self.skipTest("Node.js runtime not installed on host")
80
-
81
- self.assertTrue(BIN_LEGACY_JS.stat().st_mode & 0o111 != 0, "bin/komainu-legacy.js is not executable")
82
- res = subprocess.run([node_bin, str(BIN_LEGACY_JS), "--version"], capture_output=True, text=True)
83
- self.assertEqual(res.returncode, 0, res.stderr)
84
- self.assertIn("komagu v", res.stdout)
85
- self.assertIn("renamed to `komagu`", res.stderr)
86
-
87
73
  def test_node_launcher_error_on_missing_python(self) -> None:
88
74
  """Launcher must show friendly error with Docker fallback if Python is missing."""
89
75
  node_bin = shutil.which("node")