@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.9

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 (170) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +448 -74
  5. package/dist/113.index.js +16 -7
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/207.index.js +5 -4
  9. package/dist/220.index.js +5 -3
  10. package/dist/238.index.js +4 -4
  11. package/dist/317.index.js +300 -0
  12. package/dist/384.index.js +1 -1
  13. package/dist/435.index.js +196 -21
  14. package/dist/444.index.js +20 -11
  15. package/dist/449.index.js +8 -1
  16. package/dist/513.index.js +7 -3
  17. package/dist/526.index.js +6 -6
  18. package/dist/637.index.js +1 -1
  19. package/dist/675.index.js +7 -5
  20. package/dist/839.index.js +4 -3
  21. package/dist/905.index.js +1173 -0
  22. package/dist/agentic-security.mjs +14 -14
  23. package/dist/agentic-security.mjs.sha256 +1 -1
  24. package/dist/compliance-frameworks/ccpa.json +32 -0
  25. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  26. package/dist/compliance-frameworks/gdpr.json +45 -0
  27. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  28. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  29. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  30. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  31. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  32. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  33. package/package.json +24 -12
  34. package/src/badge.js +2 -1
  35. package/src/dataflow/CLAUDE.md +10 -4
  36. package/src/dataflow/builtin-summaries.js +1 -1
  37. package/src/dataflow/cross-service-taint.js +2 -1
  38. package/src/dataflow/engine.js +324 -60
  39. package/src/dataflow/ifds-precise.js +6 -4
  40. package/src/dataflow/implicit-flow.js +68 -36
  41. package/src/dataflow/incremental.js +25 -8
  42. package/src/dataflow/index.js +2 -1
  43. package/src/dataflow/proven-clean.js +41 -0
  44. package/src/dataflow/sanitizer-gate.js +35 -9
  45. package/src/dataflow/sanitizer-proof.js +21 -3
  46. package/src/dataflow/stub-aware-filter.js +36 -13
  47. package/src/dataflow/summaries.js +21 -2
  48. package/src/discovery/CLAUDE.md +10 -0
  49. package/src/discovery/index.js +175 -3
  50. package/src/discovery/llm-invoke.js +90 -1
  51. package/src/discovery/memory.js +163 -0
  52. package/src/engine.js +247 -50
  53. package/src/integrations/tickets.js +7 -6
  54. package/src/ir/CLAUDE.md +4 -1
  55. package/src/ir/balanced-call.js +55 -0
  56. package/src/ir/ir-stats.js +1 -1
  57. package/src/ir/parser-cpp.js +1 -1
  58. package/src/ir/parser-cs.js +62 -9
  59. package/src/ir/parser-go.js +29 -11
  60. package/src/ir/parser-java.js +96 -19
  61. package/src/ir/parser-js.js +151 -20
  62. package/src/ir/parser-php.js +44 -9
  63. package/src/ir/parser-rb.js +37 -7
  64. package/src/ir/ssa.js +6 -1
  65. package/src/leaderboard.js +3 -2
  66. package/src/llm-validator/consistency.js +6 -2
  67. package/src/llm-validator/index.js +1 -2
  68. package/src/lsp/server.js +28 -2
  69. package/src/mcp/CLAUDE.md +9 -2
  70. package/src/mcp/audit.js +2 -1
  71. package/src/mcp/redact.js +26 -0
  72. package/src/mcp/tools.js +159 -17
  73. package/src/posture/CLAUDE.md +45 -8
  74. package/src/posture/accuracy-scorecard.js +67 -1
  75. package/src/posture/agents-memory.js +5 -3
  76. package/src/posture/aibom.js +12 -8
  77. package/src/posture/auditor-walkthrough.js +111 -10
  78. package/src/posture/auth-posture-import.js +5 -4
  79. package/src/posture/autopilot.js +8 -1
  80. package/src/posture/calibration-drift.js +11 -5
  81. package/src/posture/calibration.js +24 -2
  82. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  83. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  84. package/src/posture/compliance-policy.js +40 -10
  85. package/src/posture/confidence.js +44 -10
  86. package/src/posture/corpus-enroll.js +9 -5
  87. package/src/posture/corpus-match.js +19 -0
  88. package/src/posture/csharp-analysis.js +62 -3
  89. package/src/posture/custom-rules.js +7 -5
  90. package/src/posture/cve-alert-daemon.js +6 -5
  91. package/src/posture/dep-add-guard.js +2 -1
  92. package/src/posture/deploy-platform.js +4 -1
  93. package/src/posture/deterministic.js +3 -2
  94. package/src/posture/drift.js +7 -1
  95. package/src/posture/epss.js +13 -1
  96. package/src/posture/evidence-bundle.js +276 -0
  97. package/src/posture/exploitability-probability.js +15 -2
  98. package/src/posture/falsification.js +23 -2
  99. package/src/posture/feature-flags.js +3 -2
  100. package/src/posture/findings-memory.js +3 -3
  101. package/src/posture/fix-history.js +5 -2
  102. package/src/posture/fix-metrics.js +5 -5
  103. package/src/posture/fix-plan.js +2 -1
  104. package/src/posture/fix-verify-loop.js +10 -1
  105. package/src/posture/grader-calibration.js +3 -4
  106. package/src/posture/iac-reachability.js +14 -8
  107. package/src/posture/integrity.js +25 -7
  108. package/src/posture/intent-context.js +2 -1
  109. package/src/posture/learning.js +4 -3
  110. package/src/posture/license-attributions.js +5 -7
  111. package/src/posture/license-graph.js +2 -1
  112. package/src/posture/license-policy.js +2 -1
  113. package/src/posture/model-rescan.js +69 -3
  114. package/src/posture/mttr.js +5 -0
  115. package/src/posture/network-policy-import.js +3 -2
  116. package/src/posture/poc-inprocess.js +27 -8
  117. package/src/posture/pqc-migration-plan.js +7 -5
  118. package/src/posture/pr-augment.js +8 -5
  119. package/src/posture/privacy-framework.js +262 -0
  120. package/src/posture/regression-test-gen.js +23 -8
  121. package/src/posture/reverse-blast-radius.js +5 -1
  122. package/src/posture/risk-dollars.js +20 -3
  123. package/src/posture/router.js +5 -4
  124. package/src/posture/ruleset-version.js +2 -2
  125. package/src/posture/runtime-correlation.js +2 -1
  126. package/src/posture/sbom-diff.js +12 -3
  127. package/src/posture/sca-policy.js +7 -4
  128. package/src/posture/scan-checkpoint.js +15 -0
  129. package/src/posture/secret-history.js +20 -11
  130. package/src/posture/security-trend.js +7 -1
  131. package/src/posture/stack-playbook.js +22 -1
  132. package/src/posture/state-dir.js +34 -0
  133. package/src/posture/telemetry-ingest.js +4 -3
  134. package/src/posture/threat-model-auto.js +4 -1
  135. package/src/posture/threat-model-grounding.js +13 -3
  136. package/src/posture/time-to-fix.js +3 -2
  137. package/src/posture/triage-memory.js +3 -2
  138. package/src/posture/validator-metrics.js +10 -3
  139. package/src/posture/verifier.js +32 -57
  140. package/src/posture/waf-ingest.js +6 -5
  141. package/src/posture/watch-mode.js +4 -3
  142. package/src/report/index.js +183 -14
  143. package/src/runScan.js +1 -1
  144. package/src/sast/_comment-strip.js +15 -4
  145. package/src/sast/_secret-entropy.js +1 -1
  146. package/src/sast/authz.js +6 -4
  147. package/src/sast/bench-shape/index.js +2 -7
  148. package/src/sast/claude-md-prompt-injection.js +14 -3
  149. package/src/sast/cloud-iam.js +60 -7
  150. package/src/sast/code-injection-multilang.js +29 -0
  151. package/src/sast/cpp-bench-extras.js +1 -1
  152. package/src/sast/csrf.js +7 -5
  153. package/src/sast/env-hygiene.js +5 -2
  154. package/src/sast/iac-terraform.js +25 -0
  155. package/src/sast/java-bench-extras.js +1 -1
  156. package/src/sast/java-constant-fold.js +5 -5
  157. package/src/sast/llm-owasp.js +4 -2
  158. package/src/sast/mcp-audit.js +7 -0
  159. package/src/sast/pipeline.js +8 -0
  160. package/src/sast/prompt-template.js +8 -6
  161. package/src/sast/prototype-pollution.js +6 -2
  162. package/src/sast/redos-nfa.js +6 -6
  163. package/src/sast/secret-concat.js +13 -2
  164. package/src/sast/ssrf-cloud-metadata.js +6 -3
  165. package/src/sast/xss-reflected-multilang.js +1 -1
  166. package/src/sast/xxe.js +1 -1
  167. package/src/sca/CLAUDE.md +3 -4
  168. package/src/sca/container.js +35 -3
  169. package/src/sca/dep-confusion.js +9 -1
  170. package/src/sca/sarif-ingest.js +0 -187
