@clear-capabilities/agentic-security-scanner 0.155.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
- 5b1c3482deb6bf15eb606402755278ad1e04a5b2e8b12c5b5246c5dcd4028b50 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.155.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,7 +81,7 @@
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 test/language/language-trust-boundary.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",
@@ -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 };
@@ -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))) {
@@ -72,6 +72,10 @@ export function clopperPearson(k, n, alpha = 0.05) {
72
72
  const upper = k === n ? 1 : bisect((p) => binomTail(p, k + 1) >= 1 - alpha / 2, 0, 1);
73
73
  return [lower, upper];
74
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; };
75
79
  const ci = (k, n) => { const r = clopperPearson(k, n); return r ? [Number(r[0].toFixed(4)), Number(r[1].toFixed(4))] : null; };
76
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);
77
81
  const pct = (x) => (x == null ? 'not measured' : `${(x * 100).toFixed(1)}%`);
@@ -230,7 +234,7 @@ export function evaluateSupport(language, input) {
230
234
  if (!(u.precision >= TARGETS.precision)) shortfalls.push(`precision ${pct(u.precision)} < ${pct(TARGETS.precision)}`);
231
235
  if (!(u.recall >= TARGETS.recall)) shortfalls.push(`recall ${pct(u.recall)} < ${pct(TARGETS.recall)}`);
232
236
  if (!(u.f1 >= TARGETS.f1)) shortfalls.push(`F1 ${pct(u.f1)} < ${pct(TARGETS.f1)}`);
233
- 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: ci(u.tp, u.tp + u.fp), recall: ci(u.tp, u.tp + u.fn), level: 0.95, method: 'Clopper-Pearson' } };
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)` } };
234
238
  if (shortfalls.length) out.generalizationGaps.push(`${r.capability}: ${shortfalls.join(', ')}`);
235
239
  }
236
240
  // a capability whose tool is absent is blocked, not passed and not skipped
@@ -146,8 +146,10 @@ export function judgeFixWitness(before, after) {
146
146
  export function haskellRunner() {
147
147
  const p = which('runghc') || which('runhaskell');
148
148
  if (!p) return { available: false, reason: 'no Haskell toolchain (runghc) on PATH; witnesses for Haskell findings are unavailable here' };
149
- const v = spawnSync(p, ['--version'], { encoding: 'utf8', timeout: 10_000 });
150
- return v.status === 0 ? { available: true, path: p, version: String(v.stdout || v.stderr).trim() } : { available: false, reason: 'the Haskell runner did not start' };
149
+ // GHC's first start on a cold, loaded machine (a CI runner) has taken more than 10 s; a probe that gives up there reports a toolchain
150
+ // that is installed as absent, and the answer then differs between two calls in one run.
151
+ const v = spawnSync(p, ['--version'], { encoding: 'utf8', timeout: 60_000 });
152
+ return v.status === 0 ? { available: true, path: p, version: String(v.stdout || v.stderr).trim() } : { available: false, reason: v.error && v.error.code === 'ETIMEDOUT' ? 'the Haskell runner did not start within 60 s' : 'the Haskell runner did not start' };
151
153
  }
152
154
 
153
155
  /**