@clear-capabilities/agentic-security-scanner 0.154.0 → 0.156.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.
@@ -1 +1 @@
1
- e460f63891e70f53f1a0d6061a5393cc92c5ade0ddde80bd8bfaf8eb00dbb849 agentic-security.mjs
1
+ 71a2f80d8b60918125a14973bffd460f469848dc1b0aac126272864b539b61ce agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.154.0",
3
+ "version": "0.156.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -81,12 +81,12 @@
81
81
  "test:server": "node --test test/server/graph-loader.test.js test/server/security.test.js test/server/routes.test.js test/server/http-server.test.js test/server/cmd-explore.test.js test/server/cmd-dataflow-export.test.js test/server/static-assets.test.js test/server/dataflow-scenario-cli.test.js test/server/dataflow-impact-cli.test.js",
82
82
  "test:haskell": "node --test test/haskell/haskell-injection.test.js test/haskell/haskell-ir.test.js test/haskell/haskell-manifests.test.js test/haskell/haskell-parser.test.js test/haskell/haskell-remediation.test.js test/haskell/haskell-resolved-graph.test.js test/haskell/haskell-sca.test.js test/haskell/haskell-security-rules.test.js test/haskell/haskell-taint.test.js",
83
83
  "test:nix": "node --test test/nix/nix-build-trust.test.js test/nix/nix-eval-isolation.test.js test/nix/nix-input-inventory.test.js test/nix/nix-parser-ir.test.js test/nix/nix-remediation.test.js test/nix/nix-resolved-closure.test.js test/nix/nix-sca-patches.test.js test/nix/nix-script-taint.test.js test/nix/nix-secrets-store.test.js test/nix/nixos-hardening.test.js test/nix/nixos-module-resolution.test.js",
84
- "test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js",
84
+ "test:language": "node --test test/language/capability-ledger.test.js test/language/language-accuracy-regression.test.js test/language/language-aibom.test.js test/language/language-assurance.test.js test/language/language-bom.test.js test/language/language-compliance.test.js test/language/language-contracts.test.js test/language/language-corpora-integrity.test.js test/language/language-discovery.test.js test/language/language-doc-coverage.test.js test/language/language-evidence-posture.test.js test/language/language-explorer.test.js test/language/language-gap-fixes.test.js test/language/language-governance.test.js test/language/language-integrations.test.js test/language/language-llm-agent.test.js test/language/language-local-ai.test.js test/language/language-polyglot-bridges.test.js test/language/language-privacy-lineage.test.js test/language/language-proof-remediation.test.js test/language/language-report-formats.test.js test/language/language-secrets.test.js test/language/language-trust-boundary.test.js",
85
85
  "test:language-stress": "node --test --test-concurrency=1 test/language/language-stress-offline.test.js",
86
86
  "test:language-tools": "node --test test/haskell/haskell-web-auth.test.js test/haskell/haskell-support-gate.test.js",
87
87
  "bench:language-support:check": "node ../bench/language-support/check.mjs",
88
88
  "bench:language-support:perf": "node ../bench/language-support/perf.mjs",
89
- "test:lifecycle": "node --test test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs",
89
+ "test:lifecycle": "node --test test/pre-bash-guard.test.js test/lsp-protocol-smoke.test.js test/ide-surfaces.test.js test/detector-liveness.test.js test/bench-watchdog.test.js test/ci-parity.test.js test/dead-code.test.js test/no-dead-modules.test.js test/no-orphan-scripts.test.js test/no-stray-state.test.js test/tree-integrity.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js test/sandbox.test.js test/sandbox-escape.test.js test/ci-templates.test.js test/agent-mcp-tools.test.js test/triage-command.test.js test/no-orphan-tests.test.js test/supply-command.test.js test/posture-command.test.js test/labs-command.test.js test/compliance-command.test.js test/check-doc-drift.test.js test/dist-chunks-tracked.test.js && node ../scripts/lint-command-descriptions.mjs",
90
90
  "test:eval": "node --test test/independent-eval.test.js test/realworld-recall.test.js",
91
91
  "test:discovery": "node --test test/discovery-partition.test.js test/discovery-lenses.test.js test/discovery-hunter.test.js test/discovery-confirm.test.js test/discovery-disprove.test.js test/discovery-judge.test.js test/discovery-run.test.js test/discovery-wiring.test.js test/discovery-llm-invoke.test.js test/local-only-mode.test.js",
92
92
  "eval:independent": "node ../bench/independent-eval/runner.mjs",
@@ -199,10 +199,12 @@
199
199
  "test:ci-parity": "CI=true GITHUB_ACTIONS=true node --test test/backward-default.test.js test/ci-parity.test.js test/container-taint.test.js test/deep-taint.test.js test/engine-recall.test.js test/engine-reconnect.test.js test/gate-verdict-cache.test.js test/ignore-pragma.test.js test/java-taint-flow.test.js test/kt-taint-flow.test.js test/py-annotation-sources.test.js test/runscan-deep-option.test.js test/sanitizer-typed-flow.test.js",
200
200
  "docs:render-language": "node ../scripts/render-language-docs.mjs",
201
201
  "docs:capture-language": "node ../scripts/capture-language-examples.mjs",
202
+ "completion-status": "node ../scripts/write-completion-status.mjs",
202
203
  "docs:check-language": "node ../scripts/render-language-docs.mjs --check && node ../scripts/capture-language-examples.mjs --check",
203
204
  "test:nixos-host": "node --test test/nix/nixos-host-runtime.test.js",
204
205
  "test:language-gates": "node --test --test-concurrency=1 test/language/language-benchmark-gates.test.js",
205
- "test:language-slow": "node --test --test-concurrency=1 test/language/language-doc-examples-metrics.test.js test/language/language-end-to-end.test.js test/language/language-package-ci.test.js test/language/language-scan-modes.test.js"
206
+ "test:language-slow": "node --test --test-concurrency=1 test/language/language-doc-examples-metrics.test.js test/language/language-end-to-end.test.js test/language/language-package-ci.test.js test/language/language-scan-modes.test.js",
207
+ "test:chrome": "AGENTIC_SECURITY_CHROME_TESTS=1 node --test test/export-image.test.js test/server/cmd-dataflow-export.test.js"
206
208
  },
207
209
  "author": "Ross Young <ross@clearcapabilities.com>",
208
210
  "license": "PolyForm-Internal-Use-1.0.0",
