@hoanghainh/komagu 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/assess/lsa/examples/eval-run-4/ground-truth.json +823 -0
  4. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/ai-run-stats.json +58 -0
  5. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/coverage.json +85 -0
  6. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-001.json +822 -0
  7. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-002.json +393 -0
  8. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings.json +4477 -0
  9. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/mass-assignment.candidates.json +232 -0
  10. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-001.json +425 -0
  11. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-002.json +333 -0
  12. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-003.json +400 -0
  13. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-004.json +403 -0
  14. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-005.json +398 -0
  15. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-006.json +63 -0
  16. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/score.json +120 -0
  17. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/ai-run-stats.json +55 -0
  18. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/coverage.json +50 -0
  19. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-001.json +613 -0
  20. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-002.json +287 -0
  21. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings.json +4870 -0
  22. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/mass-assignment.candidates.json +232 -0
  23. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-001.json +425 -0
  24. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-002.json +333 -0
  25. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-003.json +400 -0
  26. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-004.json +403 -0
  27. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-005.json +398 -0
  28. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-006.json +63 -0
  29. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/score.json +107 -0
  30. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/ai-run-stats.json +58 -0
  31. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/coverage.json +92 -0
  32. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-001.json +645 -0
  33. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-002.json +222 -0
  34. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings.json +4859 -0
  35. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/mass-assignment.candidates.json +232 -0
  36. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-001.json +425 -0
  37. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-002.json +333 -0
  38. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-003.json +400 -0
  39. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-004.json +403 -0
  40. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-005.json +398 -0
  41. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-006.json +63 -0
  42. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/score.json +121 -0
  43. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/coverage.json +111 -0
  44. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-001.json +6 -0
  45. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-002.json +344 -0
  46. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings.json +3544 -0
  47. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/mass-assignment.candidates.json +232 -0
  48. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-001.json +425 -0
  49. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-002.json +333 -0
  50. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-003.json +400 -0
  51. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-004.json +403 -0
  52. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-005.json +398 -0
  53. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-006.json +63 -0
  54. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/score.json +123 -0
  55. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/ai-run-stats.json +50 -0
  56. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/coverage.json +42 -0
  57. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-001.json +540 -0
  58. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-002.json +238 -0
  59. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.json +4383 -0
  60. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.sarif +8729 -0
  61. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/mass-assignment.candidates.json +232 -0
  62. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.ja.md +1911 -0
  63. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.md +1911 -0
  64. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-001.json +425 -0
  65. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-002.json +333 -0
  66. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-003.json +400 -0
  67. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-004.json +403 -0
  68. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-005.json +398 -0
  69. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-006.json +63 -0
  70. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/score.json +101 -0
  71. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/stack-profile.json +226 -0
  72. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/ai-run-stats.json +54 -0
  73. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/coverage.json +66 -0
  74. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-001.json +440 -0
  75. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-002.json +229 -0
  76. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings.json +3424 -0
  77. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/mass-assignment.candidates.json +232 -0
  78. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-001.json +425 -0
  79. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-002.json +333 -0
  80. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-003.json +400 -0
  81. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-004.json +403 -0
  82. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-005.json +398 -0
  83. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-006.json +63 -0
  84. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/score.json +120 -0
  85. package/assess/lsa/examples/eval-run-5/gptoss-a/ai-run-stats.json +88 -0
  86. package/assess/lsa/examples/eval-run-5/gptoss-a/coverage.json +36 -0
  87. package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-001.json +530 -0
  88. package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-002.json +248 -0
  89. package/assess/lsa/examples/eval-run-5/gptoss-a/findings.json +4766 -0
  90. package/assess/lsa/examples/eval-run-5/gptoss-a/findings.sarif +9326 -0
  91. package/assess/lsa/examples/eval-run-5/gptoss-a/mass-assignment.candidates.json +232 -0
  92. package/assess/lsa/examples/eval-run-5/gptoss-a/report.ja.md +2011 -0
  93. package/assess/lsa/examples/eval-run-5/gptoss-a/report.md +2011 -0
  94. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-001.json +425 -0
  95. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-002.json +333 -0
  96. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-003.json +400 -0
  97. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-004.json +403 -0
  98. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-005.json +398 -0
  99. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-006.json +63 -0
  100. package/assess/lsa/examples/eval-run-5/gptoss-a/score.json +80 -0
  101. package/assess/lsa/examples/eval-run-5/gptoss-a/stack-profile.json +226 -0
  102. package/assess/lsa/examples/eval-run-5/gptoss-b/ai-run-stats.json +83 -0
  103. package/assess/lsa/examples/eval-run-5/gptoss-b/coverage.json +44 -0
  104. package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-001.json +579 -0
  105. package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-002.json +268 -0
  106. package/assess/lsa/examples/eval-run-5/gptoss-b/findings.json +3913 -0
  107. package/assess/lsa/examples/eval-run-5/gptoss-b/mass-assignment.candidates.json +232 -0
  108. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-001.json +425 -0
  109. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-002.json +333 -0
  110. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-003.json +400 -0
  111. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-004.json +403 -0
  112. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-005.json +398 -0
  113. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-006.json +63 -0
  114. package/assess/lsa/examples/eval-run-5/gptoss-b/score.json +94 -0
  115. package/assess/lsa/examples/eval-run-5/ground-truth.json +823 -0
  116. package/assess/lsa/knowledge/injection-patterns.md +8 -1
  117. package/assess/lsa/knowledge/stacks/nextjs.md +62 -0
  118. package/assess/lsa/prompts/35-privilege-auditor.md +3 -1
  119. package/assess/lsa/prompts/_shared-rules.md +1 -0
  120. package/assess/lsa/scripts/detect_stack.py +5 -1
  121. package/assess/lsa/scripts/find_injection.py +34 -5
  122. package/assess/lsa/scripts/lsa_common.py +9 -2
  123. package/assess/lsa/scripts/make_batches.py +2 -1
  124. package/assess/lsa/scripts/map_routes.py +10 -2
  125. package/assess/lsa/scripts/nextjs_routes.py +236 -0
  126. package/assess/lsa/templates/audit-config.json +1 -1
  127. package/assess/lsa/tests/EVAL-RESULTS.md +156 -0
  128. package/assess/lsa/tests/score_findings.py +58 -0
  129. package/assess/lsa/tests/test_default_scope.py +86 -0
  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_js_tagged_sql.py +112 -0
  133. package/assess/lsa/tests/test_nextjs.py +198 -0
  134. package/assess/lsa/tests/test_privilege_tier2.py +30 -0
  135. package/operate/ai/anchoring.py +130 -0
  136. package/operate/ai/client.py +13 -0
  137. package/operate/ai/context.py +105 -0
  138. package/operate/ai/runner.py +197 -69
  139. package/operate/cli/__init__.py +1 -1
  140. package/operate/cli/commands/scan.py +1 -1
  141. package/operate/tests/test_ai_anchoring.py +184 -0
  142. package/operate/tests/test_headless_ai.py +148 -1
  143. package/package.json +1 -1