@@ -13,6 +13,7 @@ import { runHunter } from './hunter.js';
13
13
  import { confirmAll } from './confirm.js';
14
14
  import { disprovePanel } from './disprove.js';
15
15
  import { judgeCandidates } from './judge.js';
16
+ import { loadMemory, saveMemory, rememberRun, previouslyRefuted, nextWavePlan } from './memory.js';
16
17
 
17
18
  // Bridge a candidate to the deterministic layer. A taint finding at or within
18
19
  // two lines of the candidate corroborates it; a modelled sink on the line
@@ -54,10 +55,91 @@ async function runDeepAnalysisSafe(perFileIR, callGraph) {
54
55
  // where { perFileIR, callGraph } come from buildProjectIR(fileContents),
55
56
  // which returns { perFile, callGraph } — callers must pass perFile as
56
57
  // perFileIR (see scanner/src/ir/index.js).
58
+ /**
59
+ * PRD Phase 0 / C3 — the run budget.
60
+ *
61
+ * This pipeline is multiplicative and was, until now, unbounded. Eight focus
62
+ * areas × seven lenses is 56 hunter calls before a single candidate exists, and
63
+ * every surviving candidate then costs three more calls in the refutation
64
+ * panel. Nothing capped any of it, so the cost of a run was a function of how
65
+ * large the repository happened to be — which is not a property you want to
66
+ * discover from an invoice.
67
+ *
68
+ * ENFORCED AT THE ONE SEAM EVERY CALL PASSES THROUGH. Rather than thread checks
69
+ * through the hunter and the panel, the budget wraps `llmInvoke` itself. When
70
+ * it is spent the wrapper throws, and both callers already treat a throwing
71
+ * llmInvoke as ordinary degradation with a stated reason. So exhaustion arrives
72
+ * through the same path as a rate limit or a dead endpoint, and lands in
73
+ * `coverage.reasons` like any other coverage gap. No new failure mode.
74
+ *
75
+ * CALLS AND WALL CLOCK, NOT TOKENS. `llmInvoke` is an injected callback that
76
+ * returns a string; it carries no usage metadata, so counting tokens here would
77
+ * mean inventing a number. Calls are exactly countable and wall clock is
78
+ * exactly observable. A caller who knows their per-call cost can pass
79
+ * `costPerCallUsd` and get a `maxCostUsd` ceiling expressed in calls, which is
80
+ * honest about being an estimate derived from their figure rather than ours.
81
+ */
82
+ // Internal, not exported: the dead-module guard treats an export with no
83
+ // external call site as shipped dead code, and these are read only by
84
+ // makeBudget below. A consumer sets a ceiling by passing opts, not by importing
85
+ // a constant.
86
+ const DEFAULT_MAX_LLM_CALLS = 200;
87
+ const DEFAULT_MAX_WALL_MS = 15 * 60 * 1000;
88
+ const DEFAULT_MAX_CANDIDATES = 50;
89
+
90
+ export function makeBudget(opts = {}, now = Date.now) {
91
+ const startedAt = now();
92
+ let maxCalls = Number.isInteger(opts.maxLlmCalls) && opts.maxLlmCalls >= 0
93
+ ? opts.maxLlmCalls : DEFAULT_MAX_LLM_CALLS;
94
+ // A dollar ceiling is only meaningful with a caller-supplied per-call cost.
95
+ // Converting it to a call count keeps one enforcement mechanism rather than
96
+ // two that can disagree.
97
+ if (Number.isFinite(opts.maxCostUsd) && Number.isFinite(opts.costPerCallUsd) && opts.costPerCallUsd > 0) {
98
+ maxCalls = Math.min(maxCalls, Math.floor(opts.maxCostUsd / opts.costPerCallUsd));
99
+ }
100
+ const maxWallMs = Number.isInteger(opts.maxWallMs) && opts.maxWallMs > 0
101
+ ? opts.maxWallMs : DEFAULT_MAX_WALL_MS;
102
+
103
+ let calls = 0;
104
+ let exhaustedReason = null;
105
+
106
+ const check = () => {
107
+ if (exhaustedReason) return exhaustedReason;
108
+ if (calls >= maxCalls) return (exhaustedReason = `LLM call budget spent (${calls}/${maxCalls} calls)`);
109
+ if (now() - startedAt >= maxWallMs) {
110
+ return (exhaustedReason = `wall-clock budget spent (${Math.round(maxWallMs / 1000)}s)`);
111
+ }
112
+ return null;
113
+ };
114
+
115
+ return {
116
+ get calls() { return calls; },
117
+ get maxCalls() { return maxCalls; },
118
+ get exhaustedReason() { return exhaustedReason; },
119
+ spent: () => check() !== null,
120
+ /** Wrap an llmInvoke so every call is counted and the ceiling is enforced. */
121
+ wrap(llmInvoke) {
122
+ if (typeof llmInvoke !== 'function') return llmInvoke;
123
+ return async (prompt) => {
124
+ const stop = check();
125
+ if (stop) throw new Error(`discovery budget exhausted: ${stop}`);
126
+ calls += 1;
127
+ return llmInvoke(prompt);
128
+ };
129
+ },
130
+ };
131
+ }
132
+
57
133
  export async function runDiscovery(ctx = {}, opts = {}) {
58
134
  const areas = partitionCallGraph(ctx.callGraph, { maxAreas: opts.maxAreas ?? 8 });
59
135
 
60
136
  const reasons = [];
137
+ const budget = makeBudget(opts);
138
+ // PRD C4 — what previous runs already judged. scanRoot absent => no memory,
139
+ // which is the correct default for a library call with nowhere to persist.
140
+ const memory = opts.scanRoot ? loadMemory(opts.scanRoot) : null;
141
+ // Every LLM call in this pipeline goes through this one wrapped callback.
142
+ const llmInvoke = budget.wrap(opts.llmInvoke);
61
143
 
62
144
  // An explicit array (including an empty one) is honoured exactly — a caller
63
145
  // narrowing a run to no lenses must get no lenses, not a silent fallback to
@@ -87,7 +169,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
87
169
  for (const area of areas) {
88
170
  let areaDegradedCount = 0;
89
171
  for (const lens of lenses) {
90
- const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke: opts.llmInvoke });
172
+ const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke });
91
173
  runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
