@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,130 @@
1
+ """Anchor model-written finding locations to the Tier 1 routes and candidates they refer to (#319).
2
+
3
+ Small local models judge the right route or candidate but often write a wrong or non-existent path
4
+ into the finding (Evaluation Run 4: `routes.js`, `app.py`, `dotnet-mvc/...`). Tier 1 already knows
5
+ where every route handler and query / binding candidate is, so the location comes from there.
6
+
7
+ Rules:
8
+ - route findings (`route_id`): the file is the route handler's file; the model's line is kept when
9
+ it falls inside the handler span (handler line up to the next route in the same file, or the end
10
+ of the file), otherwise the handler line is used.
11
+ - candidate findings (`candidate_id`): file and line are the candidate's.
12
+ - evidence entries are re-pointed the same way; entries whose file does not exist and cannot be
13
+ re-pointed are dropped.
14
+ - findings with an unknown `route_id` / `candidate_id` keep their location, get `confidence: low`
15
+ and are reported as unanchored.
16
+
17
+ Standard-library only (Python 3.8+ compatible).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ CANDIDATE_FILES = ("injection.candidates.json", "mass-assignment.candidates.json")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Anchor:
32
+ file: str
33
+ line: int
34
+ end_line: Optional[int] # last line of the handler span; None for an exact candidate site
35
+
36
+
37
+ @dataclass
38
+ class AnchorReport:
39
+ moved: List[str] = field(default_factory=list)
40
+ unanchored: List[str] = field(default_factory=list)
41
+
42
+
43
+ def _read(path: Path) -> Dict[str, Any]:
44
+ try:
45
+ doc = json.loads(path.read_text(encoding="utf-8"))
46
+ return doc if isinstance(doc, dict) else {}
47
+ except (OSError, ValueError):
48
+ return {}
49
+
50
+
51
+ def _line_count(target_dir: Path, rel_file: str) -> int:
52
+ try:
53
+ return len((target_dir / rel_file).read_text(encoding="utf-8", errors="replace").splitlines())
54
+ except OSError:
55
+ return 0
56
+
57
+
58
+ def load_anchors(out_dir: Path, target_dir: Path) -> Dict[str, Anchor]:
59
+ """route_id / candidate_id -> Anchor, from OUT/routes/*.json and the Tier 1 candidate files."""
60
+ handlers: List[Tuple[str, int, str]] = []
61
+ for path in sorted((out_dir / "routes").glob("*.json")):
62
+ for route in _read(path).get("routes") or []:
63
+ if not isinstance(route, dict):
64
+ continue
65
+ handler = route.get("handler") or {}
66
+ if route.get("route_id") and handler.get("file") and isinstance(handler.get("line"), int):
67
+ handlers.append((handler["file"], handler["line"], route["route_id"]))
68
+
69
+ anchors: Dict[str, Anchor] = {}
70
+ for rel_file, line, route_id in handlers:
71
+ later = sorted(ln for f, ln, _ in handlers if f == rel_file and ln > line)
72
+ end = later[0] - 1 if later else max(_line_count(target_dir, rel_file), line)
73
+ anchors[route_id] = Anchor(rel_file, line, end)
74
+
75
+ for name in CANDIDATE_FILES:
76
+ for cand in _read(out_dir / name).get("candidates") or []:
77
+ if isinstance(cand, dict) and cand.get("candidate_id") and cand.get("file") and isinstance(cand.get("line"), int):
78
+ anchors[cand["candidate_id"]] = Anchor(cand["file"], cand["line"], None)
79
+ return anchors
80
+
81
+
82
+ def _anchored_line(anchor: Anchor, line: Any) -> int:
83
+ if anchor.end_line is None or not isinstance(line, int):
84
+ return anchor.line
85
+ return line if anchor.line <= line <= anchor.end_line else anchor.line
86
+
87
+
88
+ def _repoint_evidence(evidence: Any, anchor: Anchor, target_dir: Path) -> List[Dict[str, Any]]:
89
+ kept = []
90
+ for ev in evidence if isinstance(evidence, list) else []:
91
+ if not isinstance(ev, dict):
92
+ continue
93
+ rel = str(ev.get("file") or "")
94
+ if rel == anchor.file or (rel and (target_dir / rel).is_file()):
95
+ kept.append(ev)
96
+ elif anchor.end_line is not None and isinstance(ev.get("line"), int) and anchor.line <= ev["line"] <= anchor.end_line:
97
+ kept.append({**ev, "file": anchor.file})
98
+ return kept or [{"file": anchor.file, "line": anchor.line, "why": "Location taken from the Tier 1 inventory"}]
99
+
100
+
101
+ def anchor_finding(finding: Dict[str, Any], anchors: Dict[str, Anchor], target_dir: Path) -> Tuple[Dict[str, Any], Optional[bool]]:
102
+ """Return (finding with Tier 1 location, moved?); moved is None when the finding has no known anchor."""
103
+ key = finding.get("route_id") or finding.get("candidate_id")
104
+ anchor = anchors.get(key) if key else None
105
+ if anchor is None:
106
+ return {**finding, "confidence": "low"}, None
107
+ loc = finding.get("location") if isinstance(finding.get("location"), dict) else {}
108
+ # a wrong path with a line inside the handler span is most likely the right line in the wrong file
109
+ new_loc = {**loc, "file": anchor.file, "start_line": _anchored_line(anchor, loc.get("start_line"))}
110
+ if anchor.end_line is None:
111
+ new_loc.pop("end_line", None)
112
+ moved = (loc.get("file"), loc.get("start_line")) != (new_loc["file"], new_loc["start_line"])
113
+ evidence = _repoint_evidence(finding.get("evidence"), anchor, target_dir)
114
+ return {**finding, "location": new_loc, "evidence": evidence}, moved
115
+
116
+
117
+ def anchor_findings(findings: List[Dict[str, Any]], anchors: Dict[str, Anchor], target_dir: Path,
118
+ report: AnchorReport) -> List[Dict[str, Any]]:
119
+ out = []
120
+ for f in findings:
121
+ if not isinstance(f, dict):
122
+ out.append(f)
123
+ continue
124
+ anchored, moved = anchor_finding(f, anchors, target_dir)
125
+ if moved is None:
126
+ report.unanchored.append(str(f.get("finding_id")))
127
+ elif moved:
128
+ report.moved.append(str(f.get("finding_id")))
129
+ out.append(anchored)
130
+ return out
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import json
9
9
  import re
10
+ import time
10
11
  import urllib.error
11
12
  import urllib.request
12
13
  from typing import Any, Dict, List, Optional
@@ -30,6 +31,8 @@ class LLMClient:
30
31
  self.model = model
31
32
  self.api_key = api_key or "ollama"
32
33
  self.timeout = timeout
34
+ # Running totals across chat() calls, read by the runner for ai-run-stats.json
35
+ self.usage: Dict[str, float] = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "seconds": 0.0}
33
36
 
34
37
  def chat(
35
38
  self,
@@ -54,10 +57,12 @@ class LLMClient:
54
57
  }
55
58
 
56
59
  req = urllib.request.Request(self.endpoint, data=data, headers=headers, method="POST")
60
+ started = time.monotonic()
57
61
  try:
58
62
  with urllib.request.urlopen(req, timeout=self.timeout) as resp:
59
63
  body = resp.read().decode("utf-8")
60
64
  res_json = json.loads(body)
65
+ self._record_usage(res_json.get("usage") or {}, time.monotonic() - started)
61
66
  choices = res_json.get("choices", [])
62
67
  if not choices:
63
68
  raise RuntimeError(f"LLM API returned no choices: {body}")
@@ -69,6 +74,14 @@ class LLMClient:
69
74
  except urllib.error.URLError as e:
70
75
  raise RuntimeError(f"Failed to connect to LLM endpoint at {self.endpoint}: {e.reason}") from e
71
76
 
77
+ def _record_usage(self, usage: Dict[str, Any], seconds: float) -> None:
78
+ self.usage = {
79
+ "calls": self.usage["calls"] + 1,
80
+ "prompt_tokens": self.usage["prompt_tokens"] + int(usage.get("prompt_tokens") or 0),
81
+ "completion_tokens": self.usage["completion_tokens"] + int(usage.get("completion_tokens") or 0),
82
+ "seconds": round(self.usage["seconds"] + seconds, 3),
83
+ }
84
+
72
85
  @staticmethod
73
86
  def extract_json(content: str) -> Any:
74
87
  """Extract and parse JSON from Markdown code blocks or raw text string."""
@@ -0,0 +1,105 @@
1
+ """Code context for the headless agents: snippets, server-side includes and bound model classes.
2
+
3
+ Standard-library only (Python 3.8+ compatible).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+
13
+ def _read_snippet(target_dir: Path, rel_file: str, line: int, radius: int = 15) -> str:
14
+ """Read a window of code around target line from the audited repository."""
15
+ full_path = target_dir / rel_file
16
+ if not full_path.is_file():
17
+ return f"[File not found: {rel_file}]"
18
+ try:
19
+ lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines()
20
+ start = max(0, line - radius - 1)
21
+ end = min(len(lines), line + radius)
22
+ numbered = [f"{i + 1:4d} | {lines[i]}" for i in range(start, end)]
23
+ return "\n".join(numbered)
24
+ except Exception as e:
25
+ return f"[Error reading {rel_file}: {e}]"
26
+
27
+
28
+ # Server-side includes whose content a verifier must see to judge a control (PHP, Classic ASP, ColdFusion)
29
+ _INCLUDE_RE = re.compile(
30
+ r"(?:require|include)(?:_once)?\s*\(?\s*['\"]([^'\"]+)['\"]"
31
+ r"|<!--\s*#include\s+(?:file|virtual)\s*=\s*\"([^\"]+)\""
32
+ r"|<cfinclude\s+template\s*=\s*\"([^\"]+)\"",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+
37
+ def _included_files(target_dir: Path, rel_file: str) -> List[str]:
38
+ """Repository-relative paths of files that rel_file includes, if they exist."""
39
+ target_dir = target_dir.resolve()
40
+ path = target_dir / rel_file
41
+ if not path.is_file():
42
+ return []
43
+ text = path.read_text(encoding="utf-8", errors="replace")
44
+ found: List[str] = []
45
+ for m in _INCLUDE_RE.finditer(text):
46
+ ref = next(g for g in m.groups() if g)
47
+ candidate = (path.parent / ref).resolve()
48
+ try:
49
+ rel = str(candidate.relative_to(target_dir))
50
+ except ValueError:
51
+ continue
52
+ if candidate.is_file() and rel not in found:
53
+ found.append(rel)
54
+ return found
55
+
56
+
57
+ _MODEL_EXTENSIONS = (".java", ".cs", ".vb", ".php", ".py", ".js", ".ts", ".rb", ".cfc")
58
+ _NEW_OBJECT_RE = re.compile(r"\bnew\s+([A-Z][A-Za-z0-9_]*)\s*\(")
59
+
60
+
61
+ def _model_source(target_dir: Path, name: Optional[str], near_file: str = "", near_line: int = 1) -> Optional[Dict[str, Any]]:
62
+ """Definition of the bound model class, so the privilege auditor can see its fields (#319).
63
+
64
+ name is the Tier 1 binding target; when it is unknown, the first `new Foo(` near the binding is used.
65
+ """
66
+ if not name and near_file:
67
+ m = _NEW_OBJECT_RE.search(_read_snippet(target_dir, near_file, near_line, radius=4))
68
+ name = m.group(1) if m else None
69
+ if not name or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
70
+ return None
71
+ class_re = re.compile(rf"\bclass\s+{name}\b")
72
+ # same application and language as the binding: a Node route must not pick up a PHP model of the same name
73
+ suffix = Path(near_file).suffix.lower()
74
+ suffixes = (suffix,) if suffix in _MODEL_EXTENSIONS else _MODEL_EXTENSIONS
75
+ parts = Path(near_file).parts
76
+ root = target_dir / parts[0] if len(parts) > 1 and (target_dir / parts[0]).is_dir() else target_dir
77
+ for path in sorted(root.rglob("*")):
78
+ if path.suffix.lower() not in suffixes or not path.is_file():
79
+ continue
80
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
81
+ for i, text in enumerate(lines):
82
+ if class_re.search(text):
83
+ rel = str(path.relative_to(target_dir))
84
+ return {"class": name, "file": rel, "code": _read_snippet(target_dir, rel, i + 16, radius=15)}
85
+ return {"class": name, "file": None, "code": "[definition not found in the repository]"}
86
+
87
+
88
+ def _verification_context(target_dir: Path, finding: Dict[str, Any]) -> Dict[str, Any]:
89
+ """Code the verifier needs: the finding's site, its file header, evidence lines and included files."""
90
+ loc = finding.get("location") or {}
91
+ rel = str(loc.get("file") or "")
92
+ line = int(loc.get("start_line") or 1)
93
+ ctx: Dict[str, Any] = {"location": _read_snippet(target_dir, rel, line, radius=20)}
94
+ if line > 40:
95
+ ctx["file_header"] = _read_snippet(target_dir, rel, 13, radius=12)
96
+ others = []
97
+ for ev in finding.get("evidence") or []:
98
+ if isinstance(ev, dict) and ev.get("file") and ev.get("file") != rel:
99
+ others.append({"file": ev["file"], "code": _read_snippet(target_dir, str(ev["file"]), int(ev.get("line") or 1), radius=6)})
100
+ if others:
101
+ ctx["evidence"] = others[:3]
102
+ includes = [{"file": inc, "code": _read_snippet(target_dir, inc, 30, radius=30)} for inc in _included_files(target_dir, rel)[:3]]
103
+ if includes:
104
+ ctx["included_files"] = includes
105
+ return ctx