@@ -7,7 +7,7 @@ LLM endpoint (Ollama, vLLM, private OpenAI server):
7
7
  3. Audits candidate routes for BOLA / IDOR (BOLA Auditor).
8
8
  4. Audits candidate queries for SQL/NoSQL Injection (Injection Auditor).
9
9
  5. Adversarial Skeptic Verification (Finding Verifier Council).
10
- 6. Merges and schema-validates all findings with 0 false positives.
10
+ 6. Merges and schema-validates all findings; writes OUT/ai-run-stats.json.
11
11
 
12
12
  Standard-library only (Python 3.8+ compatible).
13
13
  """
@@ -18,30 +18,28 @@ import json
18
18
  from pathlib import Path
19
19
  import subprocess
20
20
  import sys
21
+ import time
21
22
  from typing import Any, Callable, Dict, List, Optional
22
23
 
24
+ from operate.ai.anchoring import Anchor, AnchorReport, anchor_findings, load_anchors
23
25
  from operate.ai.client import LLMClient
26
+ from operate.ai.context import _included_files, _model_source, _read_snippet, _verification_context # noqa: F401
24
27
 
25
28
  REPO_ROOT = Path(__file__).resolve().parents[2]
26
29
  LSA_PROMPTS = REPO_ROOT / "assess" / "lsa" / "prompts"
27
30
  DEFAULT_CWE = {"bola": ["CWE-284"], "bfla": ["CWE-285"], "mass_assignment": ["CWE-915"],
28
31
  "sql_injection": ["CWE-89"], "nosql_injection": ["CWE-943"]}
29
32
  LSA_SCRIPTS = REPO_ROOT / "assess" / "lsa" / "scripts"
33
+ # Valid subtypes per category (finding.schema.json); the first one is used when a model writes an invalid value
34
+ CATEGORY_SUBTYPES = {"bfla": ["missing_role_check", "role_from_request", "client_side_role_check"],
35
+ "mass_assignment": ["whole_object_binding", "privileged_field_writable"]}
36
+ FINDING_STATUSES = {"unverified", "confirmed", "likely", "rejected", "needs_review"}
30
37
 
31
38
 
32
- def _read_snippet(target_dir: Path, rel_file: str, line: int, radius: int = 15) -> str:
33
- """Read a window of code around target line from the audited repository."""
34
- full_path = target_dir / rel_file
35
- if not full_path.is_file():
36
- return f"[File not found: {rel_file}]"
37
- try:
38
- lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines()
39
- start = max(0, line - radius - 1)
40
- end = min(len(lines), line + radius)
41
- numbered = [f"{i + 1:4d} | {lines[i]}" for i in range(start, end)]
42
- return "\n".join(numbered)
43
- except Exception as e:
44
- return f"[Error reading {rel_file}: {e}]"
39
+ VERIFY_BATCH_SIZE = 5
40
+ AUTH_MODEL_LIMIT = 6000
41
+ RETRY_REMINDER = ("Your previous reply was empty or not valid JSON. Answer again with the JSON object only, "
42
+ "exactly in the format requested above, with no other text.")
45
43
 
46
44
 
47
45
  class HeadlessAIRunner:
@@ -58,34 +56,121 @@ class HeadlessAIRunner:
58
56
  self.out_dir = Path(out_dir).resolve()
59
57
  self.client = client
60
58
  self.callback = step_callback or (lambda status, msg: None)
59
+ # Per-phase replies that could not be parsed as JSON; written to ai-run-stats.json
60
+ self.parse_failures: Dict[str, List[str]] = {}
61
+ # Per-phase batches whose model call failed (timeout, HTTP error); the run continues without them
62
+ self.call_errors: Dict[str, Dict[str, str]] = {}
63
+ # Per-phase batches whose first reply was empty or unparseable and were asked again
64
+ self.retries: Dict[str, List[str]] = {}
65
+ # Findings whose location was taken from the Tier 1 inventory (#319)
66
+ self.anchor_report = AnchorReport()
67
+ self._anchors: Optional[Dict[str, Anchor]] = None
68
+ self.phase_stats: Dict[str, Dict[str, Any]] = {}
61
69
 
62
70
  def _log(self, status: str, msg: str) -> None:
63
71
  self.callback(status, msg)
64
72
 
73
+ def _anchor(self, findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
74
+ if self._anchors is None:
75
+ self._anchors = load_anchors(self.out_dir, self.target_dir)
76
+ return anchor_findings(findings, self._anchors, self.target_dir, self.anchor_report)
77
+
78
+ def _chat(self, phase: str, batch_id: str, messages: List[Dict[str, str]]) -> Optional[str]:
79
+ """One batch call; a failed call is recorded and returns None instead of aborting the run.
80
+
81
+ An empty or unparseable reply is retried once with a reminder to answer with JSON only (#319).
82
+ """
83
+ try:
84
+ reply = self.client.chat(messages, temperature=0.1)
85
+ except Exception as e:
86
+ self.call_errors.setdefault(phase, {})[batch_id] = str(e)[:300]
87
+ self._log("warn", f"{phase}: model call failed for {batch_id} ({e}); batch left without verdicts")
88
+ return None
89
+ if self._parses(reply):
90
+ return reply
91
+ self.retries.setdefault(phase, []).append(batch_id)
92
+ self._log("warn", f"{phase}: reply for {batch_id} was {'empty' if not (reply or '').strip() else 'not valid JSON'}; retrying once")
93
+ retry_messages = messages + ([{"role": "assistant", "content": reply}] if (reply or "").strip() else []) + [
94
+ {"role": "user", "content": RETRY_REMINDER}]
95
+ try:
96
+ second = self.client.chat(retry_messages, temperature=0.1)
97
+ except Exception as e:
98
+ self._log("warn", f"{phase}: retry for {batch_id} failed ({e})")
99
+ return reply
100
+ return second if self._parses(second) or not (reply or "").strip() else reply
101
+
102
+ def _parses(self, reply: Optional[str]) -> bool:
103
+ if not (reply or "").strip():
104
+ return False
105
+ try:
106
+ self.client.extract_json(reply)
107
+ return True
108
+ except Exception:
109
+ return False
110
+
111
+ def _parse_failed(self, phase: str, batch_id: str, reply: Optional[str] = None) -> None:
112
+ if batch_id in self.call_errors.get(phase, {}):
113
+ return
114
+ self.parse_failures.setdefault(phase, []).append(batch_id)
115
+ if reply is not None:
116
+ raw_dir = self.out_dir / "ai-raw"
117
+ raw_dir.mkdir(parents=True, exist_ok=True)
118
+ (raw_dir / f"{phase}-{batch_id}.txt").write_text(reply, encoding="utf-8")
119
+ self._log("warn", f"{phase}: could not parse the model reply for {batch_id}; batch left without verdicts")
120
+
121
+ def _usage(self) -> Dict[str, Any]:
122
+ return dict(getattr(self.client, "usage", None) or {})
123
+
124
+ def _timed(self, phase: str, fn: Callable[[], Any]) -> None:
125
+ before, started = self._usage(), time.monotonic()
126
+ fn()
127
+ after = self._usage()
128
+ self.phase_stats[phase] = {
129
+ "seconds": round(time.monotonic() - started, 3),
130
+ **{k: after.get(k, 0) - before.get(k, 0) for k in ("calls", "prompt_tokens", "completion_tokens")},
131
+ }
132
+
133
+ def write_run_stats(self) -> Path:
134
+ """Write model, per-phase durations, token counts and parse failures to OUT/ai-run-stats.json."""
135
+ stats = {
136
+ "model": getattr(self.client, "model", None),
137
+ "endpoint": getattr(self.client, "endpoint", None),
138
+ "phases": self.phase_stats,
139
+ "totals": self._usage(),
140
+ "parse_failures": self.parse_failures,
141
+ "call_errors": self.call_errors,
142
+ "retries": self.retries,
143
+ "anchoring": {"moved": self.anchor_report.moved, "unanchored": self.anchor_report.unanchored},
144
+ }
145
+ path = self.out_dir / "ai-run-stats.json"
146
+ path.write_text(json.dumps(stats, indent=2), encoding="utf-8")
147
+ return path
148
+
65
149
  def run(self) -> Dict[str, Any]:
66
150
  """Execute all cognitive AI phases end-to-end."""
67
151
  self._log("step", "Starting Tier 2 Cognitive Multi-Agent Audit (Headless BYO-LLM)")
68
152
 
69
153
  # Phase 2: Stack & Auth Model Detector
70
- self.infer_auth_model()
154
+ self._timed("2-stack-detector", self.infer_auth_model)
71
155
 
72
156
  # Phase 3: Route Inventory Mapping
73
- self.map_routes_inventory()
157
+ self._timed("3-route-mapper", self.map_routes_inventory)
74
158
 
75
159
  # Phase 4a: BOLA / IDOR Cognitive Audit
76
- self.audit_bola_routes()
160
+ self._timed("4a-bola-auditor", self.audit_bola_routes)
77
161
 
78
162
  # Phase 4b: SQL / NoSQL Injection Cognitive Audit
79
- self.audit_injection_candidates()
163
+ self._timed("4b-injection-auditor", self.audit_injection_candidates)
80
164
 
81
165
  # Phase 4c: Privilege Audit (BFLA, mass assignment)
82
- self.audit_privilege()
166
+ self._timed("4c-privilege-auditor", self.audit_privilege)
83
167
 
84
168
  # Phase 5: Adversarial Skeptic Verifier
85
- self.verify_findings_skeptic()
169
+ self._timed("5-finding-verifier", self.verify_findings_skeptic)
86
170
 
87
171
  # Phase 6: Merge & Validate Outputs
88
172
  self.merge_and_validate()
173
+ self.write_run_stats()
89
174
 
90
175
  self._log("ok", "Tier 2 Cognitive Multi-Agent Audit completed successfully")
91
176
  return {"status": "success", "out_dir": str(self.out_dir)}
@@ -209,6 +294,20 @@ Output MUST be Markdown format adhering to Komagu auth-model.md specification.
209
294
  """Normalize finding fields to strictly comply with finding.schema.json."""
210
295
  if not isinstance(f, dict):
211
296
  return f
297
+ # impact must be a string; small models often return a list of consequences
298
+ if isinstance(f.get("impact"), list):
299
+ f["impact"] = "; ".join(str(i) for i in f["impact"])
300
+
301
+ # a verdict written into subtype belongs in status; fall back to the category's first subtype
302
+ allowed = CATEGORY_SUBTYPES.get(f.get("category"))
303
+ if allowed and f.get("subtype") not in allowed:
304
+ if f.get("subtype") in FINDING_STATUSES:
305
+ f["status"] = f["subtype"]
306
+ f["subtype"] = allowed[0]
307
+
308
+ if f.get("status") not in FINDING_STATUSES:
309
+ f["status"] = "unverified"
310
+
212
311
  # recommendation must be a string
213
312
  rec = f.get("recommendation")
214
313
  if isinstance(rec, dict):
@@ -227,8 +326,8 @@ Output MUST be Markdown format adhering to Komagu auth-model.md specification.
227
326
  else:
228
327
  for ev in evidence:
229
328
  if isinstance(ev, dict):
230
- if "line" not in ev:
231
- ev["line"] = ev.get("start_line", default_line)
329
+ if not isinstance(ev.get("line"), int):
330
+ ev["line"] = ev.get("start_line") if isinstance(ev.get("start_line"), int) else default_line
232
331
  if "file" not in ev:
233
332
  ev["file"] = default_file
234
333
  if "why" not in ev:
@@ -323,13 +422,14 @@ Respond STRICTLY with a valid JSON object matching:
323
422
  {"role": "user", "content": user_prompt},
324
423
  ]
325
424
 
326
- reply = self.client.chat(messages, temperature=0.1)
425
+ reply = self._chat("4a-bola-auditor", r_file.stem, messages)
327
426
  try:
328
427
  parsed = self.client.extract_json(reply)
329
428
  raw_findings = parsed.get("findings", [])
330
- findings = [self._sanitize_finding(f) for f in raw_findings]
429
+ findings = self._anchor([self._sanitize_finding(f) for f in raw_findings])
331
430
  route_verdicts = parsed.get("route_verdicts", [])
332
431
  except Exception:
432
+ self._parse_failed("4a-bola-auditor", r_file.stem, reply)
333
433
  findings = []
334
434
  route_verdicts = []
335
435
 
@@ -409,13 +509,14 @@ Respond STRICTLY with a valid JSON object matching:
409
509
  {"role": "user", "content": user_prompt},
410
510
  ]
411
511
 
412
- reply = self.client.chat(messages, temperature=0.1)
512
+ reply = self._chat("4b-injection-auditor", b_file.stem, messages)
413
513
  try:
414
514
  parsed = self.client.extract_json(reply)
415
515
  raw_findings = parsed.get("findings", [])
416
- findings = [self._sanitize_finding(f) for f in raw_findings]
516
+ findings = self._anchor([self._sanitize_finding(f) for f in raw_findings])
417
517
  candidate_verdicts = parsed.get("candidate_verdicts", [])
418
518
  except Exception:
519
+ self._parse_failed("4b-injection-auditor", b_file.stem, reply)
419
520
  findings = []
420
521
  candidate_verdicts = []
421
522
 
@@ -467,6 +568,7 @@ Respond STRICTLY with a valid JSON object matching:
467
568
  "allowlist": c.get("allowlist"),
468
569
  "sensitive_fields": c.get("sensitive_fields", []),
469
570
  "code_context": _read_snippet(self.target_dir, c.get("file", ""), c.get("line") or 1),
571
+ "model_definition": _model_source(self.target_dir, c.get("target"), c.get("file", ""), c.get("line") or 1),
470
572
  } for c in mass]
471
573
 
472
574
  user_prompt = f"""Auth Model:
@@ -480,20 +582,51 @@ Mass Assignment Candidates:
480
582
 
481
583
  Task:
482
584
  Judge every route for BFLA and every candidate for mass assignment as described in your role.
483
- Respond STRICTLY with a valid JSON object with keys "batch_id" ("{b_file.stem}"),
484
- "privilege_route_verdicts", "candidate_verdicts" and "findings" (category "bfla" or "mass_assignment").
585
+ Respond STRICTLY with a valid JSON object matching:
586
+ {{
587
+ "batch_id": "{b_file.stem}",
588
+ "privilege_route_verdicts": [
589
+ {{"route_id": "<route_id>", "verdict": "vulnerable|safe|needs_review"}}
590
+ ],
591
+ "candidate_verdicts": [
592
+ {{"candidate_id": "<candidate_id>", "verdict": "vulnerable|safe|needs_review"}}
593
+ ],
594
+ "findings": [
595
+ {{
596
+ "finding_id": "BFLA-<route_id>-1 or MASS-<candidate_id>-1",
597
+ "category": "bfla|mass_assignment",
598
+ "subtype": "missing_role_check|role_from_request|client_side_role_check (bfla) or whole_object_binding|privileged_field_writable (mass_assignment)",
599
+ "title": "<short title>",
600
+ "route_id": "<route_id, bfla only>",
601
+ "candidate_id": "<candidate_id, mass_assignment only>",
602
+ "location": {{"file": "<file>", "start_line": <line>}},
603
+ "evidence": [{{"file": "<file>", "line": <line>, "why": "<privileged action or binding, and the missing control>"}}],
604
+ "impact": "<one string naming the privileged action or fields>",
605
+ "severity": "critical|high|medium|low",
606
+ "confidence": "high|medium|low",
607
+ "cwe": ["CWE-285"],
608
+ "recommendation": "<one string>",
609
+ "status": "unverified|needs_review"
610
+ }}
611
+ ]
612
+ }}
613
+ Write one finding for every "vulnerable" or "needs_review" verdict. A "needs_review" verdict goes in
614
+ "status", never in "subtype".
615
+ For mass assignment, judge the fields from "model_definition". If it says the definition was not found
616
+ (or is null), you cannot know the fields: answer "needs_review", never "safe" for that reason alone.
485
617
  """
486
618
  messages = [
487
619
  {"role": "system", "content": auditor_prompt},
488
620
  {"role": "user", "content": user_prompt},
489
621
  ]
490
- reply = self.client.chat(messages, temperature=0.1)
622
+ reply = self._chat("4c-privilege-auditor", b_file.stem, messages)
491
623
  try:
492
624
  parsed = self.client.extract_json(reply)
493
- findings = [self._sanitize_finding(f) for f in parsed.get("findings", [])]
625
+ findings = self._anchor([self._sanitize_finding(f) for f in parsed.get("findings", [])])
494
626
  route_verdicts = parsed.get("privilege_route_verdicts", [])
495
627
  candidate_verdicts = parsed.get("candidate_verdicts", [])
496
628
  except Exception:
629
+ self._parse_failed("4c-privilege-auditor", b_file.stem, reply)
497
630
  findings, route_verdicts, candidate_verdicts = [], [], []
498
631
 
499
632
  out_doc = {
@@ -528,37 +661,46 @@ Respond STRICTLY with a valid JSON object with keys "batch_id" ("{b_file.stem}")
528
661
  return
529
662
 
530
663
  verifier_prompt = (LSA_PROMPTS / "50-finding-verifier.md").read_text(encoding="utf-8", errors="replace") if (LSA_PROMPTS / "50-finding-verifier.md").is_file() else ""
664
+ auth_model_path = self.out_dir / "auth-model.md"
665
+ auth_model = auth_model_path.read_text(encoding="utf-8", errors="replace")[:AUTH_MODEL_LIMIT] if auth_model_path.is_file() else ""
531
666
 
532
- # Batch up to 10 findings per verification
533
- batch_size = 10
667
+ batch_size = VERIFY_BATCH_SIZE
534
668
  for idx in range(0, len(all_findings), batch_size):
535
669
  chunk = all_findings[idx : idx + batch_size]
536
- user_prompt = f"""Findings to Verify & Challenge:
537
- {json.dumps(chunk, indent=2)}
670
+ # The headless verifier cannot open files, so it gets the code it must judge (#286)
671
+ to_verify = [{"finding": f, "code_context": _verification_context(self.target_dir, f)} for f in chunk]
672
+ user_prompt = f"""Auth Model:
673
+ {auth_model}
674
+
675
+ Findings to Verify & Challenge (each with the code you may rely on):
676
+ {json.dumps(to_verify, indent=2)}
538
677
 
539
678
  Task:
540
- You are the skeptical security verifier. For each finding:
541
- 1. Examine whether a compensating control (global filter, integer casting, ownership check, allow-list) neutralizes the vulnerability.
542
- 2. Confirm or refute:
543
- - "confirmed" (reason_code: "traced")
544
- - "rejected" (reason_code: "control_found" | "not_reachable" | "evidence_mismatch")
679
+ You are the skeptical security verifier. You cannot open files: judge only from the code_context given
680
+ with each finding and the Auth Model above.
681
+ For each finding:
682
+ 1. Look for a compensating control (role or ownership check, global filter, integer cast, allow-list,
683
+ parameter binding) in the code_context.
684
+ 2. Only use reason_code "control_found" when the control is visible in the code_context; quote its
685
+ file and line in "control_found". Never assume a control in a base class, include, config or
686
+ middleware you were not shown.
687
+ 3. If the code you would need is not shown, answer verdict "likely" with reason_code
688
+ "code_not_visible" or "config_not_visible".
689
+ Verdicts: "confirmed" | "likely" | "rejected".
690
+ reason_code is one of: "traced", "code_not_visible", "config_not_visible", "data_ownership_unclear",
691
+ "control_found", "evidence_mismatch", "not_executed", "not_reachable", "not_attacker_controlled".
545
692
  Respond STRICTLY with a valid JSON object matching verification.schema.json:
546
693
  {{
547
694
  "batch_id": "verified-{(idx // batch_size) + 1}",
548
695
  "verifications": [
549
696
  {{
550
697
  "finding_id": "<finding_id>",
551
- "verdict": "confirmed|rejected",
552
- "reason_code": "traced|control_found",
553
- "confidence": "high",
554
- "checked": ["Checked server-side session binding and sanitizers"],
555
- "notes": "<rationale for verdict>",
556
- "consensus": {{
557
- "score": 0.95,
558
- "agreement_level": "unanimous",
559
- "voted_models": ["{self.client.model}"],
560
- "rationale": "Independently audited and refuted false-alarm candidates."
561
- }}
698
+ "verdict": "confirmed|likely|rejected",
699
+ "reason_code": "<one of the reason codes above>",
700
+ "confidence": "high|medium|low",
701
+ "checked": ["<what you checked in the code_context>"],
702
+ "control_found": {{"file": "<file>", "line": <line>, "code": "<control>"}} or null,
703
+ "notes": "<rationale for verdict>"
562
704
  }}
563
705
  ]
564
706
  }}
@@ -568,29 +710,15 @@ Respond STRICTLY with a valid JSON object matching verification.schema.json:
568
710
  {"role": "user", "content": user_prompt},
569
711
  ]
570
712
 
571
- reply = self.client.chat(messages, temperature=0.1)
713
+ reply = self._chat("5-finding-verifier", f"verified-{(idx // batch_size) + 1}", messages)
572
714
  try:
573
715
  parsed = self.client.extract_json(reply)
574
716
  verifs = parsed.get("verifications", [])
575
717
  except Exception:
576
- # Default fallback verification for unverified items
577
- verifs = [
578
- {
579
- "finding_id": f.get("finding_id", ""),
580
- "verdict": "confirmed",
581
- "reason_code": "traced",
582
- "confidence": "high",
583
- "checked": ["Audited data-flow"],
584
- "notes": "Traced via headless cognitive consensus.",
585
- "consensus": {
586
- "score": 0.90,
587
- "agreement_level": "majority",
588
- "voted_models": [self.client.model],
589
- "rationale": "Verified in headless pipeline.",
590
- },
591
- }
592
- for f in chunk
593
- ]
718
+ # No verdicts: merge_findings.py lists these findings under
719
+ # coverage.findings_without_verification instead of confirming them
720
+ self._parse_failed("5-finding-verifier", f"verified-{(idx // batch_size) + 1}", reply)
721
+ verifs = []
594
722
 
595
723
  verif_doc = {
596
724
  "batch_id": f"verified-{(idx // batch_size) + 1}",
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.0.0" # x-release-please-version
5
+ __version__ = "2.1.0" # x-release-please-version
@@ -455,7 +455,7 @@ def execute_scan(args: argparse.Namespace) -> int:
455
455
  f" {color('🚀 Mode: Tier 2 Cognitive Multi-Agent Engine', GREEN, BOLD)} "
456
456
  f"(Headless BYO-LLM: {args.headless_ai}, Model: {args.model})"
457
457
  )
458
- print(f" {color('✔ Skeptic Consensus Applied', GREEN)}: Verified zero-false-positive findings against compensative controls.\n")
458
+ print(f" {color('✔ Skeptic Consensus Applied', GREEN)}: Findings challenged against compensating controls.\n")
459
459
  else:
460
460
  print(f" {color('💡 Mode: Tier 1 Deterministic Engine', CYAN, BOLD)} (Regex & AST candidate extraction, no LLM required)")
461
461
  print(" To run Tier 2 Cognitive Multi-Agent Audit (BOLA semantic reasoning & Skeptic Verifier):")
@@ -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()