92
174
  if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
93
175
  if (run.degraded) areaDegradedCount += 1;
@@ -97,11 +179,69 @@ export async function runDiscovery(ctx = {}, opts = {}) {
97
179
  if (lenses.length > 0 && areaDegradedCount === 0) fullyHunted.add(area.id);
98
180
  }
99
181
 
100
- const taintProbe = makeTaintProbe(ctx.perFileIR, ctx.callGraph);
182
+ // PRD Phase 0 / C3.2 — the candidate cap.
183
+ //
184
+ // Every candidate that reaches the panel costs three more LLM calls, so an
185
+ // unusually productive hunt multiplies straight into spend. Cap it, and
186
+ // REPORT the cap rather than applying it silently: a run that quietly
187
+ // examined the first N candidates and said nothing would look identical to a
188
+ // run that found only N. Same precedent as prove-findings.js's `capped`.
189
+ // PRD C4 — drop what a previous run already refuted, BEFORE spending the
190
+ // panel's three calls per candidate on it again. Only refutals suppress: a
191
+ // candidate previously judged fresh is re-reported, because it was never
192
+ // fixed, which is the same asymmetry judge.js applies to tp/fp triage.
193
+ let rememberedRefutals = 0;
194
+ if (memory) {
195
+ const before = candidates.length;
196
+ candidates = candidates.filter(c => !previouslyRefuted(memory, c));
197
+ rememberedRefutals = before - candidates.length;
198
+ if (rememberedRefutals > 0) {
199
+ reasons.push(`${rememberedRefutals} candidate(s) were refuted by an earlier run and not re-examined ` +
200
+ '(clear with --forget-refuted if a model, ruleset or the code has changed since)');
201
+ }
202
+ }
203
+
204
+ const maxCandidates = Number.isInteger(opts.maxCandidates) && opts.maxCandidates >= 0
205
+ ? opts.maxCandidates : DEFAULT_MAX_CANDIDATES;
206
+ let candidatesCapped = 0;
207
+ if (candidates.length > maxCandidates) {
208
+ candidatesCapped = candidates.length - maxCandidates;
209
+ // Deterministic: candidates arrive in a stable (area, lens) order, so the
210
+ // cap keeps the same prefix on every run over the same inputs.
211
+ candidates = candidates.slice(0, maxCandidates);
212
+ reasons.push(`candidate cap: ${candidatesCapped} candidate(s) were NOT confirmed or refuted ` +
213
+ `(cap ${maxCandidates}); they are neither findings nor cleared — they were not examined`);
214
+ }
215
+
216
+ // PRD D3 — the hybrid-loop uplift measurement.
217
+ //
218
+ // `confirm: false` runs the pipeline with the deterministic gate switched OFF,
219
+ // so every candidate reaches the panel as `unconfirmed`. Running a population
220
+ // both ways and diffing the result isolates what the taint engine contributes
221
+ // on top of the model — a number no surveyed competitor can compute, because
222
+ // none of them has a deterministic layer to switch off.
223
+ //
224
+ // It exists ONLY to be measured against. It is not a performance switch, and
225
+ // a run with it disabled is strictly weaker: severity collapses to `low` for
226
+ // everything, since the confirmation tier is what sets it.
227
+ const confirmationEnabled = opts.confirm !== false;
228
+ const taintProbe = confirmationEnabled ? makeTaintProbe(ctx.perFileIR, ctx.callGraph) : null;
229
+ if (!confirmationEnabled) {
230
+ reasons.push('deterministic confirmation was DISABLED for this run (uplift measurement); ' +
231
+ 'every candidate is reported unconfirmed and severity is not evidence-derived');
232
+ }
101
233
  const confirmed = await confirmAll(candidates, { taintProbe });
