@clear-capabilities/agentic-security-scanner 0.132.0 → 0.134.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.
- package/CHANGELOG.md +228 -0
- package/bin/agentic-security.js +103 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/499.index.js +86 -0
- package/dist/526.index.js +3 -3
- package/dist/609.index.js +741 -0
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +56 -56
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +38 -0
- package/src/discovery/confirm.js +47 -0
- package/src/discovery/disprove.js +79 -0
- package/src/discovery/hunter.js +116 -0
- package/src/discovery/index.js +159 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +31 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +151 -1
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +254 -35
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/llm-validator/providers.js +227 -0
- package/src/posture/CLAUDE.md +76 -0
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/corpus-match.js +29 -14
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/logic-claims.js +266 -0
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +567 -0
- package/src/posture/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +172 -0
- package/src/posture/rule-overrides.js +64 -3
- package/src/posture/state-dir.js +25 -0
- package/src/posture/vuln-archaeology.js +231 -0
- package/src/report/index.js +16 -0
- package/src/sandbox/CLAUDE.md +27 -5
- package/src/sandbox/backend-namespace.js +39 -11
- package/src/sandbox/backend-userspace.js +4 -0
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
|
@@ -33,6 +33,26 @@
|
|
|
33
33
|
// file:line mismatch → verdict='escalate' (KEEP the finding). The
|
|
34
34
|
// validator can NEVER silently reject a finding it didn't successfully
|
|
35
35
|
// verify.
|
|
36
|
+
// 8. A `reject` cannot DELETE a strongly-provenanced finding (IR-TAINT,
|
|
37
|
+
// MULTI-SINK, execution-proven) — it is demoted to `escalate` and the
|
|
38
|
+
// refusal is recorded. See `applyValidatorVerdicts`.
|
|
39
|
+
// 9. The response cache is HMAC-signed and verified on read; an unsigned,
|
|
40
|
+
// tampered or foreign-keyed entry is a MISS, never a verdict.
|
|
41
|
+
//
|
|
42
|
+
// WHAT ITEMS 1-6 DO AND DO NOT BUY, stated precisely because an earlier version
|
|
43
|
+
// of this header overstated it. The delimiters, challenge and file:line echo
|
|
44
|
+
// defend against a FORGED or REPLAYED response — they prove the reply came from
|
|
45
|
+
// a model that saw this prompt and judged this finding. They do NOT prevent the
|
|
46
|
+
// model being PERSUADED by instructions inside the code it legitimately read:
|
|
47
|
+
// such a reply echoes the challenge correctly because the model really did see
|
|
48
|
+
// it. Item 4 asks the model to self-report injection attempts, and the
|
|
49
|
+
// `/prompt-injection/i` check in `validateResponse` acts on that — but it only
|
|
50
|
+
// fires when the model volunteers the phrase, so "reject, and don't mention
|
|
51
|
+
// injection" walks past it.
|
|
52
|
+
//
|
|
53
|
+
// That is why items 8 and 9 exist. The property "an attacker cannot cause a
|
|
54
|
+
// finding to be deleted" is now enforced structurally for findings real
|
|
55
|
+
// analysis produced, rather than inferred from prompt hardening.
|
|
36
56
|
// 6. The reasoning string is sanitized before storing/rendering — stops
|
|
37
57
|
// secondary markdown/HTML injection into reports.
|
|
38
58
|
// 7. Concurrent worker pool replaced with deterministic sorted iteration
|
|
@@ -46,10 +66,23 @@ import * as path from 'node:path';
|
|
|
46
66
|
import * as crypto from 'node:crypto';
|
|
47
67
|
import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
|
|
48
68
|
import { redactSecrets } from './redact.js';
|
|
69
|
+
import { signLastScan } from '../posture/integrity.js';
|
|
49
70
|
|
|
50
71
|
// Bump on every prompt change so the cache invalidates. Exported as a
|
|
51
72
|
// stable public symbol (premortem 4R-15) so the validator-cache GC subcommand
|
|
52
73
|
// doesn't have to reach through the `_internal` underscore-prefixed export.
|
|
74
|
+
import { createCostLedger, parseCapUsd, renderCostCeiling } from './cost-ceiling.js';
|
|
75
|
+
import { localEndpointConfig } from './local-endpoint.js';
|
|
76
|
+
import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
|
|
77
|
+
|
|
78
|
+
// The output cap we request. Shared with the cost estimate so the ceiling
|
|
79
|
+
// charges exactly what we permit the model to produce.
|
|
80
|
+
const MAX_OUTPUT_TOKENS = 512;
|
|
81
|
+
|
|
82
|
+
// Why the local preset declined, if it did. Surfaced on the batch so a refusal
|
|
83
|
+
// reads as a refusal rather than as "no endpoint configured".
|
|
84
|
+
let _localPresetRefusal = null;
|
|
85
|
+
|
|
53
86
|
export const PROMPT_VERSION = 'v2.0-hardened';
|
|
54
87
|
const CACHE_DIR = '.agentic-security/llm-cache';
|
|
55
88
|
|
|
@@ -92,45 +125,63 @@ Snippet (single line, trusted from scanner output): {{snippet}}
|
|
|
92
125
|
Reply now with the JSON object on the last line of your response. Nothing else after it.
|
|
93
126
|
`;
|
|
94
127
|
|
|
128
|
+
// Delegates to the provider seam (PRD Epic 3). Kept as a thin adapter rather
|
|
129
|
+
// than deleted: every call site, test and cost-ceiling path already speaks this
|
|
130
|
+
// shape, and changing a seam and all its consumers at once is how a refactor
|
|
131
|
+
// becomes a regression. `_localPresetRefusal` still carries a REFUSAL
|
|
132
|
+
// distinctly from "nothing configured" — the local preset declining a remote
|
|
133
|
+
// endpoint must not read as an absent config.
|
|
95
134
|
function endpointConfig() {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
endpoint: 'https://api.anthropic.com/v1/messages',
|
|
110
|
-
apiKey,
|
|
111
|
-
model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'claude-haiku-4-5',
|
|
112
|
-
preset: 'anthropic',
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
return null;
|
|
135
|
+
const r = resolveProvider({ role: 'validate' });
|
|
136
|
+
if (!r.ok) { _localPresetRefusal = r.reason || null; return null; }
|
|
137
|
+
_localPresetRefusal = null;
|
|
138
|
+
const c = r.config;
|
|
139
|
+
return {
|
|
140
|
+
endpoint: c.endpoint,
|
|
141
|
+
apiKey: c.apiKey,
|
|
142
|
+
model: c.model,
|
|
143
|
+
preset: c.provider === 'anthropic' ? 'anthropic' : (c.provider === 'local' ? 'local' : null),
|
|
144
|
+
provider: c.provider,
|
|
145
|
+
egress: c.egress,
|
|
146
|
+
_shape: c.shape,
|
|
147
|
+
};
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
// Shape the request for the target: the Anthropic Messages API needs an
|
|
119
151
|
// x-api-key header (added by the caller), an anthropic-version header, and a
|
|
120
152
|
// {model, max_tokens, messages:[…]} body with the reply in content[].text. The
|
|
121
153
|
// generic path posts {prompt, model} with a Bearer header. Pure — no I/O.
|
|
122
|
-
function buildRequest(model, prompt, preset) {
|
|
154
|
+
function buildRequest(model, prompt, preset, shape) {
|
|
155
|
+
// A resolved provider carries its own wire shape; use it. The hand-written
|
|
156
|
+
// branches below remain for callers that pass only a preset string.
|
|
157
|
+
if (shape) return buildProviderRequest({ shape, model, apiKey: null }, prompt, MAX_OUTPUT_TOKENS);
|
|
123
158
|
if (preset === 'anthropic') {
|
|
124
159
|
return {
|
|
125
160
|
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
126
|
-
body: { model, max_tokens:
|
|
161
|
+
body: { model, max_tokens: MAX_OUTPUT_TOKENS, messages: [{ role: 'user', content: prompt }] },
|
|
127
162
|
extractText: (j) => (Array.isArray(j?.content) ? j.content.filter(b => b?.type === 'text').map(b => b.text || '').join('') : ''),
|
|
163
|
+
// R12 — real token usage, so the cost ledger books what was actually
|
|
164
|
+
// spent instead of the worst case it had to assume beforehand.
|
|
165
|
+
extractUsage: (j) => (j?.usage && Number.isFinite(j.usage.input_tokens)
|
|
166
|
+
? { inputTokens: j.usage.input_tokens, outputTokens: j.usage.output_tokens || 0 }
|
|
167
|
+
: null),
|
|
128
168
|
};
|
|
129
169
|
}
|
|
130
170
|
return {
|
|
131
171
|
headers: { 'Content-Type': 'application/json' },
|
|
132
172
|
body: { prompt, model },
|
|
133
173
|
extractText: (j) => (j && (j.response || j.text || j.content || j.output || j.choices?.[0]?.message?.content || j.message?.content)) || '',
|
|
174
|
+
// OpenAI-compatible servers (and most local ones) report usage in this
|
|
175
|
+
// shape. A server that reports nothing yields null, and the ledger then
|
|
176
|
+
// records an ESTIMATE and says so — it never silently presents one as the
|
|
177
|
+
// other.
|
|
178
|
+
extractUsage: (j) => {
|
|
179
|
+
const u = j?.usage;
|
|
180
|
+
if (!u) return null;
|
|
181
|
+
const inputTokens = u.prompt_tokens ?? u.input_tokens;
|
|
182
|
+
const outputTokens = u.completion_tokens ?? u.output_tokens ?? 0;
|
|
183
|
+
return Number.isFinite(inputTokens) ? { inputTokens, outputTokens } : null;
|
|
184
|
+
},
|
|
134
185
|
};
|
|
135
186
|
}
|
|
136
187
|
|
|
@@ -150,15 +201,78 @@ function cacheKey(finding, fileHash, modelId) {
|
|
|
150
201
|
return crypto.createHash('sha256').update(material).digest('hex');
|
|
151
202
|
}
|
|
152
203
|
|
|
204
|
+
// The cache is INTEGRITY-PROTECTED, and it has to be: a cache hit assigns a
|
|
205
|
+
// verdict directly, and a `reject` verdict DELETES a finding. That made the
|
|
206
|
+
// cache a deletion primitive — planting one JSON file under
|
|
207
|
+
// `.agentic-security/llm-cache/` removed a critical finding with no model call
|
|
208
|
+
// and no network, deterministically. The key is derivable by anyone with repo
|
|
209
|
+
// access (file hash, path, prompt version, model id are all knowable), and the
|
|
210
|
+
// realistic delivery vector is not a repo write at all: CI restores cache
|
|
211
|
+
// directories between runs.
|
|
212
|
+
//
|
|
213
|
+
// `last-scan.json` has been HMAC-signed for exactly this reason. The cache that
|
|
214
|
+
// can delete findings had nothing. Same mechanism, same key handling — no
|
|
215
|
+
// second crypto path is introduced.
|
|
216
|
+
//
|
|
217
|
+
// AN UNVERIFIABLE ENTRY IS A MISS, NEVER A VERDICT. That includes the entry
|
|
218
|
+
// being unsigned, signed under a different install key, or structurally
|
|
219
|
+
// malformed. The cost of a miss is one model call; the cost of trusting a
|
|
220
|
+
// planted entry is a silently deleted vulnerability.
|
|
221
|
+
function _cacheSignable(value) {
|
|
222
|
+
// Sign the fields that carry meaning. Signing the serialised object would
|
|
223
|
+
// make the signature depend on key order and on any field added later.
|
|
224
|
+
return [
|
|
225
|
+
String(value?.verdict ?? ''),
|
|
226
|
+
String(value?.confidence ?? ''),
|
|
227
|
+
String(value?.reasoning ?? ''),
|
|
228
|
+
String(value?.model ?? ''),
|
|
229
|
+
String(value?.prompt_version ?? ''),
|
|
230
|
+
].join('\u0000');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// A cache entry that EXISTS but does not verify is a different event from an
|
|
234
|
+
// ordinary miss, and it must be countable. In CI the per-install key is
|
|
235
|
+
// regenerated every run (nothing persists `$XDG_CONFIG_HOME`), so a restored
|
|
236
|
+
// cache directory verifies against nothing and the hit rate is silently zero —
|
|
237
|
+
// the operator pays for every call twice over and sees no signal. Fail-closed is
|
|
238
|
+
// right; failing closed invisibly is not.
|
|
239
|
+
const _cacheStats = { hits: 0, misses: 0, unverified: 0 };
|
|
240
|
+
function cacheStats() { return { ..._cacheStats }; }
|
|
241
|
+
function _resetCacheStatsForTests() { _cacheStats.hits = 0; _cacheStats.misses = 0; _cacheStats.unverified = 0; }
|
|
242
|
+
|
|
153
243
|
function readCache(scanRoot, key) {
|
|
154
244
|
const fp = statePath(scanRoot, 'llm-cache', key + '.json');
|
|
155
|
-
if (!fs.existsSync(fp)) return null;
|
|
156
|
-
|
|
245
|
+
if (!fs.existsSync(fp)) { _cacheStats.misses++; return null; }
|
|
246
|
+
let raw;
|
|
247
|
+
try { raw = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { _cacheStats.unverified++; return null; }
|
|
248
|
+
if (!raw || typeof raw !== 'object') { _cacheStats.unverified++; return null; }
|
|
249
|
+
|
|
250
|
+
// Verdict allowlist on READ, mirroring validateResponse. A cached verdict is
|
|
251
|
+
// as much untrusted input as a model response is.
|
|
252
|
+
if (!['accept', 'reject', 'escalate'].includes(raw.verdict)) { _cacheStats.unverified++; return null; }
|
|
253
|
+
|
|
254
|
+
const sig = typeof raw.sig === 'string' ? raw.sig : null;
|
|
255
|
+
if (!sig) { _cacheStats.unverified++; return null; }
|
|
256
|
+
let expected;
|
|
257
|
+
try { expected = signLastScan(_cacheSignable(raw)); } catch { _cacheStats.unverified++; return null; }
|
|
258
|
+
// Constant-time compare; length mismatch short-circuits before timingSafeEqual
|
|
259
|
+
// (which throws on unequal lengths).
|
|
260
|
+
if (sig.length !== expected.length) { _cacheStats.unverified++; return null; }
|
|
261
|
+
try {
|
|
262
|
+
if (!crypto.timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'))) {
|
|
263
|
+
_cacheStats.unverified++; return null;
|
|
264
|
+
}
|
|
265
|
+
} catch { _cacheStats.unverified++; return null; }
|
|
266
|
+
_cacheStats.hits++;
|
|
267
|
+
return raw;
|
|
157
268
|
}
|
|
158
269
|
|
|
159
270
|
function writeCache(scanRoot, key, value) {
|
|
160
271
|
const fp = statePath(scanRoot, 'llm-cache', key + '.json');
|
|
161
|
-
|
|
272
|
+
let signed = value;
|
|
273
|
+
try { signed = { ...value, sig: signLastScan(_cacheSignable(value)) }; }
|
|
274
|
+
catch { return; } // cannot sign -> do not cache; an unsignable entry would never be read back
|
|
275
|
+
safeWriteState(fp, JSON.stringify(signed, null, 2));
|
|
162
276
|
}
|
|
163
277
|
|
|
164
278
|
function fileHashOf(fileContents, file) {
|
|
@@ -221,8 +335,8 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
|
|
|
221
335
|
.replace('{{context}}', sterileContext || '(no surrounding code available)');
|
|
222
336
|
}
|
|
223
337
|
|
|
224
|
-
async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
|
|
225
|
-
const { headers, body, extractText } = buildRequest(model, prompt, preset);
|
|
338
|
+
async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shape = null) {
|
|
339
|
+
const { headers, body, extractText, extractUsage } = buildRequest(model, prompt, preset, shape);
|
|
226
340
|
if (apiKey) {
|
|
227
341
|
if (preset === 'anthropic') headers['x-api-key'] = apiKey;
|
|
228
342
|
else headers['Authorization'] = `Bearer ${apiKey}`;
|
|
@@ -231,7 +345,7 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
|
|
|
231
345
|
const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
232
346
|
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
|
|
233
347
|
const j = await r.json().catch(() => null);
|
|
234
|
-
return { ok: true, text: String(extractText(j) || '') };
|
|
348
|
+
return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
|
|
235
349
|
} catch (e) {
|
|
236
350
|
return { ok: false, error: e.message };
|
|
237
351
|
}
|
|
@@ -322,7 +436,7 @@ export function validateResponse(obj, { challenge, file, line }) {
|
|
|
322
436
|
// Pre-flight (premortem 2R2.2): findings WITHOUT a precise file:line cannot
|
|
323
437
|
// be cross-checked against the LLM response (the model can trivially echo
|
|
324
438
|
// empty/zero values). Such findings are marked unvalidated and KEPT.
|
|
325
|
-
export async function validateOne(finding, fileContents, scanRoot) {
|
|
439
|
+
export async function validateOne(finding, fileContents, scanRoot, ledger = null) {
|
|
326
440
|
const cfg = endpointConfig();
|
|
327
441
|
if (!cfg) {
|
|
328
442
|
finding.validator_verdict = 'unvalidated';
|
|
@@ -371,7 +485,40 @@ export async function validateOne(finding, fileContents, scanRoot) {
|
|
|
371
485
|
const challenge = crypto.randomBytes(8).toString('hex');
|
|
372
486
|
const nonce = crypto.randomBytes(8).toString('hex');
|
|
373
487
|
const prompt = renderPrompt(finding, fileContents, challenge, nonce);
|
|
374
|
-
|
|
488
|
+
|
|
489
|
+
// R12 — the hard ceiling. Checked BEFORE the call, against a conservative
|
|
490
|
+
// estimate: input from the prompt we are about to send, output at the full
|
|
491
|
+
// max_tokens we allow. Charging the worst case is the only direction that
|
|
492
|
+
// cannot overshoot, and checking afterwards would let a call blow the cap
|
|
493
|
+
// and report the overrun as already spent.
|
|
494
|
+
const _est = {
|
|
495
|
+
inputTokens: Math.ceil(prompt.length / 4),
|
|
496
|
+
outputTokens: MAX_OUTPUT_TOKENS,
|
|
497
|
+
};
|
|
498
|
+
if (ledger) {
|
|
499
|
+
const afford = ledger.canAfford(_est);
|
|
500
|
+
if (!afford.ok) {
|
|
501
|
+
// Explicitly unvalidated, with the reason. NOT downgraded to a cheaper
|
|
502
|
+
// model and NOT treated as accepted — a finding nobody checked must not
|
|
503
|
+
// read like one that passed.
|
|
504
|
+
finding.validator_verdict = 'unvalidated';
|
|
505
|
+
finding.unvalidated = true;
|
|
506
|
+
finding.validator_skipped_reason = afford.reason;
|
|
507
|
+
return { verdict: 'unvalidated', error: 'cost-ceiling' };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset, cfg._shape);
|
|
512
|
+
// Record actual usage when the endpoint reports it, else the estimate. An
|
|
513
|
+
// unreported call is never free — but the two are recorded DISTINCTLY, so
|
|
514
|
+
// the reported spend can say which it is. Presenting an upper bound as a
|
|
515
|
+
// measurement is the defect this distinction exists to prevent: the estimate
|
|
516
|
+
// charges the full max_tokens for output, which most replies never reach.
|
|
517
|
+
if (ledger) {
|
|
518
|
+
const u = resp?.usage;
|
|
519
|
+
if (u) ledger.record(u, { measured: true });
|
|
520
|
+
else ledger.record(_est, { measured: false });
|
|
521
|
+
}
|
|
375
522
|
if (!resp.ok) {
|
|
376
523
|
finding.validator_verdict = 'unvalidated';
|
|
377
524
|
finding.unvalidated = true;
|
|
@@ -420,7 +567,9 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
420
567
|
for (const f of findings) {
|
|
421
568
|
f.validator_verdict = 'unvalidated';
|
|
422
569
|
f.unvalidated = true;
|
|
570
|
+
if (_localPresetRefusal) f.validator_skipped_reason = _localPresetRefusal;
|
|
423
571
|
}
|
|
572
|
+
if (_localPresetRefusal) findings.localPathRefusal = _localPresetRefusal;
|
|
424
573
|
return findings;
|
|
425
574
|
}
|
|
426
575
|
const candidates = findings.filter(f =>
|
|
@@ -432,11 +581,28 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
432
581
|
const kb = (b.stableId || b.id || '');
|
|
433
582
|
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
434
583
|
});
|
|
584
|
+
// R12 — one ledger for the whole batch. A per-call cap would be no cap at
|
|
585
|
+
// all: N calls each under the limit is how you get an N-times overrun.
|
|
586
|
+
let ledger = null;
|
|
587
|
+
try {
|
|
588
|
+
const capUsd = parseCapUsd();
|
|
589
|
+
if (capUsd != null) ledger = createCostLedger({ capUsd, model: cfg.model });
|
|
590
|
+
} catch (e) {
|
|
591
|
+
// A malformed cap is fatal for the tier, not ignored. Continuing with "no
|
|
592
|
+
// cap" would turn a typo into unlimited spend.
|
|
593
|
+
for (const f of findings) {
|
|
594
|
+
f.validator_verdict = 'unvalidated';
|
|
595
|
+
f.unvalidated = true;
|
|
596
|
+
f.validator_skipped_reason = e.message;
|
|
597
|
+
}
|
|
598
|
+
return findings;
|
|
599
|
+
}
|
|
600
|
+
|
|
435
601
|
let i = 0;
|
|
436
602
|
async function worker() {
|
|
437
603
|
while (i < candidates.length) {
|
|
438
604
|
const idx = i++;
|
|
439
|
-
try { await validateOne(candidates[idx], fileContents, scanRoot); }
|
|
605
|
+
try { await validateOne(candidates[idx], fileContents, scanRoot, ledger); }
|
|
440
606
|
catch (e) {
|
|
441
607
|
// FAIL-CLOSED on exception too.
|
|
442
608
|
candidates[idx].validator_verdict = 'escalate';
|
|
@@ -445,6 +611,27 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
445
611
|
}
|
|
446
612
|
}
|
|
447
613
|
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
|
|
614
|
+
if (ledger) {
|
|
615
|
+
// Surfaced on the batch so a report can state what was spent and, more
|
|
616
|
+
// importantly, what was NOT checked because the ceiling bound.
|
|
617
|
+
findings.costCeiling = ledger.state();
|
|
618
|
+
findings.costCeilingSummary = renderCostCeiling(ledger.state());
|
|
619
|
+
}
|
|
620
|
+
// Which provider each role would use. No keys, ever — this is reported.
|
|
621
|
+
findings.providerMatrix = providerMatrix();
|
|
622
|
+
const _cs = cacheStats();
|
|
623
|
+
findings.validatorCache = _cs;
|
|
624
|
+
if (_cs.unverified > 0) {
|
|
625
|
+
// Loud, because the common cause is structural rather than an attack: a CI
|
|
626
|
+
// runner that regenerates the per-install key every run can never verify a
|
|
627
|
+
// restored cache, so every call is re-paid silently.
|
|
628
|
+
try {
|
|
629
|
+
process.stderr.write(
|
|
630
|
+
`agentic-security: ${_cs.unverified} validator-cache entr(y/ies) present but UNVERIFIED `
|
|
631
|
+
+ '(treated as misses). If this is CI, the per-install HMAC key is probably regenerated each '
|
|
632
|
+
+ 'run — set AGENTIC_SECURITY_HMAC_KEY to a stable secret, or expect a permanent 0% hit rate.\n');
|
|
633
|
+
} catch {}
|
|
634
|
+
}
|
|
448
635
|
for (const f of findings) {
|
|
449
636
|
if (f.validator_verdict) continue;
|
|
450
637
|
f.validator_verdict = 'unvalidated';
|
|
@@ -453,16 +640,48 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
453
640
|
return findings;
|
|
454
641
|
}
|
|
455
642
|
|
|
643
|
+
// Provenance strong enough that a model's opinion must not delete it. These
|
|
644
|
+
// findings were produced by real analysis — an interprocedural taint path, a
|
|
645
|
+
// multi-sink correlation, or a proof-of-concept that actually executed — so a
|
|
646
|
+
// `reject` against one is far more likely to be a manipulated or mistaken
|
|
647
|
+
// verdict than a correct dismissal.
|
|
648
|
+
const _STRONG_PARSERS = new Set(['IR-TAINT', 'MULTI-SINK']);
|
|
649
|
+
function _stronglyProvenanced(f) {
|
|
650
|
+
return _STRONG_PARSERS.has(f?.parser) || f?.proofTier === 'execution-proven';
|
|
651
|
+
}
|
|
652
|
+
|
|
456
653
|
// Apply validator verdicts: reject → drop, escalate → keep but mark, accept →
|
|
457
654
|
// boost confidence. Returns { kept, dropped }.
|
|
458
655
|
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
656
|
+
// THE ASYMMETRY, AND WHY IT USED TO BE OVERSTATED. Only `reject` drops;
|
|
657
|
+
// `escalate` and `accept` keep. This comment previously concluded from that
|
|
658
|
+
// that "prompt-injection of the validator is harmless — the worst an attacker
|
|
659
|
+
// can produce is escalate". That was FALSE, and the falseness mattered because
|
|
660
|
+
// it stopped anyone looking: the model reads adversary-controlled source, and
|
|
661
|
+
// the only thing converting an injected `reject` into `escalate` was a regex in
|
|
662
|
+
// `validateResponse` testing whether the model VOLUNTEERED the phrase
|
|
663
|
+
// "prompt-injection" in its reasoning. An injected instruction that says
|
|
664
|
+
// "reject, and do not mention injection" walks straight past it. The
|
|
665
|
+
// challenge/nonce cross-check defends against forged and replayed responses —
|
|
666
|
+
// not against a model being persuaded by content it legitimately read.
|
|
667
|
+
//
|
|
668
|
+
// So the guarantee is now enforced structurally rather than asserted:
|
|
669
|
+
// a `reject` can never delete a strongly-provenanced finding. For those, the
|
|
670
|
+
// worst any verdict can do is demote to `escalate`, which keeps them. The claim
|
|
671
|
+
// and the code now agree.
|
|
462
672
|
export function applyValidatorVerdicts(findings) {
|
|
463
673
|
const kept = [];
|
|
464
674
|
const dropped = [];
|
|
465
675
|
for (const f of findings) {
|
|
676
|
+
if (f.validator_verdict === 'reject' && _stronglyProvenanced(f)) {
|
|
677
|
+
// Downgrade rather than drop, and record why, so this is visible in the
|
|
678
|
+
// report instead of looking like an ordinary escalate.
|
|
679
|
+
f.validator_verdict = 'escalate';
|
|
680
|
+
f.validator_reject_refused = 'strong provenance: a model verdict may not delete a '
|
|
681
|
+
+ 'taint-proven, multi-sink or execution-proven finding';
|
|
682
|
+
kept.push(f);
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
466
685
|
if (f.validator_verdict === 'reject') {
|
|
467
686
|
f._droppedBy = 'llm-validator';
|
|
468
687
|
dropped.push(f);
|
|
@@ -478,4 +697,4 @@ export function applyValidatorVerdicts(findings) {
|
|
|
478
697
|
return { kept, dropped };
|
|
479
698
|
}
|
|
480
699
|
|
|
481
|
-
export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey, endpointConfig, buildRequest };
|
|
700
|
+
export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey, endpointConfig, buildRequest, readCache, writeCache, ensureCacheDir, cacheStats, _resetCacheStatsForTests };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// R11 — the local / offline model path.
|
|
2
|
+
//
|
|
3
|
+
// A BYO endpoint already existed (`AGENTIC_SECURITY_LLM_ENDPOINT`), and you
|
|
4
|
+
// could always point it at something on localhost. What did not exist is a
|
|
5
|
+
// path that GUARANTEES the prompt never leaves the machine. That distinction
|
|
6
|
+
// is the whole feature: the reason to run a local model is usually that the
|
|
7
|
+
// code being analysed must not be sent anywhere, and a configuration that
|
|
8
|
+
// merely happens to be local today provides no such assurance. One typo, one
|
|
9
|
+
// inherited environment variable, and source is on someone else's server.
|
|
10
|
+
//
|
|
11
|
+
// So the `local` preset ENFORCES loopback. An endpoint that resolves to
|
|
12
|
+
// anything other than a loopback literal is refused outright rather than
|
|
13
|
+
// called. The check is on the host literal, not on DNS: resolving a name would
|
|
14
|
+
// mean trusting a resolver, and a name that answers 127.0.0.1 today can answer
|
|
15
|
+
// something else tomorrow. Only literal loopback forms are accepted, so the
|
|
16
|
+
// guarantee cannot be undone by a DNS entry.
|
|
17
|
+
//
|
|
18
|
+
// OFFLINE DEGRADATION. A local server that is not running is the normal case,
|
|
19
|
+
// not an error: the validator tier is optional and the scan must complete
|
|
20
|
+
// without it. An unreachable local endpoint leaves findings `unvalidated` with
|
|
21
|
+
// a reason naming the endpoint, exactly like every other validator failure.
|
|
22
|
+
// The connect timeout is short for the same reason — a dead port must not hang
|
|
23
|
+
// a scan.
|
|
24
|
+
|
|
25
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1/chat/completions';
|
|
28
|
+
export const DEFAULT_LOCAL_TIMEOUT_MS = 20000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Is this URL unambiguously loopback?
|
|
32
|
+
*
|
|
33
|
+
* Deliberately strict and literal. Anything unparseable, non-http(s), or
|
|
34
|
+
* hostname-based is NOT loopback — the answer to "am I sure this stays on this
|
|
35
|
+
* machine?" must be yes or no, never "probably".
|
|
36
|
+
*/
|
|
37
|
+
export function isLoopbackUrl(url) {
|
|
38
|
+
let u;
|
|
39
|
+
try { u = new URL(String(url)); } catch { return false; }
|
|
40
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
41
|
+
const host = u.hostname.toLowerCase();
|
|
42
|
+
if (LOOPBACK_HOSTS.has(host)) return true;
|
|
43
|
+
// The whole 127.0.0.0/8 block is loopback.
|
|
44
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
|
|
45
|
+
return host.split('.').slice(1).every(o => Number(o) >= 0 && Number(o) <= 255);
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the local-preset configuration.
|
|
52
|
+
*
|
|
53
|
+
* @returns {{ok:true, config:object} | {ok:false, reason:string}}
|
|
54
|
+
*/
|
|
55
|
+
export function localEndpointConfig(env = process.env) {
|
|
56
|
+
const endpoint = env.AGENTIC_SECURITY_LLM_ENDPOINT || DEFAULT_LOCAL_ENDPOINT;
|
|
57
|
+
if (!isLoopbackUrl(endpoint)) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
reason: `the 'local' preset refuses a non-loopback endpoint: ${endpoint}. `
|
|
61
|
+
+ 'The point of the local path is that source never leaves this machine, so a remote '
|
|
62
|
+
+ 'address is refused rather than silently used. Only literal loopback addresses are '
|
|
63
|
+
+ 'accepted — a hostname is not, because a resolver could point it anywhere. '
|
|
64
|
+
+ 'Use AGENTIC_SECURITY_LLM_PRESET=anthropic (or a BYO endpoint) to call out deliberately.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const timeoutRaw = Number(env.AGENTIC_SECURITY_LLM_TIMEOUT_MS);
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
config: {
|
|
71
|
+
endpoint,
|
|
72
|
+
// No key. A local server that demands one can still receive it via the
|
|
73
|
+
// BYO path; the local preset does not invent credentials.
|
|
74
|
+
apiKey: env.AGENTIC_SECURITY_LLM_API_KEY || null,
|
|
75
|
+
model: env.AGENTIC_SECURITY_LLM_MODEL || 'local-model',
|
|
76
|
+
preset: 'local',
|
|
77
|
+
timeoutMs: Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_LOCAL_TIMEOUT_MS,
|
|
78
|
+
// Stated so a report can say plainly that nothing left the machine.
|
|
79
|
+
egress: 'loopback-only',
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Human explanation of the current local-path state. */
|
|
85
|
+
export function renderLocalPath(cfg) {
|
|
86
|
+
if (!cfg) return null;
|
|
87
|
+
return `LLM validator: local path, ${cfg.endpoint} (loopback-enforced, no egress), model '${cfg.model}'.`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const _internals = { LOOPBACK_HOSTS };
|