@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.
- package/CHANGELOG.md +432 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +448 -74
- package/dist/113.index.js +16 -7
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +5 -4
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +300 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +196 -21
- package/dist/444.index.js +20 -11
- package/dist/449.index.js +8 -1
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +14 -14
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +24 -12
- package/src/badge.js +2 -1
- package/src/dataflow/CLAUDE.md +10 -4
- package/src/dataflow/builtin-summaries.js +1 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/engine.js +324 -60
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/implicit-flow.js +68 -36
- package/src/dataflow/incremental.js +25 -8
- package/src/dataflow/index.js +2 -1
- package/src/dataflow/proven-clean.js +41 -0
- package/src/dataflow/sanitizer-gate.js +35 -9
- package/src/dataflow/sanitizer-proof.js +21 -3
- package/src/dataflow/stub-aware-filter.js +36 -13
- package/src/dataflow/summaries.js +21 -2
- package/src/discovery/CLAUDE.md +10 -0
- package/src/discovery/index.js +175 -3
- package/src/discovery/llm-invoke.js +90 -1
- package/src/discovery/memory.js +163 -0
- package/src/engine.js +247 -50
- package/src/integrations/tickets.js +7 -6
- package/src/ir/CLAUDE.md +4 -1
- package/src/ir/balanced-call.js +55 -0
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/ir/parser-cs.js +62 -9
- package/src/ir/parser-go.js +29 -11
- package/src/ir/parser-java.js +96 -19
- package/src/ir/parser-js.js +151 -20
- package/src/ir/parser-php.js +44 -9
- package/src/ir/parser-rb.js +37 -7
- package/src/ir/ssa.js +6 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +1 -2
- package/src/lsp/server.js +28 -2
- package/src/mcp/CLAUDE.md +9 -2
- package/src/mcp/audit.js +2 -1
- package/src/mcp/redact.js +26 -0
- package/src/mcp/tools.js +159 -17
- package/src/posture/CLAUDE.md +45 -8
- package/src/posture/accuracy-scorecard.js +67 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/aibom.js +12 -8
- package/src/posture/auditor-walkthrough.js +111 -10
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +8 -1
- package/src/posture/calibration-drift.js +11 -5
- package/src/posture/calibration.js +24 -2
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
- package/src/posture/compliance-policy.js +40 -10
- package/src/posture/confidence.js +44 -10
- package/src/posture/corpus-enroll.js +9 -5
- package/src/posture/corpus-match.js +19 -0
- package/src/posture/csharp-analysis.js +62 -3
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deploy-platform.js +4 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/drift.js +7 -1
- package/src/posture/epss.js +13 -1
- package/src/posture/evidence-bundle.js +276 -0
- package/src/posture/exploitability-probability.js +15 -2
- package/src/posture/falsification.js +23 -2
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fix-verify-loop.js +10 -1
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/iac-reachability.js +14 -8
- package/src/posture/integrity.js +25 -7
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/model-rescan.js +69 -3
- package/src/posture/mttr.js +5 -0
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +27 -8
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/regression-test-gen.js +23 -8
- package/src/posture/reverse-blast-radius.js +5 -1
- package/src/posture/risk-dollars.js +20 -3
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/secret-history.js +20 -11
- package/src/posture/security-trend.js +7 -1
- package/src/posture/stack-playbook.js +22 -1
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +13 -3
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/validator-metrics.js +10 -3
- package/src/posture/verifier.js +32 -57
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +183 -14
- package/src/runScan.js +1 -1
- package/src/sast/_comment-strip.js +15 -4
- package/src/sast/_secret-entropy.js +1 -1
- package/src/sast/authz.js +6 -4
- package/src/sast/bench-shape/index.js +2 -7
- package/src/sast/claude-md-prompt-injection.js +14 -3
- package/src/sast/cloud-iam.js +60 -7
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sast/cpp-bench-extras.js +1 -1
- package/src/sast/csrf.js +7 -5
- package/src/sast/env-hygiene.js +5 -2
- package/src/sast/iac-terraform.js +25 -0
- package/src/sast/java-bench-extras.js +1 -1
- package/src/sast/java-constant-fold.js +5 -5
- package/src/sast/llm-owasp.js +4 -2
- package/src/sast/mcp-audit.js +7 -0
- package/src/sast/pipeline.js +8 -0
- package/src/sast/prompt-template.js +8 -6
- package/src/sast/prototype-pollution.js +6 -2
- package/src/sast/redos-nfa.js +6 -6
- package/src/sast/secret-concat.js +13 -2
- package/src/sast/ssrf-cloud-metadata.js +6 -3
- package/src/sast/xss-reflected-multilang.js +1 -1
- package/src/sast/xxe.js +1 -1
- package/src/sca/CLAUDE.md +3 -4
- package/src/sca/container.js +35 -3
- package/src/sca/dep-confusion.js +9 -1
- package/src/sca/sarif-ingest.js +0 -187
package/src/discovery/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|