102
- const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke: opts.llmInvoke });
234
+ const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke });
103
235
  const { fresh, duplicates, suppressed } = judgeCandidates(survivors, ctx.priorScan, ctx.triageFeedback);
104
236
 
237
+ // A spent budget is a coverage gap, stated once at the top level rather than
238
+ // left to be inferred from N identical per-run degradation reasons.
239
+ if (budget.exhaustedReason) {
240
+ reasons.push(`RUN INCOMPLETE — ${budget.exhaustedReason}. Work remained when the budget ran ` +
241
+ 'out, so absence of a finding below is not evidence of absence. Raise maxLlmCalls / ' +
242
+ 'maxWallMs, or narrow the scope with --root or --lens, and re-run.');
243
+ }
244
+
105
245
  // Coverage must not stop at the hunter stage. `confirm.js` correctly never
106
246
  // lowers a candidate below `unconfirmed`, and `disprove.js` correctly lets
107
247
  // a candidate survive when no voter votes — each rule is right on its own,
@@ -124,6 +264,16 @@ export async function runDiscovery(ctx = {}, opts = {}) {
124
264
  reasons.push(`refutation panel returned no votes for any of ${panelsRun} candidate(s) — every finding below survived unrefuted, not because it withstood scrutiny`);
125
265
  }
