@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
@@ -43,7 +43,7 @@ Finding id prefixes: `BOLA-`, `INJ-`, `BFLA-`, `MASS-`.
43
43
 
44
44
  `fingerprint` is optional. When present it has the form `kfp1:<64 lowercase hex>`
45
45
  and must not depend on line numbers, so the same issue keeps the same value
46
- across scans. Exporters copy it to SARIF `partialFingerprints["komainu/v1"]`.
46
+ across scans. Exporters copy it to SARIF `partialFingerprints["komagu/v1"]`.
47
47
 
48
48
  ## SARIF level mapping
49
49
 
@@ -69,7 +69,9 @@ privileged, and a hinted route can be safe.
69
69
  2. **Find the model's fields.** Read the model / entity / table. List the
70
70
  privileged fields it holds (role, admin flag, permissions, owner, user,
71
71
  tenant, price, amount, balance, status, verified, approved, plan).
72
- No privileged field → `safe`, reason "no privileged field".
72
+ No privileged field → `safe`, reason "no privileged field". If you cannot
73
+ find or read the model's definition, you do not know its fields: answer
74
+ `needs_review` (reason "model fields not visible"), never `safe`.
73
75
  3. **Look for what limits the fields**, with `file:line`: explicit field
74
76
  copy, allow-list (`$fillable` without privileged fields, `permit(...)`,
75
77
  `[Bind(Include=...)]`, DTO with only editable fields), validation that
@@ -35,7 +35,9 @@ from trend import append_history, mttr_by_severity, snapshot # noqa: E402
35
35
 
36
36
  SEVERITY_RANK = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}
37
37
  FAIL_ON = ("none", "new", "any")
38
- SARIF_FINGERPRINT_KEY = "komainu/v1"
38
+ SARIF_FINGERPRINT_KEY = "komagu/v1"
39
+ # SARIF baselines written before 2.0 use the old key; still read for one major release (#312)
40
+ LEGACY_SARIF_FINGERPRINT_KEY = "komainu/v1"
39
41
 
40
42
 
41
43
  class BaselineError(Exception):
@@ -57,7 +59,8 @@ def _entries_from_sarif(doc: Dict) -> Dict[str, Dict]:
57
59
  entries: Dict[str, Dict] = {}
58
60
  for run in doc.get("runs") or []:
59
61
  for result in run.get("results") or []:
60
- fp = (result.get("partialFingerprints") or {}).get(SARIF_FINGERPRINT_KEY)
62
+ fingerprints = result.get("partialFingerprints") or {}
63
+ fp = fingerprints.get(SARIF_FINGERPRINT_KEY) or fingerprints.get(LEGACY_SARIF_FINGERPRINT_KEY)
61
64
  if not fp:
62
65
  continue
63
66
  props = result.get("properties") or {}
@@ -132,6 +132,28 @@ OWASP_TAGS = {
132
132
  }
133
133
 
134
134
 
135
+ # Attack-vector keys that `komagu test --from-scan` and `--export-poc` need to replay a
136
+ # finding from SARIF. suggested_payload is left out so attack strings stay out of code scanning.
137
+ REPLAY_KEYS = ("method", "path", "fields", "readback", "target_param")
138
+
139
+
140
+ def replay_properties(f: Dict[str, Any]) -> Dict[str, Any]:
141
+ """Result properties that let a SARIF result be replayed like a findings.json entry."""
142
+ props: Dict[str, Any] = {"finding_id": f.get("finding_id"), "category": f.get("category", "bola")}
143
+ ep = f.get("endpoint")
144
+ if isinstance(ep, dict) and ep.get("path"):
145
+ props["endpoint"] = {k: ep[k] for k in ("method", "path") if ep.get(k)}
146
+ av = f.get("attack_vector")
147
+ if isinstance(av, dict):
148
+ kept = {k: av[k] for k in REPLAY_KEYS if av.get(k) not in (None, "", {})}
149
+ if kept:
150
+ props["attack_vector"] = kept
151
+ cons = f.get("consensus")
152
+ if isinstance(cons, dict) and isinstance(cons.get("score"), (int, float)):
153
+ props["consensus_score"] = cons["score"]
154
+ return props
155
+
156
+
135
157
  def normalize_cwe_id(cwe_val: Any) -> str:
136
158
  """Normalize numeric or prefixed CWE ID to standard 'CWE-XXX' format."""
137
159
  s = str(cwe_val).strip()
@@ -319,7 +341,7 @@ def to_sarif(
319
341
 
320
342
  fingerprints = {"lsaFindingId": f.get("finding_id", f"LSA-{len(results)+1}")}
321
343
  if f.get("fingerprint"):
322
- fingerprints["komainu/v1"] = f["fingerprint"]
344
+ fingerprints["komagu/v1"] = f["fingerprint"]
323
345
 
324
346
  result_taxa = []
325
347
  for c in norm_cwes:
@@ -352,6 +374,7 @@ def to_sarif(
352
374
  "confidence": f.get("confidence", "high"),
353
375
  "status": f.get("status", "unverified"),
354
376
  "cwe": norm_cwes,
377
+ **replay_properties(f),
355
378
  },
356
379
  }
357
380
  state = f.get("baseline_state")
@@ -152,3 +152,159 @@ Based on Qwen 3.5's findings, the following concrete improvements were implement
152
152
  | **False Positives on Safe Code** | 0 | 0 | 0 on safe markers | **0** |
