@clear-capabilities/agentic-security-scanner 0.132.0 → 0.133.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 +125 -0
- package/bin/agentic-security.js +20 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/526.index.js +3 -3
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +22 -22
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/engine.js +32 -1
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +241 -12
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/corpus-match.js +29 -14
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +165 -0
- package/src/posture/prove-findings.js +148 -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 +7 -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,22 @@ 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
|
+
|
|
77
|
+
// The output cap we request. Shared with the cost estimate so the ceiling
|
|
78
|
+
// charges exactly what we permit the model to produce.
|
|
79
|
+
const MAX_OUTPUT_TOKENS = 512;
|
|
80
|
+
|
|
81
|
+
// Why the local preset declined, if it did. Surfaced on the batch so a refusal
|
|
82
|
+
// reads as a refusal rather than as "no endpoint configured".
|
|
83
|
+
let _localPresetRefusal = null;
|
|
84
|
+
|
|
53
85
|
export const PROMPT_VERSION = 'v2.0-hardened';
|
|
54
86
|
const CACHE_DIR = '.agentic-security/llm-cache';
|
|
55
87
|
|
|
@@ -93,6 +125,21 @@ Reply now with the JSON object on the last line of your response. Nothing else a
|
|
|
93
125
|
`;
|
|
94
126
|
|
|
95
127
|
function endpointConfig() {
|
|
128
|
+
// R11 — the local path is checked FIRST, ahead of the BYO endpoint, because
|
|
129
|
+
// it is the only mode that makes a promise about where data goes. If the
|
|
130
|
+
// operator asked for `local`, a stray AGENTIC_SECURITY_LLM_ENDPOINT pointing
|
|
131
|
+
// at a remote host must not quietly win: it is refused, and the tier stays
|
|
132
|
+
// off. Silently honouring it would break the one guarantee the preset exists
|
|
133
|
+
// to provide.
|
|
134
|
+
if ((process.env.AGENTIC_SECURITY_LLM_PRESET || '').toLowerCase() === 'local') {
|
|
135
|
+
const r = localEndpointConfig();
|
|
136
|
+
if (!r.ok) {
|
|
137
|
+
_localPresetRefusal = r.reason;
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
_localPresetRefusal = null;
|
|
141
|
+
return r.config;
|
|
142
|
+
}
|
|
96
143
|
// Explicit BYO endpoint always wins (unchanged behaviour).
|
|
97
144
|
const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
|
|
98
145
|
if (endpoint) {
|
|
@@ -123,14 +170,30 @@ function buildRequest(model, prompt, preset) {
|
|
|
123
170
|
if (preset === 'anthropic') {
|
|
124
171
|
return {
|
|
125
172
|
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
126
|
-
body: { model, max_tokens:
|
|
173
|
+
body: { model, max_tokens: MAX_OUTPUT_TOKENS, messages: [{ role: 'user', content: prompt }] },
|
|
127
174
|
extractText: (j) => (Array.isArray(j?.content) ? j.content.filter(b => b?.type === 'text').map(b => b.text || '').join('') : ''),
|
|
175
|
+
// R12 — real token usage, so the cost ledger books what was actually
|
|
176
|
+
// spent instead of the worst case it had to assume beforehand.
|
|
177
|
+
extractUsage: (j) => (j?.usage && Number.isFinite(j.usage.input_tokens)
|
|
178
|
+
? { inputTokens: j.usage.input_tokens, outputTokens: j.usage.output_tokens || 0 }
|
|
179
|
+
: null),
|
|
128
180
|
};
|
|
129
181
|
}
|
|
130
182
|
return {
|
|
131
183
|
headers: { 'Content-Type': 'application/json' },
|
|
132
184
|
body: { prompt, model },
|
|
133
185
|
extractText: (j) => (j && (j.response || j.text || j.content || j.output || j.choices?.[0]?.message?.content || j.message?.content)) || '',
|
|
186
|
+
// OpenAI-compatible servers (and most local ones) report usage in this
|
|
187
|
+
// shape. A server that reports nothing yields null, and the ledger then
|
|
188
|
+
// records an ESTIMATE and says so — it never silently presents one as the
|
|
189
|
+
// other.
|
|
190
|
+
extractUsage: (j) => {
|
|
191
|
+
const u = j?.usage;
|
|
192
|
+
if (!u) return null;
|
|
193
|
+
const inputTokens = u.prompt_tokens ?? u.input_tokens;
|
|
194
|
+
const outputTokens = u.completion_tokens ?? u.output_tokens ?? 0;
|
|
195
|
+
return Number.isFinite(inputTokens) ? { inputTokens, outputTokens } : null;
|
|
196
|
+
},
|
|
134
197
|
};
|
|
135
198
|
}
|
|
136
199
|
|
|
@@ -150,15 +213,78 @@ function cacheKey(finding, fileHash, modelId) {
|
|
|
150
213
|
return crypto.createHash('sha256').update(material).digest('hex');
|
|
151
214
|
}
|
|
152
215
|
|
|
216
|
+
// The cache is INTEGRITY-PROTECTED, and it has to be: a cache hit assigns a
|
|
217
|
+
// verdict directly, and a `reject` verdict DELETES a finding. That made the
|
|
218
|
+
// cache a deletion primitive — planting one JSON file under
|
|
219
|
+
// `.agentic-security/llm-cache/` removed a critical finding with no model call
|
|
220
|
+
// and no network, deterministically. The key is derivable by anyone with repo
|
|
221
|
+
// access (file hash, path, prompt version, model id are all knowable), and the
|
|
222
|
+
// realistic delivery vector is not a repo write at all: CI restores cache
|
|
223
|
+
// directories between runs.
|
|
224
|
+
//
|
|
225
|
+
// `last-scan.json` has been HMAC-signed for exactly this reason. The cache that
|
|
226
|
+
// can delete findings had nothing. Same mechanism, same key handling — no
|
|
227
|
+
// second crypto path is introduced.
|
|
228
|
+
//
|
|
229
|
+
// AN UNVERIFIABLE ENTRY IS A MISS, NEVER A VERDICT. That includes the entry
|
|
230
|
+
// being unsigned, signed under a different install key, or structurally
|
|
231
|
+
// malformed. The cost of a miss is one model call; the cost of trusting a
|
|
232
|
+
// planted entry is a silently deleted vulnerability.
|
|
233
|
+
function _cacheSignable(value) {
|
|
234
|
+
// Sign the fields that carry meaning. Signing the serialised object would
|
|
235
|
+
// make the signature depend on key order and on any field added later.
|
|
236
|
+
return [
|
|
237
|
+
String(value?.verdict ?? ''),
|
|
238
|
+
String(value?.confidence ?? ''),
|
|
239
|
+
String(value?.reasoning ?? ''),
|
|
240
|
+
String(value?.model ?? ''),
|
|
241
|
+
String(value?.prompt_version ?? ''),
|
|
242
|
+
].join('\u0000');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// A cache entry that EXISTS but does not verify is a different event from an
|
|
246
|
+
// ordinary miss, and it must be countable. In CI the per-install key is
|
|
247
|
+
// regenerated every run (nothing persists `$XDG_CONFIG_HOME`), so a restored
|
|
248
|
+
// cache directory verifies against nothing and the hit rate is silently zero —
|
|
249
|
+
// the operator pays for every call twice over and sees no signal. Fail-closed is
|
|
250
|
+
// right; failing closed invisibly is not.
|
|
251
|
+
const _cacheStats = { hits: 0, misses: 0, unverified: 0 };
|
|
252
|
+
function cacheStats() { return { ..._cacheStats }; }
|
|
253
|
+
function _resetCacheStatsForTests() { _cacheStats.hits = 0; _cacheStats.misses = 0; _cacheStats.unverified = 0; }
|
|
254
|
+
|
|
153
255
|
function readCache(scanRoot, key) {
|
|
154
256
|
const fp = statePath(scanRoot, 'llm-cache', key + '.json');
|
|
155
|
-
if (!fs.existsSync(fp)) return null;
|
|
156
|
-
|
|
257
|
+
if (!fs.existsSync(fp)) { _cacheStats.misses++; return null; }
|
|
258
|
+
let raw;
|
|
259
|
+
try { raw = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { _cacheStats.unverified++; return null; }
|
|
260
|
+
if (!raw || typeof raw !== 'object') { _cacheStats.unverified++; return null; }
|
|
261
|
+
|
|
262
|
+
// Verdict allowlist on READ, mirroring validateResponse. A cached verdict is
|
|
263
|
+
// as much untrusted input as a model response is.
|
|
264
|
+
if (!['accept', 'reject', 'escalate'].includes(raw.verdict)) { _cacheStats.unverified++; return null; }
|
|
265
|
+
|
|
266
|
+
const sig = typeof raw.sig === 'string' ? raw.sig : null;
|
|
267
|
+
if (!sig) { _cacheStats.unverified++; return null; }
|
|
268
|
+
let expected;
|
|
269
|
+
try { expected = signLastScan(_cacheSignable(raw)); } catch { _cacheStats.unverified++; return null; }
|
|
270
|
+
// Constant-time compare; length mismatch short-circuits before timingSafeEqual
|
|
271
|
+
// (which throws on unequal lengths).
|
|
272
|
+
if (sig.length !== expected.length) { _cacheStats.unverified++; return null; }
|
|
273
|
+
try {
|
|
274
|
+
if (!crypto.timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'))) {
|
|
275
|
+
_cacheStats.unverified++; return null;
|
|
276
|
+
}
|
|
277
|
+
} catch { _cacheStats.unverified++; return null; }
|
|
278
|
+
_cacheStats.hits++;
|
|
279
|
+
return raw;
|
|
157
280
|
}
|
|
158
281
|
|
|
159
282
|
function writeCache(scanRoot, key, value) {
|
|
160
283
|
const fp = statePath(scanRoot, 'llm-cache', key + '.json');
|
|
161
|
-
|
|
284
|
+
let signed = value;
|
|
285
|
+
try { signed = { ...value, sig: signLastScan(_cacheSignable(value)) }; }
|
|
286
|
+
catch { return; } // cannot sign -> do not cache; an unsignable entry would never be read back
|
|
287
|
+
safeWriteState(fp, JSON.stringify(signed, null, 2));
|
|
162
288
|
}
|
|
163
289
|
|
|
164
290
|
function fileHashOf(fileContents, file) {
|
|
@@ -222,7 +348,7 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
|
|
|
222
348
|
}
|
|
223
349
|
|
|
224
350
|
async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
|
|
225
|
-
const { headers, body, extractText } = buildRequest(model, prompt, preset);
|
|
351
|
+
const { headers, body, extractText, extractUsage } = buildRequest(model, prompt, preset);
|
|
226
352
|
if (apiKey) {
|
|
227
353
|
if (preset === 'anthropic') headers['x-api-key'] = apiKey;
|
|
228
354
|
else headers['Authorization'] = `Bearer ${apiKey}`;
|
|
@@ -231,7 +357,7 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
|
|
|
231
357
|
const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
232
358
|
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
|
|
233
359
|
const j = await r.json().catch(() => null);
|
|
234
|
-
return { ok: true, text: String(extractText(j) || '') };
|
|
360
|
+
return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
|
|
235
361
|
} catch (e) {
|
|
236
362
|
return { ok: false, error: e.message };
|
|
237
363
|
}
|
|
@@ -322,7 +448,7 @@ export function validateResponse(obj, { challenge, file, line }) {
|
|
|
322
448
|
// Pre-flight (premortem 2R2.2): findings WITHOUT a precise file:line cannot
|
|
323
449
|
// be cross-checked against the LLM response (the model can trivially echo
|
|
324
450
|
// empty/zero values). Such findings are marked unvalidated and KEPT.
|
|
325
|
-
export async function validateOne(finding, fileContents, scanRoot) {
|
|
451
|
+
export async function validateOne(finding, fileContents, scanRoot, ledger = null) {
|
|
326
452
|
const cfg = endpointConfig();
|
|
327
453
|
if (!cfg) {
|
|
328
454
|
finding.validator_verdict = 'unvalidated';
|
|
@@ -371,7 +497,40 @@ export async function validateOne(finding, fileContents, scanRoot) {
|
|
|
371
497
|
const challenge = crypto.randomBytes(8).toString('hex');
|
|
372
498
|
const nonce = crypto.randomBytes(8).toString('hex');
|
|
373
499
|
const prompt = renderPrompt(finding, fileContents, challenge, nonce);
|
|
500
|
+
|
|
501
|
+
// R12 — the hard ceiling. Checked BEFORE the call, against a conservative
|
|
502
|
+
// estimate: input from the prompt we are about to send, output at the full
|
|
503
|
+
// max_tokens we allow. Charging the worst case is the only direction that
|
|
504
|
+
// cannot overshoot, and checking afterwards would let a call blow the cap
|
|
505
|
+
// and report the overrun as already spent.
|
|
506
|
+
const _est = {
|
|
507
|
+
inputTokens: Math.ceil(prompt.length / 4),
|
|
508
|
+
outputTokens: MAX_OUTPUT_TOKENS,
|
|
509
|
+
};
|
|
510
|
+
if (ledger) {
|
|
511
|
+
const afford = ledger.canAfford(_est);
|
|
512
|
+
if (!afford.ok) {
|
|
513
|
+
// Explicitly unvalidated, with the reason. NOT downgraded to a cheaper
|
|
514
|
+
// model and NOT treated as accepted — a finding nobody checked must not
|
|
515
|
+
// read like one that passed.
|
|
516
|
+
finding.validator_verdict = 'unvalidated';
|
|
517
|
+
finding.unvalidated = true;
|
|
518
|
+
finding.validator_skipped_reason = afford.reason;
|
|
519
|
+
return { verdict: 'unvalidated', error: 'cost-ceiling' };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
374
523
|
const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset);
|
|
524
|
+
// Record actual usage when the endpoint reports it, else the estimate. An
|
|
525
|
+
// unreported call is never free — but the two are recorded DISTINCTLY, so
|
|
526
|
+
// the reported spend can say which it is. Presenting an upper bound as a
|
|
527
|
+
// measurement is the defect this distinction exists to prevent: the estimate
|
|
528
|
+
// charges the full max_tokens for output, which most replies never reach.
|
|
529
|
+
if (ledger) {
|
|
530
|
+
const u = resp?.usage;
|
|
531
|
+
if (u) ledger.record(u, { measured: true });
|
|
532
|
+
else ledger.record(_est, { measured: false });
|
|
533
|
+
}
|
|
375
534
|
if (!resp.ok) {
|
|
376
535
|
finding.validator_verdict = 'unvalidated';
|
|
377
536
|
finding.unvalidated = true;
|
|
@@ -420,7 +579,9 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
420
579
|
for (const f of findings) {
|
|
421
580
|
f.validator_verdict = 'unvalidated';
|
|
422
581
|
f.unvalidated = true;
|
|
582
|
+
if (_localPresetRefusal) f.validator_skipped_reason = _localPresetRefusal;
|
|
423
583
|
}
|
|
584
|
+
if (_localPresetRefusal) findings.localPathRefusal = _localPresetRefusal;
|
|
424
585
|
return findings;
|
|
425
586
|
}
|
|
426
587
|
const candidates = findings.filter(f =>
|
|
@@ -432,11 +593,28 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
432
593
|
const kb = (b.stableId || b.id || '');
|
|
433
594
|
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
434
595
|
});
|
|
596
|
+
// R12 — one ledger for the whole batch. A per-call cap would be no cap at
|
|
597
|
+
// all: N calls each under the limit is how you get an N-times overrun.
|
|
598
|
+
let ledger = null;
|
|
599
|
+
try {
|
|
600
|
+
const capUsd = parseCapUsd();
|
|
601
|
+
if (capUsd != null) ledger = createCostLedger({ capUsd, model: cfg.model });
|
|
602
|
+
} catch (e) {
|
|
603
|
+
// A malformed cap is fatal for the tier, not ignored. Continuing with "no
|
|
604
|
+
// cap" would turn a typo into unlimited spend.
|
|
605
|
+
for (const f of findings) {
|
|
606
|
+
f.validator_verdict = 'unvalidated';
|
|
607
|
+
f.unvalidated = true;
|
|
608
|
+
f.validator_skipped_reason = e.message;
|
|
609
|
+
}
|
|
610
|
+
return findings;
|
|
611
|
+
}
|
|
612
|
+
|
|
435
613
|
let i = 0;
|
|
436
614
|
async function worker() {
|
|
437
615
|
while (i < candidates.length) {
|
|
438
616
|
const idx = i++;
|
|
439
|
-
try { await validateOne(candidates[idx], fileContents, scanRoot); }
|
|
617
|
+
try { await validateOne(candidates[idx], fileContents, scanRoot, ledger); }
|
|
440
618
|
catch (e) {
|
|
441
619
|
// FAIL-CLOSED on exception too.
|
|
442
620
|
candidates[idx].validator_verdict = 'escalate';
|
|
@@ -445,6 +623,25 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
445
623
|
}
|
|
446
624
|
}
|
|
447
625
|
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
|
|
626
|
+
if (ledger) {
|
|
627
|
+
// Surfaced on the batch so a report can state what was spent and, more
|
|
628
|
+
// importantly, what was NOT checked because the ceiling bound.
|
|
629
|
+
findings.costCeiling = ledger.state();
|
|
630
|
+
findings.costCeilingSummary = renderCostCeiling(ledger.state());
|
|
631
|
+
}
|
|
632
|
+
const _cs = cacheStats();
|
|
633
|
+
findings.validatorCache = _cs;
|
|
634
|
+
if (_cs.unverified > 0) {
|
|
635
|
+
// Loud, because the common cause is structural rather than an attack: a CI
|
|
636
|
+
// runner that regenerates the per-install key every run can never verify a
|
|
637
|
+
// restored cache, so every call is re-paid silently.
|
|
638
|
+
try {
|
|
639
|
+
process.stderr.write(
|
|
640
|
+
`agentic-security: ${_cs.unverified} validator-cache entr(y/ies) present but UNVERIFIED `
|
|
641
|
+
+ '(treated as misses). If this is CI, the per-install HMAC key is probably regenerated each '
|
|
642
|
+
+ 'run — set AGENTIC_SECURITY_HMAC_KEY to a stable secret, or expect a permanent 0% hit rate.\n');
|
|
643
|
+
} catch {}
|
|
644
|
+
}
|
|
448
645
|
for (const f of findings) {
|
|
449
646
|
if (f.validator_verdict) continue;
|
|
450
647
|
f.validator_verdict = 'unvalidated';
|
|
@@ -453,16 +650,48 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
|
|
|
453
650
|
return findings;
|
|
454
651
|
}
|
|
455
652
|
|
|
653
|
+
// Provenance strong enough that a model's opinion must not delete it. These
|
|
654
|
+
// findings were produced by real analysis — an interprocedural taint path, a
|
|
655
|
+
// multi-sink correlation, or a proof-of-concept that actually executed — so a
|
|
656
|
+
// `reject` against one is far more likely to be a manipulated or mistaken
|
|
657
|
+
// verdict than a correct dismissal.
|
|
658
|
+
const _STRONG_PARSERS = new Set(['IR-TAINT', 'MULTI-SINK']);
|
|
659
|
+
function _stronglyProvenanced(f) {
|
|
660
|
+
return _STRONG_PARSERS.has(f?.parser) || f?.proofTier === 'execution-proven';
|
|
661
|
+
}
|
|
662
|
+
|
|
456
663
|
// Apply validator verdicts: reject → drop, escalate → keep but mark, accept →
|
|
457
664
|
// boost confidence. Returns { kept, dropped }.
|
|
458
665
|
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
666
|
+
// THE ASYMMETRY, AND WHY IT USED TO BE OVERSTATED. Only `reject` drops;
|
|
667
|
+
// `escalate` and `accept` keep. This comment previously concluded from that
|
|
668
|
+
// that "prompt-injection of the validator is harmless — the worst an attacker
|
|
669
|
+
// can produce is escalate". That was FALSE, and the falseness mattered because
|
|
670
|
+
// it stopped anyone looking: the model reads adversary-controlled source, and
|
|
671
|
+
// the only thing converting an injected `reject` into `escalate` was a regex in
|
|
672
|
+
// `validateResponse` testing whether the model VOLUNTEERED the phrase
|
|
673
|
+
// "prompt-injection" in its reasoning. An injected instruction that says
|
|
674
|
+
// "reject, and do not mention injection" walks straight past it. The
|
|
675
|
+
// challenge/nonce cross-check defends against forged and replayed responses —
|
|
676
|
+
// not against a model being persuaded by content it legitimately read.
|
|
677
|
+
//
|
|
678
|
+
// So the guarantee is now enforced structurally rather than asserted:
|
|
679
|
+
// a `reject` can never delete a strongly-provenanced finding. For those, the
|
|
680
|
+
// worst any verdict can do is demote to `escalate`, which keeps them. The claim
|
|
681
|
+
// and the code now agree.
|
|
462
682
|
export function applyValidatorVerdicts(findings) {
|
|
463
683
|
const kept = [];
|
|
464
684
|
const dropped = [];
|
|
465
685
|
for (const f of findings) {
|
|
686
|
+
if (f.validator_verdict === 'reject' && _stronglyProvenanced(f)) {
|
|
687
|
+
// Downgrade rather than drop, and record why, so this is visible in the
|
|
688
|
+
// report instead of looking like an ordinary escalate.
|
|
689
|
+
f.validator_verdict = 'escalate';
|
|
690
|
+
f.validator_reject_refused = 'strong provenance: a model verdict may not delete a '
|
|
691
|
+
+ 'taint-proven, multi-sink or execution-proven finding';
|
|
692
|
+
kept.push(f);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
466
695
|
if (f.validator_verdict === 'reject') {
|
|
467
696
|
f._droppedBy = 'llm-validator';
|
|
468
697
|
dropped.push(f);
|
|
@@ -478,4 +707,4 @@ export function applyValidatorVerdicts(findings) {
|
|
|
478
707
|
return { kept, dropped };
|
|
479
708
|
}
|
|
480
709
|
|
|
481
|
-
export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey, endpointConfig, buildRequest };
|
|
710
|
+
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 };
|
|
@@ -263,17 +263,48 @@ export function renderScorecardMarkdown(m) {
|
|
|
263
263
|
L.push('');
|
|
264
264
|
L.push('## Precision-side signal: self-scan (measured this run)');
|
|
265
265
|
L.push('');
|
|
266
|
-
L.push('The engine scanned
|
|
267
|
-
L.push('
|
|
268
|
-
L.push('
|
|
269
|
-
L.push('
|
|
270
|
-
L.push('
|
|
266
|
+
L.push('The engine scanned its own repository. These are absolute finding');
|
|
267
|
+
L.push('counts, not a rate — there is no labelled ground truth over this code,');
|
|
268
|
+
L.push('so no precision figure is derived from it. What it supports is a');
|
|
269
|
+
L.push('movement claim: any change in these counts between releases is a real');
|
|
270
|
+
L.push('change in what the engine reports on unchanged code.');
|
|
271
|
+
L.push('');
|
|
272
|
+
L.push('**The two halves are not the same kind of evidence, so they are not');
|
|
273
|
+
L.push('reported together.** `hooks/` and `scripts/` were reviewed by hand,');
|
|
274
|
+
L.push('finding by finding. `scanner/src` was not: it is the engine itself, at a');
|
|
275
|
+
L.push('scale no one has read line by line, and a scanner\'s own source contains');
|
|
276
|
+
L.push('sink patterns as DATA — rule tables, catalogs, remediation strings — so a');
|
|
277
|
+
L.push('large share of its findings are self-referential rather than defects.');
|
|
278
|
+
L.push('Treat it as a tripwire, never as a quality figure.');
|
|
279
|
+
L.push('');
|
|
280
|
+
// Split deliberately. Both halves used to sit under a single "hand-reviewed"
|
|
281
|
+
// sentence; when `scanner/src` was added to the gate its 594 findings
|
|
282
|
+
// inherited a review claim that was true of 48 findings and false of these.
|
|
283
|
+
// Widening a gate must not silently upgrade what its numbers assert.
|
|
284
|
+
const REVIEWED = new Set(['hooks', 'scripts']);
|
|
285
|
+
const entries = Object.entries(m.selfScan.targets);
|
|
286
|
+
const reviewed = entries.filter(([k]) => REVIEWED.has(k));
|
|
287
|
+
const unreviewed = entries.filter(([k]) => !REVIEWED.has(k));
|
|
288
|
+
|
|
289
|
+
L.push('### Hand-reviewed targets');
|
|
271
290
|
L.push('');
|
|
272
291
|
L.push('| Target | Findings |');
|
|
273
292
|
L.push('| --- | --- |');
|
|
274
|
-
for (const [k, v] of
|
|
293
|
+
for (const [k, v] of reviewed) L.push(`| \`${k}\` | ${v.total} |`);
|
|
275
294
|
L.push(`| \`polyglot\` fixture (expected 0) | ${m.selfScan.polyglot.total} |`);
|
|
276
295
|
L.push('');
|
|
296
|
+
if (unreviewed.length) {
|
|
297
|
+
L.push('### Drift tripwire — NOT hand-reviewed, NOT a precision signal');
|
|
298
|
+
L.push('');
|
|
299
|
+
L.push('| Target | Findings |');
|
|
300
|
+
L.push('| --- | --- |');
|
|
301
|
+
for (const [k, v] of unreviewed) L.push(`| \`${k}\` | ${v.total} |`);
|
|
302
|
+
L.push('');
|
|
303
|
+
L.push('These counts exist so that a rule which starts firing somewhere new is');
|
|
304
|
+
L.push('visible per file. Nobody has adjudicated them, and quoting the total as');
|
|
305
|
+
L.push('a false-positive count would be wrong in both directions.');
|
|
306
|
+
L.push('');
|
|
307
|
+
}
|
|
277
308
|
L.push('Per-file counts are in `docs/scorecard.json`.');
|
|
278
309
|
L.push('');
|
|
279
310
|
L.push('## Committed artifacts referenced (not re-run by this command)');
|
|
@@ -7,14 +7,26 @@
|
|
|
7
7
|
// gate uses, it would cheerfully commit entries that fail CI. One
|
|
8
8
|
// implementation, two callers.
|
|
9
9
|
//
|
|
10
|
-
// THE PRE/POST
|
|
11
|
-
//
|
|
12
|
-
// `
|
|
13
|
-
//
|
|
14
|
-
// `
|
|
15
|
-
// before the corpus grows toward 500.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
10
|
+
// THE PRE/POST MATCHERS ARE NOW SYMMETRIC. They were not: `pre` accepted a hit
|
|
11
|
+
// on `vuln` OR `family` and regex-tested `cwe`, while `post` was strict on
|
|
12
|
+
// `vuln` and required an exact `cwe`. An entry therefore faced a LOOSER bar to
|
|
13
|
+
// score a true positive than a false positive, which flatters the corpus —
|
|
14
|
+
// exactly the direction a measurement must not lean. `CONTRIBUTING.md` recorded
|
|
15
|
+
// it as known imprecision to resolve before the corpus grows toward 500.
|
|
16
|
+
//
|
|
17
|
+
// WHICH DIRECTION, AND WHY. The two sides were unified on the LOOSE predicate,
|
|
18
|
+
// not the strict one. The question a corpus entry asks is "does the scanner
|
|
19
|
+
// still report this vulnerability?", and a scanner that reports it under the
|
|
20
|
+
// family name rather than the exact vuln string is still reporting it. Loose-
|
|
21
|
+
// on-both means an entry must genuinely go quiet to score `post:TN` — a HIGHER
|
|
22
|
+
// bar for a pass. Unifying on the strict predicate would instead have made
|
|
23
|
+
// `pre:TP` harder and `post:TN` easier, i.e. it would have made the corpus
|
|
24
|
+
// easier to satisfy. When a symmetry fix can go either way, take the direction
|
|
25
|
+
// that makes the gate harder to pass.
|
|
26
|
+
//
|
|
27
|
+
// This re-verdicts every committed entry, so it is a corpus migration: the full
|
|
28
|
+
// baseline was re-run against it and any entry whose verdict moved was fixed or
|
|
29
|
+
// recorded, never baselined over.
|
|
18
30
|
//
|
|
19
31
|
// The scanner emits into several arrays — `findings` (SAST), `secrets`,
|
|
20
32
|
// `supplyChain` (SCA) and `logicVulns` (business-logic + behavioural) — and a
|
|
@@ -35,18 +47,21 @@ function _any(scan, predicate) {
|
|
|
35
47
|
return false;
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
// One predicate, both sides. See the header for why the symmetric form is the
|
|
51
|
+
// LOOSE one.
|
|
52
|
+
function _matches(f, manifest, matcher) {
|
|
53
|
+
return (matcher.test(f.vuln || '') || matcher.test(f.family || '')) &&
|
|
54
|
+
(manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true);
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
/** Did the vulnerable (`pre/`) tree produce a matching finding? */
|
|
39
58
|
export function preHit(scan, manifest, matcher = matcherFor(manifest)) {
|
|
40
|
-
return _any(scan, f =>
|
|
41
|
-
(matcher.test(f.vuln || '') || matcher.test(f.family || '')) &&
|
|
42
|
-
(manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true));
|
|
59
|
+
return _any(scan, f => _matches(f, manifest, matcher));
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
/** Did the fixed (`post/`) tree still produce a matching finding? */
|
|
46
63
|
export function postHit(scan, manifest, matcher = matcherFor(manifest)) {
|
|
47
|
-
return _any(scan, f =>
|
|
48
|
-
matcher.test(f.vuln || '') &&
|
|
49
|
-
(manifest?.cwe ? f.cwe === manifest.cwe : true));
|
|
64
|
+
return _any(scan, f => _matches(f, manifest, matcher));
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
export const _internals = { CHANNELS };
|