126
266
 
267
+ // PRD C4 — fold this run into the memory so the next one can be additive
268
+ // rather than a repeat. Persistence failure is non-fatal: the report is still
269
+ // valid, it just will not inform the next run.
270
+ if (memory && opts.scanRoot) {
271
+ saveMemory(opts.scanRoot, rememberRun(memory, {
272
+ fresh, refutedCandidates: refuted,
273
+ areas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, hunted: hunted.has(a.id) })),
274
+ }));
275
+ }
276
+
127
277
  return {
128
278
  schema: 'agentic-security/discovery@1',
129
279
  focusAreas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, size: a.size })),
@@ -153,6 +303,28 @@ export async function runDiscovery(ctx = {}, opts = {}) {
153
303
  // of those came back with no votes at all (undecided, not refuted).
154
304
  panelsRun,
155
305
  undecidedPanels,
306
+ // PRD Phase 0 / C3 — what the run cost and whether the budget stopped it.
307
+ // `budgetExhausted` true means the report is INCOMPLETE by construction:
308
+ // work remained and was not done. Reading it as a clean result is the
309
+ // exact misreading the coverage block exists to prevent.
310
+ // PRD C4 — what history contributed, and what to hunt next. A coverage
311
+ // report says what happened; `nextWave` says what to do about it.
312
+ rememberedRefutals,
313
+ priorRuns: memory ? memory.runs : null,
314
+ nextWave: memory ? nextWavePlan(memory, areas.map(a => ({ id: a.id, label: a.label }))) : null,
315
+ llmCalls: budget.calls,
316
+ maxLlmCalls: budget.maxCalls,
317
+ // PRD N4 — the standing cost metric. C3 bounded the worst case and C4
318
+ // moved the typical case by 4x, so cost is a property that drifts across
319
+ // several workstreams rather than one that a phase finishes. A number
320
+ // that only appears when somebody goes looking regresses silently, so it
321
+ // is reported every run and carries its denominator like every other rate
322
+ // in this engine. `null` when nothing was found — dividing by zero
323
+ // findings would print Infinity and read as a catastrophe rather than as
324
+ // "there is nothing to divide".
325
+ callsPerFinding: fresh.length > 0 ? Number((budget.calls / fresh.length).toFixed(1)) : null,
326
+ budgetExhausted: Boolean(budget.exhaustedReason),
327
+ candidatesCapped,
156
328
  reasons,
157
329
  },
158
330
  };
