@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.
@@ -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: 512, messages: [{ role: 'user', content: prompt }] },
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
- try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
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
- safeWriteState(fp, JSON.stringify(value, null, 2));
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
- // Asymmetry: only 'reject' drops a finding. 'escalate' KEEPS it. This is the
460
- // design that makes prompt-injection of the validator harmless the worst
461
- // an attacker can produce is escalate (= no effect on the kept-set).
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 this repository\'s own hand-reviewed source. These are');
267
- L.push('absolute finding counts, not a rate — there is no labelled ground truth');
268
- L.push('over this code, so no precision figure is derived from it. What it');
269
- L.push('supports is a movement claim: any change in these counts between');
270
- L.push('releases is a real change in what the engine reports on unchanged code.');
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 Object.entries(m.selfScan.targets)) L.push(`| \`${k}\` | ${v.total} |`);
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 ASYMMETRY IS DELIBERATE AND PRESERVED VERBATIM. The `pre`
11
- // matcher accepts a hit on `vuln` OR `family` and regex-tests `cwe`; the
12
- // `post` matcher is strict on `vuln` and requires an exact `cwe`. This means
13
- // an entry faces a looser bar to score a TP than an FP, which
14
- // `bench/cve-replay/CONTRIBUTING.md` records as known imprecision to resolve
15
- // before the corpus grows toward 500. It is reproduced here rather than
16
- // quietly fixed: changing it would silently re-verdict entries across the
17
- // whole committed baseline, which is a corpus migration, not a refactor.
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 };