package/src/engine.js CHANGED
@@ -9228,7 +9228,7 @@ function _deterministicFileTimings(timings) {
9228
9228
  const annotatedComponents=components.map(c=>{const key=`${c.ecosystem}:${c.name}:${c.version}`;const vulns=vulnsByKey[key]||[];const riKey=c.ecosystem==='maven'&&c.group?`maven:${c.group}/${c.name}`:`${c.ecosystem}:${c.name}`;const ri=registryInfo.get(riKey)||{};const latestVersion=ri.latestVersion||'';const vd=(ri.versions||{})[c.version]||{};const isDeprecated=typeof vd.deprecated==='string'&&vd.deprecated.length>0;const deprecationMessage=isDeprecated?vd.deprecated:'';const isOutdated=!isDeprecated&&typeof vd.outdated==='string'&&vd.outdated.length>0;const outdatedMessage=isOutdated?vd.outdated:'';const license=ri.license||vd.license||'';return{...c,vulns,hasVulns:vulns.length>0,hasAttackPath:attackResult.flagged.has(key),attackPaths:attackResult.pathsByKey.get(key)||[],latestVersion,isDeprecated,deprecationMessage,isOutdated,outdatedMessage,license};});
9229
9229
  // X-010: Hackage and Nix components, dependency edges and target provenance. Kept beside (not inside) the ordinary
9230
9230
  // component list so the existing consumers are untouched; the BOM emitters and the SBOM diff read it explicitly.
9231
- let _languageBom=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project(?:\.freeze)?|package\.yaml|stack\.yaml(?:\.lock)?|flake\.lock)$/i.test(f))){const{languageBom}=await import('./language/bom.js');const{resolvedHaskellGraph,nixClosureOf}=await import('./language/resolved-pass.js');const _rg=resolvedHaskellGraph(allFileContents);const _nc=nixClosureOf(allFileContents);_languageBom=languageBom(allFileContents,{...(_rg?{resolved:_rg.graph}:{}),...(_nc?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
9231
+ let _languageBom=null;try{if(Object.keys(allFileContents).some(f=>/\.(?:l?hs|nix)$|\.cabal$|(?:^|\/)(?:cabal\.project(?:\.freeze)?|package\.yaml|stack\.yaml(?:\.lock)?|flake\.lock)$/i.test(f))){const{languageBom}=await import('./language/bom.js');const{resolvedHaskellGraph,nixClosureOf}=await import('./language/resolved-pass.js');const _rg=resolvedHaskellGraph(allFileContents);const _nc=nixClosureOf(allFileContents);_languageBom=languageBom(allFileContents,{...(_rg?{resolved:_rg.graph}:{}),...(_nc&&!_nc.refused.length?{closure:_nc.closure}:{})});}}catch(_){_languageBom=null;}
9232
9232
  aF.push(...(runDetector(_detectorErrors,'<project>','scanDbTaintCrossFile',()=>scanDbTaintCrossFile(fc))||[]));
9233
9233
  aF.push(...(runDetector(_detectorErrors,'<project>','scanCsharpCrossFile',()=>scanCsharpCrossFile(fc))||[]));
9234
9234
  // SARD_80_F1 W5.41/W5.42 — unlike the ADD-only cross-file passes above,
@@ -11038,7 +11038,7 @@ function _deterministicFileTimings(timings) {
11038
11038
  _languageCoverage = (await assessLanguageAssurance({
11039
11039
  files: allFileContents, findings: finalFindings, timeoutMs: Number(env.AGENTIC_SECURITY_LANG_TIMEOUT_MS) || 0,
11040
11040
  disabled: [...String(env.AGENTIC_SECURITY_LANG_DISABLE || '').split(',').map(x => x.trim()).filter(Boolean), ...((_hasHaskellSource && !_deepEnabled) ? ['haskell:taint'] : [])],
11041
- advisoryDb: configuredAdvisoryDb(scanRoot).db,
11041
+ ...(() => { const a = configuredAdvisoryDb(scanRoot); return { advisoryDb: a.db, advisoryReason: a.db ? null : a.reason }; })(),
11042
11042
  policyExcluded,
11043
11043
  sizeSkipped,
11044
11044
  supplyGaps: _languageSupplyGaps,
@@ -134,14 +134,14 @@ export async function assessLanguageAssurance(input = {}) {
134
134
  const scaRequired = (required.haskell || []).includes('sca') && !disabled.has('haskell:sca');
135
135
  if (present.has('haskell') && scaRequired && manifests.length) {
136
136
  const db = input.advisoryDb;
137
- if (!db) conditions.push('no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed');
137
+ if (!db) conditions.push(`no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed${input.advisoryReason ? `. ${input.advisoryReason}` : ''}`);
138
138
  else if (db.stale) conditions.push(`the Hackage advisory snapshot is stale (${db.ageDays == null ? 'age unknown' : `${Math.floor(db.ageDays)} day(s) old`}, limit ${db.maxAgeDays})`);
139
139
  }
140
140
 
141
141
  // 3b. resolved dependency data and the Nix closure: a stale, malformed or unchecked input is stated, never silently dropped
142
142
  for (const g of input.supplyGaps || []) {
143
143
  if (!g || !g.kind) continue;
144
- if (/unavailable|stale|malformed|failed|unverified/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
144
+ if (/unavailable|stale|malformed|failed|unverified|refused|contradicts/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
145
145
  else limitations.push({ kind: 'supply-gap', gap: g.kind, note: String(g.detail || '').slice(0, 220) });
146
146
  }
147
147
 
@@ -42,11 +42,11 @@ function identNames(e, out = new Set(), depth = 0) {
42
42
  const mentions = (e, vars) => { for (const n of identNames(e)) if (vars.has(n)) return true; return false; };
43
43
 
44
44
  /** Classify a condition over the value `vars`. -> {safeWhen, kind, families, needsCanon?, subject} | null */
45
- export function classifyCondition(c, vars) {
45
+ export function classifyCondition(c, vars, derived = new Set()) {
46
46
  if (!c || typeof c !== 'object') return null;
47
47
  if (c.kind === 'binary') {
48
- if (c.op === '&&') { const a = classifyCondition(c.left, vars); const b = classifyCondition(c.right, vars); return [a, b].find((x) => x && x.safeWhen === true) || null; }
49
- if (c.op === '||') { const a = classifyCondition(c.left, vars); const b = classifyCondition(c.right, vars); return [a, b].find((x) => x && x.safeWhen === false) || null; }
48
+ if (c.op === '&&') { const a = classifyCondition(c.left, vars, derived); const b = classifyCondition(c.right, vars, derived); return [a, b].find((x) => x && x.safeWhen === true) || null; }
49
+ if (c.op === '||') { const a = classifyCondition(c.left, vars, derived); const b = classifyCondition(c.right, vars, derived); return [a, b].find((x) => x && x.safeWhen === false) || null; }
50
50
  if (c.op === '==' || c.op === '===') {
51
51
  const pair = (l, r) => l && l.kind === 'call' && /(?:^|\.)takeFileName$/.test(l.callee || '') && l.args && l.args[0] && r && r.kind === 'ident' && l.args[0].kind === 'ident' && l.args[0].name === r.name && vars.has(r.name);
52
52
  if (pair(c.left, c.right) || pair(c.right, c.left)) return { safeWhen: true, kind: 'bare-filename', families: ['path'], subject: (c.left.kind === 'ident' ? c.left : c.right).name };
@@ -56,7 +56,7 @@ export function classifyCondition(c, vars) {
56
56
  if (c.kind !== 'call') return null;
57
57
  const name = c.callee || '';
58
58
  const a = c.args || [];
59
- if (name === 'not' || name === 'Prelude.not') { const inner = classifyCondition(a[0], vars); return inner ? { ...inner, safeWhen: !inner.safeWhen } : null; }
59
+ if (name === 'not' || name === 'Prelude.not') { const inner = classifyCondition(a[0], vars, derived); return inner ? { ...inner, safeWhen: !inner.safeWhen } : null; }
60
60
  if (name === 'Data.List.isInfixOf' && isStr(a[0]) && a[0].value.includes('..') && a[1] && a[1].kind === 'ident' && vars.has(a[1].name)) {
61
61
  return { safeWhen: false, kind: 'traversal-check', families: ['path'], subject: a[1].name };
62
62
  }
@@ -70,6 +70,12 @@ export function classifyCondition(c, vars) {
70
70
  if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && isStr(a[0]) && a[0].value === '..' && a[1] && a[1].kind === 'call' && /(?:^|\.)(?:splitDirectories|splitPath)$/.test(a[1].callee || '') && a[1].args && a[1].args[0] && a[1].args[0].kind === 'ident' && vars.has(a[1].args[0].name)) {
71
71
  return { safeWhen: NOT_ELEM.has(name), kind: 'traversal-check', families: ['path'], subject: a[1].args[0].name };
72
72
  }
73
+ // `uriRegName a `elem` ["api.example.com"]` where `a` was parsed from the checked URL: the host is on an allow-list, which is the
74
+ // URL family's control. Only a HOST accessor over a value derived from the checked one counts (a path or query allow-list does not).
75
+ if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && a[0] && a[0].kind === 'call' && /(?:^|\.)(?:uriRegName|uriAuthority|getHost)$/.test(a[0].callee || '') && a[0].args && a[0].args[0] && a[0].args[0].kind === 'ident'
76
+ && (vars.has(a[0].args[0].name) || derived.has(a[0].args[0].name)) && a[1] && a[1].kind === 'array' && a[1].elements.length && a[1].elements.every(isStr)) {
77
+ return { safeWhen: IS_ELEM.has(name), kind: 'host-allow-list', families: ['url'], subject: a[0].args[0].name };
78
+ }
73
79
  if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && a[0] && a[0].kind === 'ident' && vars.has(a[0].name) && !mentions(a[1], vars)) {
74
80
  return { safeWhen: IS_ELEM.has(name), kind: 'allow-list', families: ['*'], subject: a[0].name };
75
81
  }
@@ -280,10 +286,11 @@ export function judgeFinding(f, perFile) {
280
286
  // The sink argument may be a value BUILT from the checked one (`req <- parseUrlThrow target; httpLbs req m`): the
281
287
  // guard names the original, so the origins of the argument count as its subject too.
282
288
  for (const v of originsOf(fn, vars)) vars.add(v);
289
+ const derived = derivedFrom(fn, vars); // values built FROM the checked one (`a` in `Just a <- parseURI url`)
283
290
  const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
284
291
  let refuted = null;
285
292
  for (const fact of facts) {
286
- const cls = classifyCondition(fact.cond, vars);
293
+ const cls = classifyCondition(fact.cond, vars, derived);
287
294
  if (!cls || cls.safeWhen !== fact.when) continue;
288
295
  if (!(cls.families.includes('*') || cls.families.includes(fam))) continue; // wrong-context control
289
296
  if (cls.needsCanon && !isCanonical(fn, cls.subject)) { weak = { kind: cls.kind, line: fact.line || fn.line, reason: 'containment check on a path that was not canonicalised' }; continue; }
@@ -545,8 +545,15 @@ class FnLowerer {
545
545
  const mem = { kind: 'member', object: obj, prop: r.name };
546
546
  return argAsts.length === 1 ? mem : { kind: 'call', callee: '<indirect>', args: [mem, ...argAsts.slice(1).map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'field-applied' } };
547
547
  }
548
- const args = argAsts.map((a) => this.lowerExpr(a, ln));
548
+ let args = argAsts.map((a) => this.lowerExpr(a, ln));
549
+ // `lookup key table` returns an element OF the table: the key chooses which, it is not where the value comes from. Only the table
550
+ // flows to the result, so a constant table gives a constant result however tainted the key is.
551
+ let selector = null;
552
+ if (f.name === 'lookup' && args.length === 2 && (r.kind === 'builtin' || r.kind === 'external')) { selector = args[0]; args = [args[1]]; }
549
553
  const hs = { via };
554
+ // The key stays on the call as `hs.selector`: taint ignores it (it only chooses), but a rule asking "does caller text decide whether
555
+ // this fails" (fromJust (lookup key t)) still sees it.
556
+ if (selector) hs.selector = selector;
550
557
  let callee = f.qual ? `${f.qual}.${f.name}` : f.name;
551
558
  const arity = (r.kind === 'def' && r.def.kind === 'fun') ? r.def.arity : (r.kind === 'localfn' ? r.arity : -1);
552
559
  if ((r.kind === 'def' && r.def.kind === 'fun') || r.kind === 'localfn') {
@@ -163,6 +163,14 @@ san('Text.Blaze', ['toMarkup', 'toValue', 'text', 'string', 'lazyText'], ['xss']
163
163
  san('Lucid', ['toHtml', 'toHtmlRaw_'], ['xss'], 'Lucid.toHtml escapes');
164
164
  san('System.FilePath', ['takeFileName', 'takeBaseName'], ['path'], 'drops every directory component');
165
165
  san('System.FilePath.Posix', ['takeFileName', 'takeBaseName'], ['path'], 'drops every directory component');
166
+ // A function whose result is a number or a boolean cannot carry caller text: nothing of the argument survives (`show (length x)`).
167
+ const ALL_FAMILIES = ['*'];
168
+ san('Prelude', ['length', 'null', 'fromEnum'], ALL_FAMILIES, 'returns a number or boolean: no caller text survives');
169
+ san('Data.List', ['length', 'genericLength'], ALL_FAMILIES, 'returns a number: no caller text survives');
170
+ san('Data.Foldable', ['length', 'null'], ALL_FAMILIES, 'returns a number or boolean: no caller text survives');
171
+ san('Data.Text', ['length'], ALL_FAMILIES, 'returns a number: no caller text survives');
172
+ san('Data.ByteString', ['length'], ALL_FAMILIES, 'returns a number: no caller text survives');
173
+ san('Data.Char', ['ord'], ALL_FAMILIES, 'returns a number: no caller text survives');
166
174
  san('Database.PostgreSQL.Simple', ['Only'], [], 'binds a parameter; it is a safe argument position, not a query-text sanitizer');
167
175
 
168
176
  export const HS_SOURCES = Object.freeze(SRC);
@@ -57,10 +57,14 @@ const STRONG_KDF = /^(?:Crypto\.KDF\.BCrypt\.|Crypto\.Argon2\.|Crypto\.Scrypt\.|
57
57
  const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'System.IO.Unsafe.unsafeInterleaveIO': 'unsafeInterleaveIO', 'System.IO.Unsafe.unsafeDupablePerformIO': 'unsafeDupablePerformIO', 'Unsafe.Coerce.unsafeCoerce': 'unsafeCoerce' };
58
58
 
59
59
  const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
60
- const SECURITY_FN = /(?:token|nonce|secret|otp|csrf|session|salt|apikey|resetcode|passcode|verification)/i;
60
+ const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|salt|apikey|resetcode|passcode|verification)/i;
61
+ // Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
62
+ const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
63
+ const SECURITY_FN = { test: (name) => SECURITY_FN_SUBSTR.test(name) || String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).some((w) => SECURITY_WORDS.has(w)) };
61
64
  const INPUT_READS = new Set(['Prelude.getContents', 'System.IO.getContents', 'System.IO.hGetContents', 'Data.ByteString.getContents', 'Data.ByteString.hGetContents', 'Data.ByteString.Char8.getContents', 'Data.ByteString.Char8.hGetContents', 'Data.ByteString.Lazy.getContents', 'Data.ByteString.Lazy.hGetContents', 'Data.ByteString.Lazy.Char8.getContents', 'Data.ByteString.Lazy.Char8.hGetContents', 'Data.Text.IO.getContents', 'Data.Text.IO.hGetContents', 'Data.Text.Lazy.IO.getContents', 'Data.Text.Lazy.IO.hGetContents']);
62
65
  const BOUNDING = /(?:^|\.)(?:take|hGetSome|hGet|splitAt|limit|limitedRead)$/;
63
- const ALLOCATORS = new Set(['replicate', 'Prelude.replicate', 'Data.List.replicate', 'Data.ByteString.replicate', 'Data.ByteString.Char8.replicate', 'Data.ByteString.Lazy.replicate', 'Data.Text.replicate', 'Data.Vector.replicate']);
66
+ // `replicateM n getLine` reads n lines: the count is an allocation (and an input loop) sized by whoever supplied n.
67
+ const ALLOCATORS = new Set(['replicate', 'Prelude.replicate', 'Data.List.replicate', 'Data.ByteString.replicate', 'Data.ByteString.Char8.replicate', 'Data.ByteString.Lazy.replicate', 'Data.Text.replicate', 'Data.Vector.replicate', 'Control.Monad.replicateM', 'Control.Monad.replicateM_', 'Data.Vector.replicateM', 'Data.List.genericReplicate']);
64
68
  const CLAMPS = /^(?:(?:Prelude|Data\.Ord)\.)?(?:min|clamp)$/;
65
69
  const PARTIALS = new Set(['read', 'Prelude.read', 'head', 'Prelude.head', 'Data.List.head', 'tail', 'Prelude.tail', 'Data.List.tail', 'last', 'Prelude.last', 'Data.List.last', 'init', 'Prelude.init', 'Data.List.init', 'Data.Maybe.fromJust', 'fromJust']);
66
70
  const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
@@ -77,6 +81,7 @@ const kids = (e) => {
77
81
  for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') out.push(e[k]);
78
82
  for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) out.push(...e[k]);
79
83
  if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) out.push(p.value);
84
+ if (e.hs && e.hs.selector && typeof e.hs.selector === 'object') out.push(e.hs.selector); // `lookup key t`: the key decides the outcome
80
85
  return out;
81
86
  };
82
87
 
@@ -210,6 +215,11 @@ export function analyzeHaskellRules(files, opts = {}) {
210
215
  }
211
216
  continue;
212
217
  }
218
+ // `xs !! n` raises on a short list: a partial function, written as an operator, applied to caller-supplied text
219
+ if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left)) {
220
+ emit('hs-partial-function-on-input', file, e.line || node.line, { detail: '!! on a caller-supplied list', evidence: { partial: '!!' } });
221
+ continue;
222
+ }
213
223
  if (e.kind !== 'call' || typeof e.callee !== 'string') continue;
214
224
  const c = e.callee;
215
225
  const line = e.line || node.line;
@@ -301,7 +311,18 @@ export function analyzeHaskellRules(files, opts = {}) {
301
311
  // a file append is a log only when its path says so (a log, audit or trace file)
302
312
  if (sink.channel === 'file') { const p = (e.args || [])[0]; if (!(p && p.kind === 'literal' && /(?:log|audit|trace|journal)/i.test(String(p.value)))) continue; }
303
313
  let hit = null;
314
+ // a labelled value: a literal that names a credential ("token=", "password: ") followed by a non-literal operand
315
+ if (!hit) {
316
+ for (const x of walk(arg)) {
317
+ if (x.kind === 'binary' && x.hs && x.hs.concat && x.left && x.left.kind === 'literal' && typeof x.left.value === 'string' && /^\s*[\w .-]*(?:pass(?:word|wd|phrase)?|pwd|secret|token|api_?key|authorization|bearer|private_?key|session_?id)[\w .-]*\s*[=:]\s*$/i.test(x.left.value) && x.right && x.right.kind !== 'literal') {
318
+ const lab = x.left.value.replace(/[=:\s]+$/, '').trim();
319
+ hit = { name: lab, low: LOW_ENTROPY.test(lab), hashed: false, var: lab };
320
+ break;
321
+ }
322
+ }
323
+ }
304
324
  for (const n of names(arg)) {
325
+ if (hit) break;
305
326
  const bare = n.replace(/^\./, '');
306
327
  if (sensitive.has(n)) hit = { ...sensitive.get(n), var: n };
307
328
  else if (SENSITIVE.test(bare) && !/^[A-Z][A-Za-z0-9_.]*\./.test(n)) hit = { name: bare, low: LOW_ENTROPY.test(bare), hashed: false, var: n };
@@ -1,8 +1,8 @@
1
1
  // Haskell supply-chain orchestration for a project (HS-009): manifests -> components -> advisories + policy.
2
2
  // The pure logic lives in haskell-sca.js; this module only gathers inputs and shapes supply-chain entries.
3
3
 
4
- import { statePath } from '../posture/state-dir.js';
5
- import { readFileSync, existsSync } from 'node:fs';
4
+ import { resolveOperatorSnapshot, IGNORED_NOTE } from './trusted-inputs.js';
5
+ import { readFileSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { analyzeHaskellManifests } from './haskell-manifests.js';
8
8
  import { buildHaskellIR } from './haskell-ir.js';
@@ -62,12 +62,12 @@ function mergeResolved(declared, resolved) {
62
62
 
63
63
  /** Advisory data configured for this scan: a hash-pinned snapshot from the environment or the project's state dir. */
64
64
  export function configuredAdvisoryDb(root, env = process.env) {
65
- const path = env.AGENTIC_SECURITY_HACKAGE_ADVISORIES || (root && existsSync(statePath(root, 'hackage-advisories.json')) ? statePath(root, 'hackage-advisories.json') : null);
66
- if (!path) return { db: null, reason: 'no advisory snapshot configured (set AGENTIC_SECURITY_HACKAGE_ADVISORIES or provide the hackage-advisories.json snapshot in the project state directory)' };
65
+ const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_HACKAGE_ADVISORIES', fileName: 'hackage-advisories.json', root, env });
66
+ if (!sel.path) return { db: null, reason: `no advisory snapshot configured (set AGENTIC_SECURITY_HACKAGE_ADVISORIES, or place hackage-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('hackage-advisories.json') : ''}`, projectLocalIgnored: !!sel.projectLocalIgnored };
67
67
  let snap;
68
- try { snap = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { db: null, reason: `advisory snapshot unreadable: ${e.code || e.message}` }; }
68
+ try { snap = JSON.parse(readFileSync(sel.path, 'utf8')); } catch (e) { return { db: null, reason: `advisory snapshot unreadable: ${e.code || e.message}` }; }
69
69
  const r = loadAdvisorySnapshot(snap, { pinnedSha256: env.AGENTIC_SECURITY_HACKAGE_ADVISORIES_SHA256 || null });
70
- return r.ok ? { db: r.db, reason: null } : { db: null, reason: `advisory snapshot refused: ${r.reason}` };
70
+ return r.ok ? { db: r.db, reason: null, source: sel.source } : { db: null, reason: `advisory snapshot refused: ${r.reason}` };
71
71
  }
72
72
 
73
73
  /** Imports and import-qualified callees across the project's Haskell sources. */
@@ -201,6 +201,9 @@ function handlerFacts(ctx, fn) {
201
201
  if (e.kind === 'union' && e.hs && e.hs.branchConds) for (const bc of e.hs.branchConds) conds.push(bc.cond);
202
202
  }
203
203
  }
204
+ // Program order is SOURCE order. A wrapper guard (`guarded $ do ...`) encloses the statements it protects, but the lowering emits
205
+ // those statements as earlier control-flow nodes than the call that contains them, so node order would make the guard look late.
206
+ ordered.sort((a, b) => (Number.isFinite(a.line) && Number.isFinite(b.line) ? a.line - b.line : 0));
204
207
  const isGuardCall = (c) => {
205
208
  if (GUARD_PRIMITIVES.has(c.callee)) return true;
206
209
  if (c.hs && c.hs.status === 'resolved' && c.hs.target) return guardSummary(ctx, ctx.fnByQid.get(c.hs.target)).auth;
@@ -67,6 +67,8 @@ export function plausibleSecret(v) {
67
67
  if (/^\/(?:run|var|etc|nix|home|usr|opt|tmp)\//.test(t) || /^\.{0,2}\//.test(t)) return false;
68
68
  if (/^(?:https?|file|unix):\/\/[^:@\s]*$/.test(t)) return false;
69
69
  if (/^\$\{|^\$[A-Za-z_]/.test(t)) return false;
70
+ // wpa_supplicant's `ext:NAME` is a REFERENCE to a variable supplied at runtime (networking.wireless.environmentFile), not a value
71
+ if (/^ext:[A-Za-z0-9_.-]+$/.test(t)) return false;
70
72
  return true;
71
73
  }
72
74
 
@@ -140,6 +140,31 @@ const hasPathNode = (n, depth = 0) => {
140
140
  for (const k of Object.keys(n)) { const v = n[k]; if (Array.isArray(v)) { if (v.some((x) => hasPathNode(x, depth + 1))) return true; } else if (v && typeof v === 'object' && hasPathNode(v, depth + 1)) return true; }
141
141
  return false;
142
142
  };
143
+ // An expression that CREATES a store file from its arguments (`pkgs.writeText "k" "…"`): used as a key/cert option it puts the secret in the store
144
+ // exactly as a path literal does.
145
+ const STORE_WRITERS = new Set(['writeText', 'writeTextFile', 'writeTextDir', 'writeScript', 'writeScriptBin', 'toFile', 'copyPathToStore', 'runCommand', 'runCommandLocal']);
146
+ const isStoreCopy = (n, depth = 0) => {
147
+ if (!n || typeof n !== 'object' || depth > 12) return false;
148
+ if (n.type === 'app') { let h = n; while (h && h.type === 'app') h = h.fn; return !!h && ((h.type === 'select' && h.attrpath && h.attrpath.length && STORE_WRITERS.has(h.attrpath[h.attrpath.length - 1].name)) || (h.type === 'ident' && STORE_WRITERS.has(h.name))); }
149
+ return false;
150
+ };
151
+ const strOf = (n) => (n && n.type === 'string' && typeof n.literal === 'string' ? n.literal : null);
152
+ // sudo `extraRules`: a rule is NOPASSWD for ALL when one of its `commands` is ALL (or a {command = "ALL"; options = [ "NOPASSWD" ]}) with the NOPASSWD tag.
153
+ const sudoRulesNopasswdAll = (node) => {
154
+ if (!node || node.type !== 'list') return false;
155
+ for (const rule of node.items || []) {
156
+ const cmds = attrOf(rule, 'commands');
157
+ if (!cmds || !cmds.value || cmds.value.type !== 'list') continue;
158
+ for (const c of cmds.value.items || []) {
159
+ if (c.type !== 'attrset') continue;
160
+ const command = attrOf(c, 'command'); const options = attrOf(c, 'options');
161
+ const all = command && strOf(command.value) === 'ALL';
162
+ const nopass = options && options.value && options.value.type === 'list' && (options.value.items || []).some((x) => strOf(x) === 'NOPASSWD');
163
+ if (all && nopass) return true;
164
+ }
165
+ }
166
+ return false;
167
+ };
143
168
  const attrOf = (set, name) => (set && set.type === 'attrset' ? (set.bindings || []).find((b) => b.kind === 'attr' && b.path && b.path.length === 1 && b.path[0].name === name) : null);
144
169
  const isTrueNode = (n) => !!n && n.type === 'ident' && n.name === 'true';
145
170
 
@@ -291,6 +316,12 @@ export function analyzeNixosHardening(opts, extra = {}) {
291
316
  }
292
317
  }
293
318
 
319
+ const sudoRules = nixos('security.sudo.extraRules');
320
+ if (sudoRules && sudoState !== 'inactive' && sudoState !== 'unknown' && sudoRulesNopasswdAll(rhsNodeOf(sudoRules, opts.files))) {
321
+ addFinding('sudo-nopasswd-all', { anchor: anchorOf(sudoRules), scope: 'security.sudo.extraRules', state: sudoState === 'conditional' ? 'conditional' : 'active', uncertain: false, evidence: [evidenceOf(sudoRules)] });
322
+ entryPoints.push({ id: 'sudo-nopasswd-all', kind: 'sudo-nopasswd', state: sudoState === 'conditional' ? 'conditional' : 'active', privilege: 'root', evidence: [evidenceOf(sudoRules)] });
323
+ }
324
+
294
325
  const doasEnable = nixos('security.doas.enable');
295
326
  const doasState = stateOf(doasEnable);
296
327
  const doasCfg = note(nixos('security.doas.extraConfig'), 'doas');
@@ -356,7 +387,7 @@ export function analyzeNixosHardening(opts, extra = {}) {
356
387
  const st = nginxState === 'conditional' ? 'conditional' : 'active';
357
388
  for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^services\.nginx\.virtualHosts\..+\.(?:sslCertificateKey|sslTrustedCertificate)$/.test(x.path))) {
358
389
  const node = rhsNodeOf(o, opts.files);
359
- if (node && hasPathNode(node)) addFinding('tls-key-in-store', { anchor: anchorOf(o), scope: o.path, state: st, uncertain: false, evidence: [evidenceOf(o)] });
390
+ if (node && (hasPathNode(node) || isStoreCopy(node))) addFinding('tls-key-in-store', { anchor: anchorOf(o), scope: o.path, state: st, uncertain: false, evidence: [evidenceOf(o)] });
360
391
  else if (view(o).state === 'known' && typeof view(o).value === 'string') controls.push({ control: 'tls-key-runtime-path', option: o.path, applies: true });
361
392
  }
362
393
  for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^services\.nginx\.virtualHosts\..+\.forceSSL$/.test(x.path))) {
@@ -10,8 +10,9 @@
10
10
  //
11
11
  // Nothing here fetches, builds, evaluates or runs a project's code unless the evaluation is selected AND the sandbox proves its isolation.
12
12
 
13
- import { readFileSync, existsSync } from 'node:fs';
14
- import { statePath } from '../posture/state-dir.js';
13
+ import { readFileSync } from 'node:fs';
14
+ import { createHash } from 'node:crypto';
15
+ import { resolveOperatorSnapshot, IGNORED_NOTE } from './trusted-inputs.js';
15
16
  import { analyzeHaskellManifests } from './haskell-manifests.js';
16
17
  import { buildResolvedGraph } from './haskell-resolved-graph.js';
17
18
  import { importNixClosure } from './nix-closure.js';
@@ -58,6 +59,12 @@ export function resolvedHackageComponents(files) {
58
59
  const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
59
60
  const summary = { file: r.file, source: g.source, graphAvailable: g.graphAvailable, freshness: g.freshness, closure: g.closure, units: g.units ? g.units.length : null, edges: g.edges ? g.edges.length : null };
60
61
  if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
62
+ // The plan is a file in the scanned project. Where the project's own freeze file also pins a package, the two must agree: a plan that
63
+ // contradicts the freeze is stale or forged, and contributes no versions.
64
+ const frozen = new Map(((r.manifests && r.manifests.lockedPackages) || []).filter((l) => l.name && l.version).map((l) => [l.name, l.version]));
65
+ const clashes = (g.units || []).filter((u) => u.name && u.version && frozen.has(u.name) && frozen.get(u.name) !== u.version).map((u) => `${u.name} ${u.version} (plan) vs ${frozen.get(u.name)} (freeze)`);
66
+ if (clashes.length) { gaps.push({ kind: 'resolved-stale-plan-contradicts-freeze', detail: `the plan contradicts the freeze file (${clashes.slice(0, 3).join('; ')}): it is stale or forged, so no version was taken from it`, file: r.file }); return { components: [], gaps, summary }; }
67
+ gaps.push({ kind: 'resolved-project-supplied-plan', detail: 'the plan is a file in the scanned project: it is checked against the declared bounds and the freeze file, but whoever controls the project controls its contents', file: r.file });
61
68
  const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
62
69
  const direct = new Set((g.declared || []).map((d) => d.name));
63
70
  const components = [];
@@ -71,24 +78,39 @@ export function resolvedHackageComponents(files) {
71
78
  }
72
79
 
73
80
  // ── Nix closure ──────────────────────────────────────────────────────────────────
74
- function loadNixExports(files) {
75
- const exports = []; const sources = []; const problems = []; let expected = {};
76
- for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) {
77
- const parsed = parseJson(files[p]);
78
- sources.push(p);
79
- if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: p, detail: parsed.reason }); continue; }
81
+ function loadNixExports(files, env = process.env) {
82
+ const exports = []; const sources = []; const problems = []; const trust = new Set();
83
+ const take = (label, text, origin) => {
84
+ sources.push(label);
85
+ const parsed = parseJson(text);
86
+ if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: label, detail: parsed.reason }); return; }
80
87
  const j = parsed.value;
81
- if (j && Array.isArray(j.exports)) { exports.push(...j.exports.filter((e) => e && typeof e === 'object')); if (j.expected && typeof j.expected === 'object') expected = { ...expected, ...j.expected }; }
88
+ trust.add(origin);
89
+ // NOTE: an `expected` block inside the file is IGNORED. The file is the thing under test, so it cannot also say what it should be
90
+ // checked against; what an export must match is derived by the scanner (below) or comes from the operator.
91
+ if (j && Array.isArray(j.exports)) exports.push(...j.exports.filter((e) => e && typeof e === 'object'));
82
92
  else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
83
- else exports.push({ text: files[p] });
93
+ else exports.push({ text });
94
+ };
95
+ if (env.AGENTIC_SECURITY_NIX_EXPORT) {
96
+ try { take(env.AGENTIC_SECURITY_NIX_EXPORT, readFileSync(env.AGENTIC_SECURITY_NIX_EXPORT, 'utf8'), 'operator'); }
97
+ catch (e) { sources.push(env.AGENTIC_SECURITY_NIX_EXPORT); problems.push({ kind: 'malformed-export', file: env.AGENTIC_SECURITY_NIX_EXPORT, detail: `unreadable: ${e.code || e.message}` }); }
84
98
  }
85
- return { exports, sources, problems, expected };
99
+ for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) take(p, files[p], 'project-supplied');
100
+ // What the export must have been made against: the real flake.lock of this project (a forged export cannot also forge that file's hash
101
+ // without the lock it claims to describe being the lock that is here).
102
+ const expected = {};
103
+ const lockPath = Object.keys(files).filter((p) => /(^|\/)flake\.lock$/.test(p)).sort((a, b) => a.length - b.length)[0];
104
+ if (lockPath) expected.flakeLockSha256 = createHash('sha256').update(files[lockPath]).digest('hex');
105
+ else if (sources.length) problems.push({ kind: 'export-unverified', detail: 'there is no flake.lock to bind the export to, so it cannot be shown to describe this project' });
106
+ if (env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY) { try { expected.publicKeyPem = readFileSync(env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, 'utf8'); expected.requireSigned = true; } catch { problems.push({ kind: 'malformed-export', detail: 'the configured export public key is unreadable' }); } }
107
+ return { exports, sources, problems, expected, trust: [...trust] };
86
108
  }
87
109
 
88
110
  /** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
89
111
  export function nixClosureOf(files, { now = Date.now() } = {}) {
90
112
  return memoized(files, 'nix-closure', () => {
91
- const { exports, sources, problems, expected } = loadNixExports(files);
113
+ const { exports, sources, problems, expected, trust } = loadNixExports(files);
92
114
  if (!sources.length) return null;
93
115
  const closure = importNixClosure({ exports, expected, now });
94
116
  const drvEnv = {};
@@ -97,15 +119,19 @@ export function nixClosureOf(files, { now = Date.now() } = {}) {
97
119
  let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
98
120
  for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
99
121
  }
100
- return { closure, drvEnv, sources, problems };
122
+ // A disclosure that says the export does not describe THIS project (another lock, another target, a bad or missing required
123
+ // signature) means its contents are not evidence about this build: it is refused, not merely labelled.
124
+ const REFUSE = new Set(['stale-export', 'foreign-target', 'foreign-revision', 'invalid-signature', 'unsigned']);
125
+ const refused = (closure.disclosures || []).filter((d) => REFUSE.has(d.kind));
126
+ return { closure, drvEnv, sources, problems, trust, refused };
101
127
  });
102
128
  }
103
129
 
104
130
  /** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
105
131
  export function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
106
- const path = env.AGENTIC_SECURITY_NIX_ADVISORIES || (root && existsSync(statePath(root, 'nix-advisories.json')) ? statePath(root, 'nix-advisories.json') : null);
107
- if (!path) return { data: null, reason: 'no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES or provide nix-advisories.json in the project state directory)' };
108
- let snap; try { snap = JSON.parse(readFileSync(path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
132
+ const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
133
+ if (!sel.path) return { data: null, reason: `no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES, or place nix-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('nix-advisories.json') : ''}` };
134
+ let snap; try { snap = JSON.parse(readFileSync(sel.path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
109
135
  const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
110
136
  return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
111
137
  }
@@ -121,9 +147,11 @@ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = D
121
147
  const adv = configuredNixAdvisories(scanRoot, env, hackage);
122
148
  let usage = null; try { usage = collectUsage(files); } catch { usage = null; }
123
149
  let matched = null;
124
- try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
150
+ if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
125
151
  const gaps = [];
152
+ if (c.trust.includes('project-supplied') && !c.trust.includes('operator')) gaps.push({ kind: 'closure-project-supplied', detail: 'the closure export is a file in the scanned project, bound to its flake.lock but not signed: whoever controls the project controls its contents, so the ABSENCE of a finding for a component it lists is not evidence the real build is free of it (provide the export through AGENTIC_SECURITY_NIX_EXPORT, signed with AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, to have it treated as operator evidence)' });
126
153
  for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
154
+ for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
127
155
  for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
128
156
  if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
129
157
  else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
@@ -24,7 +24,7 @@ export const PAIR_GATE = Object.freeze({ change: 1, preserve: 0.9 });
24
24
  export const REQUIRED_CAPABILITIES = Object.freeze({
25
25
  haskell: Object.freeze({
26
26
  parser: 'parse-fixtures', sast: 'sast-holdout', taint: 'taint-holdout', 'privacy-lineage': 'privacy-holdout',
27
- auth: 'test-suite', sca: 'sca-fixtures', bom: 'test-suite', fix: 'fix-fixtures', integration: 'test-suite',
27
+ auth: 'auth-detection', sca: 'sca-fixtures', bom: 'test-suite', fix: 'fix-fixtures', integration: 'test-suite',
28
28
  }),
29
29
  nix: Object.freeze({
30
30
  parser: 'parse-fixtures', 'config-sast': 'sast-holdout', 'config-taint': 'taint-holdout', 'privacy-lineage': 'privacy-holdout',
@@ -32,6 +32,13 @@ export const REQUIRED_CAPABILITIES = Object.freeze({
32
32
  }),
33
33
  });
34
34
 
35
+ /**
36
+ * Authentication and authorization are rated on DETECTION of the families that carry them (measured on the frozen holdout, gated like
37
+ * any other layer) AND on the web-model test suite passing. A pass/fail test run alone is not a measurement of how well missing or
38
+ * broken authentication is found, so it can never promote this row by itself.
39
+ */
40
+ export const AUTH_FAMILIES = Object.freeze(['route-authentication', 'object-authorization']);
41
+
35
42
  /** Capabilities that need an external tool or host to be exercised at all: absent, the row is BLOCKED whatever its tests say. */
36
43
  export const REQUIRES_TOOL = Object.freeze({
37
44
  haskell: Object.freeze({ auth: Object.freeze({ tool: 'ghc', why: 'the route fixtures are compiled by GHC (HS-006.AC01)' }) }),
@@ -45,6 +52,32 @@ export const REQUIRES_TOOL = Object.freeze({
45
52
  export const NON_PROMOTING_EVIDENCE = Object.freeze(['installation', 'grammar-present', 'structural-only', 'documentation', 'model-assertion', 'taint-as-privacy', 'unknown']);
46
53
 
47
54
  const ratio = (n, d) => (d > 0 ? n / d : null);
55
+
56
+ /**
57
+ * Exact (Clopper-Pearson) two-sided confidence interval for k successes in n trials. A rate of 30/30 is NOT "100%": its 95% lower
58
+ * bound is about 0.88, and a table that prints 1.000 without it invites exactly that misreading.
59
+ */
60
+ export function clopperPearson(k, n, alpha = 0.05) {
61
+ if (!(n > 0)) return null;
62
+ const binomTail = (p, from) => { // P(X >= from) for X ~ Bin(n, p), computed in log space
63
+ let sum = 0;
64
+ for (let i = from; i <= n; i++) {
65
+ let lc = 0; for (let j = 1; j <= i; j++) lc += Math.log((n - i + j) / j);
66
+ sum += Math.exp(lc + (p > 0 ? i * Math.log(p) : (i === 0 ? 0 : -Infinity)) + (p < 1 ? (n - i) * Math.log(1 - p) : (n - i === 0 ? 0 : -Infinity)));
67
+ }
68
+ return sum;
69
+ };
70
+ const bisect = (f, lo, hi) => { for (let i = 0; i < 80; i++) { const mid = (lo + hi) / 2; if (f(mid)) hi = mid; else lo = mid; } return (lo + hi) / 2; };
71
+ const lower = k === 0 ? 0 : bisect((p) => binomTail(p, k) >= alpha / 2, 0, 1);
72
+ const upper = k === n ? 1 : bisect((p) => binomTail(p, k + 1) >= 1 - alpha / 2, 0, 1);
73
+ return [lower, upper];
74
+ }
75
+ // Every unseen shape is instantiated with TWO near-identical nouns, so those two cases are one observation, not two. The interval over
76
+ // cases would be too narrow; this one is over shapes (counts halved, rounded), and is labelled so.
77
+ const SHAPE_CLUSTER = 2;
78
+ const ciShapes = (k, n) => { const r = clopperPearson(Math.round(k / SHAPE_CLUSTER), Math.max(1, Math.round(n / SHAPE_CLUSTER))); return r ? [Number(r[0].toFixed(4)), Number(r[1].toFixed(4))] : null; };
79
+ const ci = (k, n) => { const r = clopperPearson(k, n); return r ? [Number(r[0].toFixed(4)), Number(r[1].toFixed(4))] : null; };
80
+ const withInterval = (e) => (e && Number.isFinite(e.tp) ? { ...e, ci: { precision: ci(e.tp, e.tp + (e.fp || 0)), recall: ci(e.tp, e.tp + (e.fn || 0)), level: 0.95, method: 'Clopper-Pearson' } } : e);
48
81
  const pct = (x) => (x == null ? 'not measured' : `${(x * 100).toFixed(1)}%`);
49
82
 
50
83
  function rate(layer) {
@@ -132,6 +165,19 @@ export function evaluateSupport(language, input) {
132
165
  const u = detection.unknownOutcomes;
133
166
  if (u && u.silentClean > 0) reasons.push(`${u.silentClean} unknown case(s) came back with neither a finding nor a disclosure (false assurance)`);
134
167
  row(cap, !layer || !layer.measured ? 'not-measured' : reasons.length ? 'failed' : 'supported', reasons, layer ? { metric: kind, layer: key, tp: layer.tp, fp: layer.fp, fn: layer.fn, tn: layer.tn, precision: layer.precision, recall: layer.recall, f1: layer.f1, cases: layer.cases, families: layer.families, strictPrecision: layer.strict && layer.strict.precision } : null);
168
+ } else if (kind === 'auth-detection') {
169
+ const sast = detection.layers && detection.layers.sast;
170
+ const fams = Object.fromEntries(AUTH_FAMILIES.filter((f) => sast && sast.families && sast.families[f]).map((f) => [f, sast.families[f]]));
171
+ if (!Object.keys(fams).length) { row(cap, 'not-measured', [`no measured authentication families (${AUTH_FAMILIES.join(', ')}): a passing test run is not a detection measurement`]); continue; }
172
+ const sum = (k) => Object.values(fams).reduce((n, x) => n + (x[k] || 0), 0);
173
+ const tp = sum('tp'); const fp = sum('fp'); const fn = sum('fn');
174
+ const precision = ratio(tp, tp + fp); const recall = ratio(tp, tp + fn);
175
+ const f1 = precision != null && recall != null && precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : null;
176
+ const pseudo = { measured: true, tp, fp, fn, tn: sum('tn'), precision, recall, f1, families: fams };
177
+ const g = gateLayer(pseudo, filterFamilies(detection.perFamilyCases, pseudo), { kind: 'auth' });
178
+ const ts = suiteStatus(input.suites, (input.capabilitySuites || {})[cap]);
179
+ const reasons = [...g.reasons, ...dataOk.reasons, ...(ts.ok ? [] : ts.reasons)];
180
+ row(cap, reasons.length ? (ts.status === 'blocked' ? 'blocked' : 'failed') : 'supported', reasons, { metric: 'auth-detection', layer: 'sast', families: Object.keys(fams), tp, fp, fn, precision, recall, f1, suites: ((input.capabilitySuites || {})[cap] || []) });
135
181
  } else if (kind === 'privacy-holdout') {
136
182
  const p = eco.privacy;
137
183
  if (!p || !(p.positives > 0 && p.negatives > 0)) { row(cap, 'not-measured', ['no privacy field-to-sink measurement (a security finding or a taint metric is never a privacy metric)']); continue; }
@@ -168,6 +214,29 @@ export function evaluateSupport(language, input) {
168
214
  row(cap, ts.status === 'supported' ? 'supported' : ts.status, ts.reasons, { metric: kind, suites: ((input.capabilitySuites || {})[cap] || []) });
169
215
  }
170
216
  }
217
+ // 95% intervals beside every measured rate, and the strict precision (which counts findings of other families) beside the scoped one
218
+ for (const r of Object.values(out.rows)) {
219
+ if (!r.evidence || !Number.isFinite(r.evidence.tp)) continue;
220
+ const key = /taint/.test(r.capability) ? 'taint' : 'sast';
221
+ const strict = r.evidence.metric === 'sast-holdout' || r.evidence.metric === 'taint-holdout' ? (detection.layers || {})[key] && (detection.layers[key].strict || null) : null;
222
+ r.evidence = withInterval({ ...r.evidence, ...(strict && Number.isFinite(strict.precision) ? { strictPrecision: strict.precision } : {}) });
223
+ }
224
+ // Generalisation: the SAME layers measured on shapes absent from train, validation and holdout. It never changes a row's status (the
225
+ // PRD's gates are defined on the frozen holdout); it is stated beside it, with the shortfalls named, so "supported" is not read as more.
226
+ const un = input.unseen && input.unseen.ecosystems && input.unseen.ecosystems[language] && input.unseen.ecosystems[language].detection;
227
+ out.generalizationGaps = [];
228
+ for (const r of Object.values(out.rows)) {
229
+ if (!r.evidence || !/^(sast|taint)-holdout$/.test(r.evidence.metric || '')) continue;
230
+ const key = /taint/.test(r.capability) ? 'taint' : 'sast';
231
+ const u = un && un.layers && un.layers[key];
232
+ if (!u || !u.measured) { r.generalization = { status: 'not-measured' }; continue; }
233
+ const shortfalls = [];
234
+ if (!(u.precision >= TARGETS.precision)) shortfalls.push(`precision ${pct(u.precision)} < ${pct(TARGETS.precision)}`);
235
+ if (!(u.recall >= TARGETS.recall)) shortfalls.push(`recall ${pct(u.recall)} < ${pct(TARGETS.recall)}`);
236
+ if (!(u.f1 >= TARGETS.f1)) shortfalls.push(`F1 ${pct(u.f1)} < ${pct(TARGETS.f1)}`);
237
+ r.generalization = { status: shortfalls.length ? 'below-target' : 'meets-targets', shortfalls, cases: u.cases, tp: u.tp, fp: u.fp, fn: u.fn, tn: u.tn, precision: u.precision, recall: u.recall, f1: u.f1, strictPrecision: u.strict && u.strict.precision, ci: { precision: ciShapes(u.tp, u.tp + u.fp), recall: ciShapes(u.tp, u.tp + u.fn), level: 0.95, method: `Clopper-Pearson over shapes (${SHAPE_CLUSTER} near-identical cases per shape)` } };
238
+ if (shortfalls.length) out.generalizationGaps.push(`${r.capability}: ${shortfalls.join(', ')}`);
239
+ }
171
240
  // a capability whose tool is absent is blocked, not passed and not skipped
172
241
  const tools = input.tools || {};
173
242
  for (const [cap, req] of Object.entries(REQUIRES_TOOL[language] || {})) {