@@ -13,7 +13,11 @@ export async function defaultLlmInvoke(prompt, opts = {}) {
13
13
  // variable they set. Reaching it is this module's entire purpose; no
14
14
  // request-controlled input exists anywhere on this path, and an operator who
15
15
  // can set this variable can already run code.
16
- const res = await fetch(process.env.AGENTIC_SECURITY_LLM_ENDPOINT, { // agentic-security-ignore: CWE-918
16
+ // `opts.endpoint` lets the consensus caller target one specific provider.
17
+ // Absent, it falls back to the single configured endpoint — so the ordinary
18
+ // single-model path is byte-identical to what it was before consensus existed.
19
+ const endpoint = opts.endpoint || process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
20
+ const res = await fetch(endpoint, { // agentic-security-ignore: CWE-918
17
21
  method: 'POST',
18
22
  headers: { 'content-type': 'application/json' },
19
23
  body: JSON.stringify({ prompt }),
@@ -24,8 +28,93 @@ export async function defaultLlmInvoke(prompt, opts = {}) {
24
28
  return typeof body === 'string' ? body : (body?.text ?? JSON.stringify(body));
25
29
  }
26
30
 
31
+ // --- PRD Phase 3 / C2: multi-model consensus --------------------------------
32
+ //
33
+ // One model's opinion is one model's opinion. Asking several INDEPENDENT
34
+ // endpoints the same question and keeping only what a majority agree on
35
+ // collapses the idiosyncratic failures of any single one — a model that
36
+ // hallucinates a sink, or that is simply having a bad day on a prompt shape.
37
+ //
38
+ // WHY IT LIVES HERE AND NOWHERE ELSE. Every LLM call in the discovery layer
39
+ // already funnels through `resolveLlmInvoke`. Consensus is therefore a property
40
+ // of the seam, not of the hunter or the panel, and adding a provider cannot
41
+ // require touching either.
42
+ //
43
+ // WHAT CONSENSUS DOES AND DOES NOT MEAN. It reduces variance. It does NOT make
44
+ // the answer true — three models can agree and all be wrong, which is precisely
45
+ // why the deterministic confirmation gate still runs afterwards and still sets
46
+ // severity. Consensus is a noise filter in front of the real check, never a
47
+ // replacement for it.
48
+ //
49
+ // A provider that errors is EXCLUDED from the vote, not counted as dissent —
50
+ // the same rule `disprove.js` applies to its voters, for the same reason: an
51
+ // outage must never look like disagreement.
52
+ // Internal: read by resolveLlmInvoke below. Exporting it with no external
53
+ // caller is shipped dead code by the dead-module guard's definition.
54
+ const DEFAULT_CONSENSUS_ENV = 'AGENTIC_SECURITY_LLM_ENDPOINTS';
55
+
56
+ /** Split a comma-separated endpoint list into distinct URLs. */
57
+ export function parseEndpoints(raw) {
58
+ return String(raw || '')
59
+ .split(',')
60
+ .map(s => s.trim())
61
+ .filter(Boolean)
62
+ .filter((v, i, a) => a.indexOf(v) === i); // duplicates would fake agreement
63
+ }
64
+
65
+ /**
66
+ * Combine N responses into one, keeping the most common answer.
67
+ *
68
+ * Ties are resolved towards the FIRST endpoint listed, deterministically, rather
69
+ * than arbitrarily — a caller ordering their endpoints by trust should get the
70
+ * behaviour that ordering implies, and a random tie-break would make the whole
71
+ * pipeline non-reproducible.
72
+ */
73
+ export function consensusOf(responses) {
74
+ const usable = (responses || []).filter(r => typeof r === 'string' && r.trim());
75
+ if (usable.length === 0) return { value: null, agreement: 0, voters: 0 };
76
+ const counts = new Map();
77
+ for (const r of usable) counts.set(r, (counts.get(r) || 0) + 1);
78
+ let best = usable[0];
79
+ let bestCount = counts.get(best);
80
+ for (const r of usable) {
81
+ const c = counts.get(r);
82
+ if (c > bestCount) { best = r; bestCount = c; }
83
+ }
84
+ return { value: best, agreement: bestCount / usable.length, voters: usable.length };
85
+ }
86
+
87
+ /**
88
+ * An llmInvoke that queries several endpoints and returns the consensus answer.
89
+ * Returns null when no endpoint answered — the callers already treat a null or
90
+ * a throw as degradation, so an all-providers-down run degrades honestly.
91
+ */
92
+ function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
93
+ const list = parseEndpoints(endpoints);
94
+ if (list.length === 0) return null;
95
+ return async (prompt) => {
96
+ const answers = await Promise.all(list.map(async (url) => {
97
+ try { return await defaultLlmInvoke(prompt, { timeoutMs, endpoint: url }); }
98
+ catch { return null; } // excluded from the vote, never counted as dissent
99
+ }));
100
+ const { value } = consensusOf(answers);
101
+ if (value === null) throw new Error('no LLM endpoint answered');
102
+ return value;
103
+ };
104
+ }
105
+
27
106
  export function resolveLlmInvoke(opts = {}) {
107
+ // Precedence, most explicit first: an injected callback beats configuration,
108
+ // and a multi-endpoint list beats a single endpoint. A caller who supplied
109
+ // their own function must always get exactly that function.
28
110
  if (opts.llmInvoke) return opts.llmInvoke;
111
+
112
+ const multi = opts.endpoints || process.env[DEFAULT_CONSENSUS_ENV];
113
+ if (multi) {
114
+ const consensus = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs });
115
+ if (consensus) return consensus;
116
+ }
117
+
29
118
  if (!process.env.AGENTIC_SECURITY_LLM_ENDPOINT) return null;
30
119
  return (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs });
31
120
  }
@@ -0,0 +1,163 @@
1
+ // Cross-run discovery memory — PRD Phase 3 / C4.
2
+ //
3
+ // WHAT WAS MISSING
4
+ // ----------------
5
+ // `judge.js` dedupes a hunt against `last-scan.json` and the triage ledger, so
6
+ // it knows what the RULE ENGINE found and what a human dismissed. It has never
7
+ // known what a PREVIOUS HUNT found. Two consequences, both bad:
8
+ //
9
+ // 1. Every run re-proposes, re-confirms and re-refutes the same candidates.
10
+ // That is three LLM calls per candidate per run, spent to rediscover
11
+ // something already judged — the exact waste the Phase 0 budget exists to
12
+ // bound, being incurred deliberately.
13
+ // 2. A second run cannot be *additive*. Without a record of what was already
14
+ // examined, "hunt again" means "hunt the same thing again" rather than
15
+ // "hunt what we missed".
16
+ //
17
+ // This is that record. It turns a sequence of independent runs into a campaign.
18
+ //
19
+ // WHAT IS AND IS NOT REMEMBERED
20
+ // -----------------------------
21
+ // Remembered: every candidate ever JUDGED, with the verdict and the run that
22
+ // produced it. Also every focus area ever hunted, so coverage can become a plan
23
+ // instead of a report.
24
+ //
25
+ // NOT remembered: refuted candidates as if they were settled forever. A
26
+ // refutation is a majority opinion from three prompts on one day, not a proof.
27
+ // It suppresses re-reporting, and `--forget-refuted` exists precisely because a
28
+ // verdict made by a weaker model, or before a sanitiser was removed, must be
29
+ // re-openable. A memory you cannot clear is a memory that eventually lies.
30
+ //
31
+ // THE PRECEDENT THIS FOLLOWS
32
+ // --------------------------
33
+ // `judge.js` deliberately suppresses only `fp` triage verdicts and re-reports
34
+ // `tp` ones, because a prior true positive that is still in the code is still a
35
+ // bug. The same asymmetry holds here: a candidate previously judged FRESH is
36
+ // re-reported (it was never fixed), while one previously REFUTED is held back
37
+ // until something changes.
38
+
39
+ import * as fs from 'node:fs';
40
+ import * as path from 'node:path';
41
+
42
+ import { stateWritesEnabled } from '../posture/state-dir.js';
43
+ const MEMORY_SCHEMA = 'agentic-security/discovery-memory@1';
44
+ export const MEMORY_FILE = path.join('.agentic-security', 'discovery-memory.json');
45
+
46
+ function emptyMemory() {
47
+ return { schema: MEMORY_SCHEMA, runs: 0, candidates: {}, areas: {} };
48
+ }
49
+
50
+ /** A stable identity for a candidate across runs. */
51
+ export function memoryKey(candidate) {
52
+ // Location + family, matching judge.js's PRIMARY duplicate key. Deliberately
53
+ // NOT stableId: that is location-fuzzy by design and collides across distinct
54
+ // findings in one file, which is tolerable for a single scan's dedupe and
55
+ // corrosive when it accumulates across every run ever made.
56
+ const file = candidate?.file ?? '?';
57
+ const line = candidate?.line ?? '?';
58
+ const family = candidate?.family ?? candidate?.lens ?? '?';
59
+ return `${file}:${line}:${family}`;
60
+ }
61
+
62
+ /** Read the memory. Anything unreadable or unrecognised yields an empty one. */
63
+ export function loadMemory(scanRoot) {
64
+ try {
65
+ const doc = JSON.parse(fs.readFileSync(path.join(scanRoot, MEMORY_FILE), 'utf8'));
66
+ if (doc?.schema !== MEMORY_SCHEMA) return emptyMemory();
67
+ return { ...emptyMemory(), ...doc };
68
+ } catch {
69
+ // A corrupt memory must degrade to "remember nothing", never to a crash and
70
+ // never to a partially-trusted record. Re-hunting is cheap next to acting on
71
+ // a half-read ledger.
72
+ return emptyMemory();
73
+ }
74
+ }
75
+
76
+ /** Persist. Failure is non-fatal — the run still produced its report. */
77
+ export function saveMemory(scanRoot, memory) {
78
+ try {
79
+ const p = path.join(scanRoot, MEMORY_FILE);
80
+ if (!stateWritesEnabled()) return;
81
+ fs.mkdirSync(path.dirname(p), { recursive: true });
82
+ fs.writeFileSync(p, JSON.stringify(memory, null, 2) + '\n');
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Should this candidate be held back because a previous run already judged it?
91
+ *
92
+ * Only a REFUTED verdict suppresses. Everything else — fresh, duplicate,
93
+ * suppressed-by-triage — is re-evaluated, because those states are about the
94
+ * code and the code may have changed.
95
+ */
96
+ export function previouslyRefuted(memory, candidate) {
97
+ const rec = memory?.candidates?.[memoryKey(candidate)];
98
+ return Boolean(rec && rec.verdict === 'refuted');
99
+ }
100
+
101
+ /** Fold this run's outcome into the memory. Returns a NEW memory object. */
102
+ export function rememberRun(memory, { fresh = [], refutedCandidates = [], areas = [], at }) {
103
+ const next = {
104
+ ...emptyMemory(),
105
+ ...memory,
106
+ candidates: { ...(memory?.candidates || {}) },
107
+ areas: { ...(memory?.areas || {}) },
108
+ };
109
+ next.runs = (memory?.runs || 0) + 1;
110
+ const stamp = at || new Date().toISOString();
111
+
112
+ for (const f of fresh) {
113
+ next.candidates[memoryKey(f)] = { verdict: 'fresh', run: next.runs, at: stamp };
114
+ }
115
+ for (const c of refutedCandidates) {
116
+ next.candidates[memoryKey(c)] = { verdict: 'refuted', run: next.runs, at: stamp };
117
+ }
118
+ for (const a of areas) {
119
+ const prev = next.areas[a.id] || { hunts: 0 };
120
+ next.areas[a.id] = {
121
+ label: a.label,
122
+ hunts: prev.hunts + (a.hunted ? 1 : 0),
123
+ lastRun: a.hunted ? next.runs : (prev.lastRun ?? null),
124
+ files: a.files ?? prev.files ?? null,
125
+ };
126
+ }
127
+ return next;
128
+ }
129
+
130
+ /**
131
+ * Turn the memory into a PLAN: which areas have never been successfully hunted.
132
+ *
133
+ * This is the half that makes a second run additive rather than repetitive. A
134
+ * coverage report says what happened; this says what to do next.
135
+ */
136
+ export function nextWavePlan(memory, areas) {
137
+ const unhunted = [];
138
+ const stale = [];
139
+ for (const a of areas || []) {
140
+ const rec = memory?.areas?.[a.id];
141
+ if (!rec || rec.hunts === 0) unhunted.push(a.label || a.id);
142
+ else if (rec.lastRun !== memory.runs) stale.push(a.label || a.id);
143
+ }
144
+ return {
145
+ unhunted,
146
+ stale,
147
+ // Stated as a sentence because this lands in a report a human reads, and
148
+ // "3 areas" without saying which ones is not actionable.
149
+ summary: unhunted.length
150
+ ? `${unhunted.length} focus area(s) have NEVER been successfully hunted: ${unhunted.slice(0, 5).join(', ')}` +
151
+ (unhunted.length > 5 ? `, +${unhunted.length - 5} more` : '')
152
+ : 'every focus area has been hunted at least once',
153
+ };
154
+ }
155
+
156
+ /** Drop refuted verdicts so they can be re-examined. */
157
+ export function forgetRefuted(memory) {
158
+ const candidates = {};
159
+ for (const [k, v] of Object.entries(memory?.candidates || {})) {
160
+ if (v?.verdict !== 'refuted') candidates[k] = v;
161
+ }
162
+ return { ...emptyMemory(), ...memory, candidates };
163
+ }