153
153
  | **Precision (matched / active)** | 0.965 (55 / 57) | 0.965 (55 / 57) | 0.966 | **1.000 (57 / 57)** |
154
154
  | **Legacy Stack Shims** | None | None | Recommended | **4 Stacks (`remediate/legacy_shims/`)** |
155
+
156
+ ---
157
+
158
+ ## Run 4 — 2026-10-31, kit 0.3.1 (BFLA & mass assignment Tier 2, local Ollama)
159
+
160
+ Issue #286. First end-to-end measurement of phase 4c (`35-privilege-auditor`) with real models, and a
161
+ re-check of BOLA / injection on the same harness. All runs are **local only**: fixture code never left
162
+ the machine, and no third-party LLM was used.
163
+
164
+ ### Setup
165
+
166
+ | Item | Value |
167
+ |---|---|
168
+ | Eval repo | `tests/build_eval_repo.py` (10 fixture apps, markers stripped); ground truth 117 items: 25 BOLA, 34 SQL/NoSQL injection, 10 BFLA, 15 mass assignment, plus 33 SAFE markers |
169
+ | Pipeline | `komagu scan <repo> --no-cache --headless-ai http://localhost:11434/v1 --model <model> --ai-timeout 3600` (`operate/ai/runner.py`, Tier 1 pre-scan + phases 2–6) |
170
+ | Hosting | Ollama 0.40.13 on an Apple M4 Pro, 24 GB RAM, 100% GPU; context 32,768 tokens |
171
+ | Models | `gpt-oss:20b` (MXFP4, reasoning effort default `medium`), `qwen3.5:9b` |
172
+ | Scoring | `tests/score_findings.py --verdicts` (tolerance 3 lines); archived per run in [`assess/lsa/examples/eval-run-4/`](../examples/eval-run-4/) |
173
+
174
+ **This is a floor, not a ceiling.** A 9–20B model on a laptop is much weaker than the models used in
175
+ Runs 1–2. The numbers below say what a fully local deployment achieves today, not what the prompts can
176
+ do with a stronger model.
177
+
178
+ **Not comparable with Run 3.** Run 3 does not record its model, and the archived Run 1 / Run 2 / Qwen
179
+ numbers were produced through agent runtimes that can open files, not through the headless runner.
180
+ The BOLA / injection columns below are therefore a new baseline for the headless harness, not a
181
+ regression check against Run 3.
182
+
183
+ ### Harness fixes found by this run
184
+
185
+ Measuring with real models exposed defects in the headless runner that inflated or lost results.
186
+ They were fixed before the numbers were trusted, in two iterations (the limit set in #286):
187
+
188
+ | Iteration | Fix | Effect seen |
189
+ |---|---|---|
190
+ | 0 → 1 | Verifier no longer marks a whole batch `confirmed` when its reply cannot be parsed (it used to, with a made-up "consensus" note); such findings stay `unverified` | Results stopped being inflated by broken replies |
191
+ | 0 → 1 | A failed call (timeout) is recorded per batch in `ai-run-stats.json` instead of aborting the run | A 25-candidate injection batch took > 15 min and used to kill the run |
192
+ | 0 → 1 | Phase 4c gets the same JSON output template as 4a / 4b; sanitizer joins list `impact` values and moves a verdict written into `subtype` to `status`; unparseable replies saved to `OUT/ai-raw/` | Mass assignment findings stopped failing schema validation |
193
+ | 1 → 2 | Verifier now receives the code it judges (finding site, file header, evidence lines, server-side includes) and the auth model, and may only cite controls that are visible; otherwise `likely` + `code_not_visible` | In iteration 1 it rejected 10 of 11 real BFLA findings by inventing an admin base class and a role check in a login-only `auth.php` |
194
+ | 1 → 2 | Verifier batches of 5 instead of 10; sanitizer fills a `null` evidence line and a missing `status` | No verifier parse failures in iteration 2 |
195
+
196
+ ### Results after verification
197
+
198
+ "Marker" columns use the normal location match. "Verdict" columns (BFLA / mass assignment only) count a
199
+ truth item as found when the privilege auditor judged the route or candidate that contains it
200
+ `vulnerable` or `needs_review`, whatever path and line the model then wrote into the finding.
201
+
202
+ | Run | BOLA | Injection | BFLA marker | Mass marker | BFLA verdict | Mass verdict | FP on safe code | Precision | Rejected | Unverified | Parse fail | Min · calls · tokens |
203
+ |---|---|---|---|---|---|---|---|---|---|---|---|---|
204
+ | iter 0, gpt-oss A | 19/25 | 20/34 | 8/10 | 2/15 | 10/10 | 12/15 | 0 | 0.689 | 10 | 11 | 3 | 81 · 22 · 311K |
205
+ | iter 1, gpt-oss A | 21/25 | 34/34 | 1/10 | 2/15 | 10/10 | 14/15 | 0 | 0.765 | 19 | 17 | 2 | 75 · 21 · 288K |
206
+ | iter 1, gpt-oss B | 19/25 | 28/34 | 5/10 | 5/15 | 10/10 | 14/15 | 1 | 0.667 | 4 | 8 | 3 | 80 · 22 · 273K |
207
+ | iter 1, qwen3.5 (incomplete) | 23/25 | 33/34 | 0/10 | 0/15 | 0/10 | 8/15 | 1 | 0.778 | 0 | 52 | — | stopped after 2 h 50 min |
208
+ | **iter 2, gpt-oss A** | **24/25** | **34/34** | **8/10** | **6/15** | **10/10** | **10/15** | **0** | 0.729 | 1 | 0 | 0 | 57 · 32 · 309K |
209
+ | **iter 2, gpt-oss B** | **11/25** | **32/34** | **5/10** | **4/15** | **10/10** | **9/15** | **0** | 0.693 | 1 | 0 | 1 | 56 · 29 · 318K |
210
+
211
+ Final harness = iteration 2. Stated plainly for `gpt-oss:20b` on it:
212
+
213
+ - **BFLA:** the auditor flagged the right route for **10 / 10** BFLA markers in both runs, with no SAFE-BFLA
214
+ route flagged. Only **8 / 10 and 5 / 10** survive as correctly located findings, because the model
215
+ writes wrong or non-existent paths into the finding (`dotnet-mvc/Controllers/AdminController.cs`,
216
+ `routes.js`).
217
+ - **Mass assignment:** **10 / 15 and 9 / 15** at verdict level, **6 / 15 and 4 / 15** as located findings.
218
+ Five candidates in .NET (`CustomerController.cs`) and Java (`AccountController.java`,
219
+ `ProfileServlet.java`) were judged `safe` in both runs: real model misses.
220
+ - **False positives on SAFE markers:** **0** in both iteration-2 runs, for all four categories.
221
+ - **BOLA / injection:** 24 / 25 and 34 / 34 in run A. In run B, 11 of its 14 missed BOLA items sit in
222
+ one batch whose reply was empty (`routes-001`: `gpt-oss` returned no content, so 15 routes got no
223
+ verdict); the other 3 are real misses (`perl-cgi/cgi-bin/order.cgi:19` is missed in both runs).
224
+ - **Run-to-run variance is large.** Two runs with the same model, harness and temperature 0.1 differ by
225
+ up to 13 BOLA items and 3 BFLA findings. Single-run numbers from a local model should not be quoted
226
+ as product capability.
227
+ - **Precision ~0.7** is measured against markers only. Of the ~25 unmatched findings per run, most are BOLA
228
+ findings in files that carry BFLA / mass-assignment markers but no BOLA markers
229
+ (`CustomerController.cs`, `AccountController.java`, `AdminController.cs`), plus the misplaced BFLA /
230
+ mass findings above. They have not been triaged by a person and are not counted as false positives.
231
+
232
+ **qwen3.5:9b** could not be measured on this machine: with a 32K context the MLX runner used 22 GB of the
233
+ 24 GB RAM, the system swapped (29.7 / 30 GB), and two calls hit the 1-hour timeout (the privilege batch
234
+ holding all BFLA routes, and a verifier batch). The run was stopped after 2 h 50 min with 52 findings
235
+ unverified. Its partial output is archived as `iter1-qwen-a-incomplete/` and says nothing about the
236
+ model's ability.
237
+
238
+ ### Known gaps (follow-up: #319)
239
+
240
+ 1. **Misplaced findings.** Take `file` / `line` of BFLA and mass assignment findings from the Tier 1 route
241
+ or candidate the auditor judged (`route_id` / `candidate_id`), not from the model's text. Expected to
242
+ close most of the gap between the marker and verdict columns.
243
+ 2. **Empty replies.** Retry a batch once when the reply is empty or unparseable (seen with `gpt-oss`).
244
+ 3. **Mass assignment on .NET / Java binding.** Review `knowledge/mass-assignment-patterns.md` for
245
+ `UpdateModel` / `TryUpdateModel` and Spring `@ModelAttribute` binding into entities.
246
+ 4. **Re-run qwen3.5:9b** with 16K context or on a machine with more memory.
247
+ 5. **Triage unmatched BOLA findings** and add markers to the fixtures where they are real.
248
+
249
+ ---
250
+
251
+ ## Run 5 — 2026-10-31, kit 0.3.1 (Run 4 follow-up: anchored locations, retries, model context)
252
+
253
+ Issue #319. Same eval repo, ground truth, hosting and model as Run 4 (`gpt-oss:20b`, Ollama 0.40.13,
254
+ M4 Pro 24 GB, 32K context, local only). Outputs are archived in
255
+ [`assess/lsa/examples/eval-run-5/`](../examples/eval-run-5/).
256
+
257
+ ### Changes since Run 4 iteration 2
258
+
259
+ | Change | Why (from Run 4) |
260
+ |---|---|
261
+ | Finding `file` / `line` come from the Tier 1 route handler span or candidate the finding references (`operate/ai/anchoring.py`); evidence is re-pointed or dropped; unknown references get `confidence: low` | The auditor judged the right route for 10 / 10 BFLA markers, but wrote paths such as `routes.js`, `app.py`, `dotnet-mvc/...` |
262
+ | An empty or unparseable batch reply is retried once with a JSON-only reminder | One empty reply cost 15 BOLA routes |
263
+ | Each mass assignment candidate carries the bound model's definition (same app and language); an unknown model means `needs_review`, never `safe` (prompt `35-privilege-auditor.md` and the headless 4c prompt) | Five .NET / Java candidates were judged `safe` in both runs because the auditor never saw `Account.java` (`role`, `balance`) |
264
+
265
+ ### Results after verification
266
+
267
+ | Run | BOLA | Injection | BFLA marker | Mass marker | BFLA verdict | Mass verdict | FP on safe code | Precision | Rejected | Unverified | Retries · parse fail | Min · calls · tokens |
268
+ |---|---|---|---|---|---|---|---|---|---|---|---|---|
269
+ | Run 4 iter 2, A | 24/25 | 34/34 | 8/10 | 6/15 | 10/10 | 10/15 | 0 | 0.729 | 1 | 0 | — · 0 | 57 · 32 · 309K |
270
+ | Run 4 iter 2, B | 11/25 | 32/34 | 5/10 | 4/15 | 10/10 | 9/15 | 0 | 0.693 | 1 | 0 | — · 1 | 56 · 29 · 318K |
271
+ | **Run 5, A** | **24/25** | **34/34** | **10/10** | **10/15** | **10/10** | **15/15** | **0** | **0.818** | 2 | 0 | 0 · 0 | 83 · 32 · 328K |
272
+ | **Run 5, B** | **12/25** | **32/34** | **9/10** | **14/15** | **10/10** | **15/15** | **0** | **0.779** | 3 | 0 | 1 · 1 | 86 · 31 · 369K |
273
+
274
+ - **BFLA:** 10 / 10 and 9 / 10 as located findings (from 8 and 5). Every anchored finding pointed at a
275
+ known route or candidate (0 unanchored).
276
+ - **Mass assignment:** the auditor flagged **15 / 15** candidates in both runs (from 10 and 9), including the
277
+ five .NET / Java bindings. As located findings 10 / 15 and 14 / 15: in run A the model gave a
278
+ `vulnerable` verdict for four candidates (`php-plain/profile.php` ×2, `CustomerController.cs` ×2)
279
+ without writing a finding for them.
280
+ - **`php-plain/register.php:4` (MASS marker)** was rejected by the verifier in both runs: `extract($_POST)`
281
+ is followed by an INSERT of only `email` and `name`. The argument is sound for this fixture as written,
282
+ so the marker should be reviewed rather than counted as a verifier error.
283
+ - **BOLA in run B (12 / 25)** again lost batch `routes-001` (15 routes). This time the reply was not empty
284
+ but invalid JSON: the model put code with unescaped `"` into `recommendation.code_before`; the retry
285
+ failed the same way. The same batch failed in Run 4 run B.
286
+ - **No false positives** on SAFE markers in either run; precision rose to 0.82 / 0.78.
287
+ - Runs are ~45% slower than Run 4 iteration 2 (83–86 min): the privilege and verifier prompts are longer
288
+ and there are more findings to verify.
289
+
290
+ ### Unmatched BOLA findings (triage, not yet reflected in fixtures)
291
+
292
+ About 20 BOLA findings per run sit in files with no BOLA marker. By route:
293
+
294
+ | Route(s) | Assessment |
295
+ |---|---|
296
+ | `Customer/EditName`, `account/rename`, `python-flask` `/users/<uid>/name`, `account/profile` | Likely **real BOLA** not marked: the record ID comes from the request and is not bound to the caller |
297
+ | `Customer/Edit`, `Customer/Refresh`, `account/save`, `account/api`, `/users/<uid>` | Mass-assignment fixtures that also take the ID from the request: plausibly BOLA as well |
298
+ | `Admin/DeleteUser`, `Admin/Export`, `admin/users/{id}/delete`, `delete_user.php`, `promote.php`, `manage_users.php` | **BFLA routes also reported as BOLA**: same flaw seen from a second category; duplicate rather than wrong |
299
+
300
+ Fixture markers were left unchanged so Run 4 and Run 5 share the same ground truth.
301
+
302
+ ### Remaining gaps
303
+
304
+ 1. **Invalid JSON from code fields:** drop `code_before` / `code_after` from the 4a / 4b output templates
305
+ (the sanitizer already reduces `recommendation` to a string), or repair unescaped quotes before parsing.
306
+ 2. **Verdict without finding:** write a minimal finding for every `vulnerable` / `needs_review` verdict
307
+ the model did not turn into one.
308
+ 3. **Fixtures:** review the `register.php` MASS marker; add BOLA markers for the routes judged real above.
309
+ 4. **qwen3.5:9b** still unmeasured: 32K context does not fit in 24 GB next to the OS; needs 16K context or
310
+ a larger machine.
@@ -7,13 +7,20 @@ A vulnerable truth item is found (TP) when an active finding (status != rejected
7
7
  same category (sql_injection and nosql_injection are treated as one group), and the truth
8
8
  line is within --tolerance lines of the finding location or of any evidence line in the same file.
9
9
  A safe truth item hit by an active finding counts as a false positive (FP-safe).
10
+
11
+ --verdicts adds a verdict-level score for bfla and mass_assignment: a truth item counts as found when
12
+ the privilege auditor judged the route or candidate that contains it "vulnerable" or "needs_review",
13
+ whatever file and line the model then wrote into the finding. Routes cover their handler line up to
14
+ the next inventoried route in the same file; mass assignment candidates match their exact line.
10
15
  """
11
16
  from __future__ import annotations
12
17
 
13
18
  import argparse
19
+ import glob
14
20
  import json
15
21
  import sys
16
22
  from pathlib import Path
23
+ from typing import Dict, List, Optional, Tuple
17
24
 
18
25
 
19
26
  def group(cat: str) -> str:
@@ -32,11 +39,60 @@ def hits(finding: dict, item: dict, tol: int) -> bool:
32
39
  return False
33
40
 
34
41
 
42
+ FLAGGED = ("vulnerable", "needs_review")
43
+
44
+
45
+ def _same_file(a: str, b: str) -> bool:
46
+ return bool(a) and bool(b) and (a.endswith(b) or b.endswith(a))
47
+
48
+
49
+ def _route_for(item: dict, routes: List[Tuple[str, int, str]]) -> Optional[str]:
50
+ """route_id whose handler span (handler line .. next route in the file) contains the truth line."""
51
+ in_file = sorted((line, rid) for f, line, rid in routes if _same_file(f, item["file"]) and line <= item["line"])
52
+ return in_file[-1][1] if in_file else None
53
+
54
+
55
+ def verdict_scores(out: Path, truth: List[dict]) -> Dict[str, dict]:
56
+ routes: List[Tuple[str, int, str]] = []
57
+ for p in glob.glob(str(out / "routes" / "*.json")):
58
+ for r in json.loads(Path(p).read_text()).get("routes", []):
59
+ h = r.get("handler") or {}
60
+ if h.get("file") and isinstance(h.get("line"), int):
61
+ routes.append((h["file"], h["line"], r.get("route_id")))
62
+ cands_path = out / "mass-assignment.candidates.json"
63
+ cands = json.loads(cands_path.read_text()).get("candidates", []) if cands_path.is_file() else []
64
+ route_v: Dict[str, str] = {}
65
+ cand_v: Dict[str, str] = {}
66
+ for p in glob.glob(str(out / "findings" / "priv-*.json")):
67
+ doc = json.loads(Path(p).read_text())
68
+ route_v.update({v.get("route_id"): v.get("verdict") for v in doc.get("privilege_route_verdicts", []) if isinstance(v, dict)})
69
+ cand_v.update({v.get("candidate_id"): v.get("verdict") for v in doc.get("candidate_verdicts", []) if isinstance(v, dict)})
70
+
71
+ def flagged(item: dict) -> bool:
72
+ if item["category"] == "bfla":
73
+ return route_v.get(_route_for(item, routes)) in FLAGGED
74
+ return any(_same_file(c.get("file", ""), item["file"]) and c.get("line") == item["line"]
75
+ and cand_v.get(c.get("candidate_id")) in FLAGGED for c in cands)
76
+
77
+ rows = {}
78
+ for cat in ("bfla", "mass_assignment"):
79
+ vul = [t for t in truth if t["category"] == cat and t["vulnerable"]]
80
+ safe = [t for t in truth if t["category"] == cat and not t["vulnerable"]]
81
+ if not vul:
82
+ continue
83
+ found = [t for t in vul if flagged(t)]
84
+ rows[cat] = {"truth_vulnerable": len(vul), "found": len(found), "recall": round(len(found) / len(vul), 3),
85
+ "flagged_safe": [f"{t['file']}:{t['line']}" for t in safe if flagged(t)],
86
+ "missed": [f"{t['file']}:{t['line']}" for t in vul if t not in found]}
87
+ return rows
88
+
89
+
35
90
  def main() -> int:
36
91
  ap = argparse.ArgumentParser(description=__doc__)
37
92
  ap.add_argument("ground_truth")
38
93
  ap.add_argument("out")
39
94
  ap.add_argument("--tolerance", type=int, default=3)
95
+ ap.add_argument("--verdicts", action="store_true", help="also score bfla / mass_assignment by auditor verdicts")
40
96
  a = ap.parse_args()
41
97
  truth = json.loads(Path(a.ground_truth).read_text())["items"]
42
98
  # fixture paths are relative to tests/fixtures; eval repo keeps the same layout
@@ -69,6 +125,8 @@ def main() -> int:
69
125
  report = {"per_category": rows, "active_findings": len(g_findings), "matched_findings": len(matched_findings),
70
126
  "precision": precision, "unmatched_findings": unmatched,
71
127
  "needs_review_not_scored": needs_review}
128
+ if a.verdicts:
129
+ report["verdict_level"] = verdict_scores(Path(a.out), truth)
72
130
  print(json.dumps(report, indent=2))
73
131
  return 0
74
132
 
@@ -93,6 +93,17 @@ class TestCodexAdapter(unittest.TestCase):
93
93
  self.assertEqual(text.count(AGENTS_MD_BEGIN), 1)
94
94
  self.assertEqual(text.count(AGENTS_MD_END), 1)
95
95
 
96
+ def test_pre_2_0_markers_are_replaced_in_place(self) -> None:
97
+ old = (f"# Notes\n\n{build_adapters.LEGACY_AGENTS_MD_BEGIN}\nold block\n"
98
+ f"{build_adapters.LEGACY_AGENTS_MD_END}\n\n# Footer\n")
99
+ (self.dest / "AGENTS.md").write_text(old, encoding="utf-8")
100
+ build("codex", self.dest)
101
+ text = (self.dest / "AGENTS.md").read_text(encoding="utf-8")
102
+ self.assertNotIn("komainu-lsa", text)
103
+ self.assertNotIn("old block", text)
104
+ self.assertEqual(text.count(AGENTS_MD_BEGIN), 1)
105
+ self.assertTrue(text.startswith("# Notes\n\n") and text.endswith("# Footer\n"))
106
+
96
107
  def test_unbalanced_markers_fail_before_touching_workspace(self) -> None:
97
108
  user = f"# Notes\n{AGENTS_MD_BEGIN}\nimportant user text\n"
98
109
  (self.dest / "AGENTS.md").write_text(user, encoding="utf-8")
@@ -187,7 +198,7 @@ class TestGenericAdapterGuard(unittest.TestCase):
187
198
 
188
199
  def test_contract_carries_generator_marker(self) -> None:
189
200
  build("generic", self.dest)
190
- self.assertEqual(json.loads(self.contract.read_text(encoding="utf-8"))["generator"], "komainu-lsa")
201
+ self.assertEqual(json.loads(self.contract.read_text(encoding="utf-8"))["generator"], "komagu-lsa")
191
202
 
192
203
  def test_rebuild_refreshes_generated_contract(self) -> None:
193
204
  build("generic", self.dest)
@@ -197,6 +208,14 @@ class TestGenericAdapterGuard(unittest.TestCase):
197
208
  build("generic", self.dest)
198
209
  self.assertEqual(json.loads(self.contract.read_text(encoding="utf-8"))["kit_version"], build_adapters.VERSION)
199
210
 
211
+ def test_rebuild_accepts_pre_2_0_generator(self) -> None:
212
+ build("generic", self.dest)
213
+ data = json.loads(self.contract.read_text(encoding="utf-8"))
214
+ data["generator"] = "komainu-lsa"
215
+ self.contract.write_text(json.dumps(data), encoding="utf-8")
216
+ build("generic", self.dest)
217
+ self.assertEqual(json.loads(self.contract.read_text(encoding="utf-8"))["generator"], "komagu-lsa")
218
+
200
219
  def test_foreign_contract_fails_before_touching_workspace(self) -> None:
201
220
  self.contract.write_text('{"name": "my-project-contract"}', encoding="utf-8")
202
221
  with self.assertRaisesRegex(ValueError, "contract.json"):
@@ -211,7 +230,7 @@ class TestGenericAdapterGuard(unittest.TestCase):
211
230
  self.assert_untouched("contract.json")
212
231
 
213
232
  def test_non_object_contract_fails(self) -> None:
214
- self.contract.write_text('["komainu-lsa"]', encoding="utf-8")
233
+ self.contract.write_text('["komagu-lsa"]', encoding="utf-8")
215
234
  with self.assertRaises(ValueError):
216
235
  build("generic", self.dest)
217
236
 
@@ -198,6 +198,14 @@ class TestLoadBaseline(unittest.TestCase):
198
198
  entries = load_baseline(self.dir / "prev.sarif")
199
199
  self.assertEqual(entries["kfp1:" + "e" * 64]["state"], "open")
200
200
 
201
+ def test_loads_pre_2_0_sarif_fingerprint_key(self) -> None:
202
+ sarif = to_sarif({"findings": [{**_finding(), "fingerprint": "kfp1:" + "f" * 64}]})
203
+ for result in sarif["runs"][0]["results"]:
204
+ fps = result["partialFingerprints"]
205
+ fps["komainu/v1"] = fps.pop("komagu/v1")
206
+ write_json(self.dir / "old.sarif", sarif)
207
+ self.assertIn("kfp1:" + "f" * 64, load_baseline(self.dir / "old.sarif"))
208
+
201
209
  def test_invalid_file_raises(self) -> None:
202
210
  (self.dir / "bad.json").write_text("{not json", encoding="utf-8")
203
211
  with self.assertRaises(BaselineError):
@@ -156,9 +156,9 @@ class TestLsaSarifExport(unittest.TestCase):
156
156
  by_rule = {r["ruleId"]: r for r in self.run["results"]}
157
157
  mass_fp = by_rule["lsa/mass_assignment/whole_object_binding"]["partialFingerprints"]
158
158
  bfla_fp = by_rule["lsa/bfla/missing_role_check"]["partialFingerprints"]
159
- self.assertEqual(mass_fp["komainu/v1"], FINGERPRINT)
159
+ self.assertEqual(mass_fp["komagu/v1"], FINGERPRINT)
160
160
  self.assertEqual(mass_fp["lsaFindingId"], "MASS-R-0002-1")
161
- self.assertNotIn("komainu/v1", bfla_fp)
161
+ self.assertNotIn("komagu/v1", bfla_fp)
162
162
 
163
163
 
164
164
  class TestOperateSarifExport(unittest.TestCase):
@@ -166,16 +166,16 @@ class TestOperateSarifExport(unittest.TestCase):
166
166
  sarif = convert_findings_to_sarif([_finding(cwe=285), {**_mass_assignment(), "cwe": 915}])
167
167
  run = sarif["runs"][0]
168
168
  rule_ids = {r["id"] for r in run["tool"]["driver"]["rules"]}
169
- self.assertEqual(rule_ids, {"KOMAINU-BFLA-CWE285", "KOMAINU-MASS-CWE915"})
170
- mass = next(r for r in run["results"] if r["ruleId"] == "KOMAINU-MASS-CWE915")
171
- self.assertEqual(mass["partialFingerprints"], {"komainu/v1": FINGERPRINT})
172
- bfla = next(r for r in run["results"] if r["ruleId"] == "KOMAINU-BFLA-CWE285")
169
+ self.assertEqual(rule_ids, {"KOMAGU-BFLA-CWE285", "KOMAGU-MASS-CWE915"})
170
+ mass = next(r for r in run["results"] if r["ruleId"] == "KOMAGU-MASS-CWE915")
171
+ self.assertEqual(mass["partialFingerprints"], {"komagu/v1": FINGERPRINT})
172
+ bfla = next(r for r in run["results"] if r["ruleId"] == "KOMAGU-BFLA-CWE285")
173
173
  self.assertNotIn("partialFingerprints", bfla)
174
174
 
175
175
  def test_default_cwe_for_new_categories(self) -> None:
176
176
  sarif = convert_findings_to_sarif([_finding(cwe=None), {**_mass_assignment(), "cwe": None}])
177
177
  rule_ids = {r["id"] for r in sarif["runs"][0]["tool"]["driver"]["rules"]}
178
- self.assertEqual(rule_ids, {"KOMAINU-BFLA-CWE285", "KOMAINU-MASS-CWE915"})
178
+ self.assertEqual(rule_ids, {"KOMAGU-BFLA-CWE285", "KOMAGU-MASS-CWE915"})
179
179
 
180
180
 
181
181
  class TestSummaryAndReports(unittest.TestCase):
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """Keeps the archived Evaluation Run 4 numbers (Issue #286) in step with the scorer and EVAL-RESULTS.md."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ TESTS_DIR = Path(__file__).resolve().parent
13
+ LSA_ROOT = TESTS_DIR.parent
14
+ EVAL_RUN_4_DIR = LSA_ROOT / "examples" / "eval-run-4"
15
+ GROUND_TRUTH = EVAL_RUN_4_DIR / "ground-truth.json"
16
+ RUNS = ("iter0-gptoss-a", "iter1-gptoss-a", "iter1-gptoss-b", "iter1-qwen-a-incomplete", "iter2-gptoss-a", "iter2-gptoss-b")
17
+ # (BOLA, injection, BFLA, mass assignment) found by location, then BFLA / mass found by auditor verdict
18
+ EXPECTED = {
19
+ "iter2-gptoss-a": {"marker": (24, 34, 8, 6), "verdict": (10, 10)},
20
+ "iter2-gptoss-b": {"marker": (11, 32, 5, 4), "verdict": (10, 9)},
21
+ }
22
+
23
+
24
+ def score(run: str) -> dict:
25
+ proc = subprocess.run(
26
+ [sys.executable, str(TESTS_DIR / "score_findings.py"), "--verdicts", str(GROUND_TRUTH), str(EVAL_RUN_4_DIR / run)],
27
+ capture_output=True, text=True, check=True,
28
+ )
29
+ return json.loads(proc.stdout)
30
+
31
+
32
+ class TestEvaluationRun4(unittest.TestCase):
33
+ def test_artifacts_exist(self) -> None:
34
+ self.assertTrue(GROUND_TRUTH.is_file())
35
+ for run in RUNS:
36
+ for name in ("findings.json", "coverage.json", "score.json"):
37
+ self.assertTrue((EVAL_RUN_4_DIR / run / name).is_file(), f"{run}/{name}")
38
+
39
+ def test_archived_scores_match_the_scorer(self) -> None:
40
+ for run in RUNS:
41
+ with self.subTest(run=run):
42
+ archived = json.loads((EVAL_RUN_4_DIR / run / "score.json").read_text(encoding="utf-8"))
43
+ self.assertEqual(score(run), archived)
44
+
45
+ def test_final_harness_numbers_match_eval_results(self) -> None:
46
+ for run, want in EXPECTED.items():
47
+ with self.subTest(run=run):
48
+ report = score(run)
49
+ pc, vl = report["per_category"], report["verdict_level"]
50
+ got = tuple(pc[c]["found"] for c in ("bola", "injection", "bfla", "mass_assignment"))
51
+ self.assertEqual(got, want["marker"])
52
+ self.assertEqual((vl["bfla"]["found"], vl["mass_assignment"]["found"]), want["verdict"])
53
+
54
+ def test_final_harness_has_no_false_positive_on_safe_code(self) -> None:
55
+ for run in EXPECTED:
56
+ with self.subTest(run=run):
57
+ report = score(run)
58
+ for cat, row in report["per_category"].items():
59
+ self.assertEqual(row["false_positive_on_safe_code"], [], cat)
60
+ for cat, row in report["verdict_level"].items():
61
+ self.assertEqual(row["flagged_safe"], [], cat)
62
+
63
+ def test_final_harness_records_run_stats(self) -> None:
64
+ for run in EXPECTED:
65
+ with self.subTest(run=run):
66
+ stats = json.loads((EVAL_RUN_4_DIR / run / "ai-run-stats.json").read_text(encoding="utf-8"))
67
+ self.assertEqual(stats["model"], "gpt-oss:20b")
68
+ self.assertGreater(stats["totals"]["prompt_tokens"], 0)
69
+ self.assertEqual(stats["call_errors"], {})
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """Keeps the archived Evaluation Run 5 numbers (Issue #319) in step with the scorer and EVAL-RESULTS.md."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import unittest
10
+ from pathlib import Path
11
+
12
+ TESTS_DIR = Path(__file__).resolve().parent
13
+ LSA_ROOT = TESTS_DIR.parent
14
+ EVAL_RUN_5_DIR = LSA_ROOT / "examples" / "eval-run-5"
15
+ GROUND_TRUTH = EVAL_RUN_5_DIR / "ground-truth.json"
16
+ RUNS = ("gptoss-a", "gptoss-b")
17
+ # (BOLA, injection, BFLA, mass assignment) found by location, then BFLA / mass found by auditor verdict
18
+ EXPECTED = {
19
+ "gptoss-a": {"marker": (24, 34, 10, 10), "verdict": (10, 15)},
20
+ "gptoss-b": {"marker": (12, 32, 9, 14), "verdict": (10, 15)},
21
+ }
22
+
23
+ def score(run: str) -> dict:
24
+ proc = subprocess.run(
25
+ [sys.executable, str(TESTS_DIR / "score_findings.py"), "--verdicts", str(GROUND_TRUTH), str(EVAL_RUN_5_DIR / run)],
26
+ capture_output=True, text=True, check=True,
27
+ )
28
+ return json.loads(proc.stdout)
29
+
30
+
31
+ class TestEvaluationRun5(unittest.TestCase):
32
+ def test_artifacts_exist(self) -> None:
33
+ self.assertTrue(GROUND_TRUTH.is_file())
34
+ for run in RUNS:
35
+ for name in ("findings.json", "coverage.json", "score.json"):
36
+ self.assertTrue((EVAL_RUN_5_DIR / run / name).is_file(), f"{run}/{name}")
37
+
38
+ def test_archived_scores_match_the_scorer(self) -> None:
39
+ for run in RUNS:
40
+ with self.subTest(run=run):
41
+ archived = json.loads((EVAL_RUN_5_DIR / run / "score.json").read_text(encoding="utf-8"))
42
+ self.assertEqual(score(run), archived)
43
+
44
+ def test_final_harness_numbers_match_eval_results(self) -> None:
45
+ for run, want in EXPECTED.items():
46
+ with self.subTest(run=run):
47
+ report = score(run)
48
+ pc, vl = report["per_category"], report["verdict_level"]
49
+ got = tuple(pc[c]["found"] for c in ("bola", "injection", "bfla", "mass_assignment"))
50
+ self.assertEqual(got, want["marker"])
51
+ self.assertEqual((vl["bfla"]["found"], vl["mass_assignment"]["found"]), want["verdict"])
52
+
53
+ def test_final_harness_has_no_false_positive_on_safe_code(self) -> None:
54
+ for run in EXPECTED:
55
+ with self.subTest(run=run):
56
+ report = score(run)
57
+ for cat, row in report["per_category"].items():
58
+ self.assertEqual(row["false_positive_on_safe_code"], [], cat)
59
+ for cat, row in report["verdict_level"].items():
60
+ self.assertEqual(row["flagged_safe"], [], cat)
61
+
62
+ def test_final_harness_records_run_stats(self) -> None:
63
+ for run in EXPECTED:
64
+ with self.subTest(run=run):
65
+ stats = json.loads((EVAL_RUN_5_DIR / run / "ai-run-stats.json").read_text(encoding="utf-8"))
66
+ self.assertEqual(stats["model"], "gpt-oss:20b")
67
+ self.assertGreater(stats["totals"]["prompt_tokens"], 0)
68
+ self.assertEqual(stats["call_errors"], {})
69
+ self.assertEqual(stats["anchoring"]["unanchored"], [])
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -242,6 +242,36 @@ class TestScoring(unittest.TestCase):
242
242
  self.assertEqual(report["per_category"]["mass_assignment"]["recall"], 0.0)
243
243
  self.assertNotIn("bola", report["per_category"])
244
244
 
245
+ def test_verdict_level_ignores_misplaced_findings(self) -> None:
246
+ """--verdicts credits the auditor's route / candidate verdict even when the finding has a wrong path (#286)."""
247
+ with tempfile.TemporaryDirectory() as tmp:
248
+ truth = Path(tmp) / "gt.json"
249
+ write_json(truth, {"items": [
250
+ {"file": "admin.js", "line": 9, "category": "bfla", "vulnerable": True, "label": "BFLA"},
251
+ {"file": "admin.js", "line": 13, "category": "bfla", "vulnerable": False, "label": "SAFE-BFLA"},
252
+ {"file": "p.php", "line": 6, "category": "mass_assignment", "vulnerable": True, "label": "MASS"},
253
+ {"file": "p.php", "line": 9, "category": "mass_assignment", "vulnerable": False, "label": "SAFE-MASS"},
254
+ ]})
255
+ out = Path(tmp) / "out"
256
+ write_json(out / "findings.json", {"findings": [
257
+ {"finding_id": "BFLA-R-1-1", "category": "bfla", "status": "confirmed",
258
+ "location": {"file": "routes.js", "start_line": 1}, "evidence": []}]})
259
+ write_json(out / "routes" / "routes-001.json", {"routes": [
260
+ {"route_id": "R-1", "handler": {"file": "admin.js", "line": 8}},
261
+ {"route_id": "R-2", "handler": {"file": "admin.js", "line": 12}}]})
262
+ write_json(out / "mass-assignment.candidates.json", {"candidates": [
263
+ {"candidate_id": "M-1", "file": "p.php", "line": 6}]})
264
+ write_json(out / "findings" / "priv-privilege-001.json", {
265
+ "privilege_route_verdicts": [{"route_id": "R-1", "verdict": "vulnerable"}],
266
+ "candidate_verdicts": [{"candidate_id": "M-1", "verdict": "needs_review"}], "findings": []})
267
+ res = subprocess.run([sys.executable, str(SCORE), "--verdicts", str(truth), str(out)], capture_output=True, text=True)
268
+ report = json.loads(res.stdout)
269
+ self.assertEqual(report["per_category"]["bfla"]["found"], 0)
270
+ self.assertEqual(report["verdict_level"]["bfla"], {"truth_vulnerable": 1, "found": 1, "recall": 1.0,
271
+ "flagged_safe": [], "missed": []})
272
+ self.assertEqual(report["verdict_level"]["mass_assignment"]["found"], 1)
273
+ self.assertEqual(report["verdict_level"]["mass_assignment"]["flagged_safe"], [])
274
+
245
275
 
246
276
  if __name__ == "__main__":
247
277
  unittest.main()