@clear-capabilities/agentic-security-scanner 0.195.8 → 0.195.10

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.
@@ -818,8 +818,20 @@ export function analyzeNixScripts(opts = {}) {
818
818
  }
819
819
  const envMap = collectEnvironment(site, ev);
820
820
 
821
+ const emitted = new Map();
821
822
  const emit = (rule, o) => {
822
823
  const R = RULES[rule];
824
+ // One finding per rule, script line and origin class: `rsync ${cfg.source} ${cfg.destination}` is one unescaped command line
825
+ // with two configuration interpolations, not two findings. Origins of different classes on one line (an attacker value next
826
+ // to a configuration value) stay separate findings, as NIX-003.AC04 requires; the later same-class interpolations join the first.
827
+ const kinds = [...new Set((o.origins || []).map((x) => x.kind).filter((k) => k !== 'constant'))].sort().join('+');
828
+ const dupKey = `${rule}|${siteFile}|${o.line}|${kinds}`;
829
+ const prior = emitted.get(dupKey);
830
+ if (prior) {
831
+ for (const x of o.origins || []) if (!prior.origins.some((y) => y.kind === x.kind && y.detail === x.detail)) prior.origins.push({ kind: x.kind, detail: x.detail });
832
+ prior.alsoAt = [...(prior.alsoAt || []), { column: o.column }];
833
+ return prior;
834
+ }
823
835
  const f = {
824
836
  id: `NIX-SCRIPT-${rule}-${siteFile}:${o.line}:${o.column}`,
825
837
  severity: o.severity, file: siteFile, line: o.line, column: o.column, vuln: o.vuln || R.vuln, cwe: R.cwe,
@@ -832,6 +844,7 @@ export function analyzeNixScripts(opts = {}) {
832
844
  ...(o.uncertainty ? { uncertainty: o.uncertainty } : {}), ...(o.extra || {}),
833
845
  };
834
846
  findings.push(f);
847
+ emitted.set(dupKey, f);
835
848
  return f;
836
849
  };
837
850
 
@@ -275,6 +275,20 @@ class Walker {
275
275
  }
276
276
  return;
277
277
  }
278
+ // `passwordFile = pkgs.writeText "x" "literal"`: an option that expects a RUNTIME path to a secret is handed a file the evaluation
279
+ // writes into the store, with the secret's text written here. The option name says what the file holds; the writer says where it lands.
280
+ if (v && v.type === 'app' && PATHISH_TAIL.test(last) && SECRET_NAME.test(last.replace(PATHISH_TAIL, ''))) {
281
+ const { fn, args } = this.ev.flatten(v);
282
+ const c = this.ev.calleeOf(fn);
283
+ const idx = c.name ? WRITERS.get(c.name) : undefined;
284
+ const content = Number.isInteger(idx) && args[idx] ? unparen(args[idx]) : null;
285
+ if (content && content.type === 'string' && !content.interpolated && plausibleSecret(content.literal)) {
286
+ this.scriptKeys.add(`${file}:${content.start}`);
287
+ const option = full.filter(Boolean).join('.');
288
+ this.emit('nix-secret-plaintext', { ...this.at(file, bspan), file, severity: 'high', exposure: 'store', description: `${option} expects a runtime path to a secret but is given a file that ${c.name} writes into the world-readable Nix store, with the value written here as a literal (value redacted).`, source: { kind: 'literal', file, line: content.span ? content.span.startLine : (bspan ? bspan.startLine : 1) }, destination: { kind: 'store', label: `${c.name} file`, exposure: 'store' }, evidence: { ...redact(content.literal), option, writer: c.name }, originalLocation: bspan, chain: [hop(file, content.span, 'literal'), hop(file, bspan, `${c.name} into ${last}`)].filter(Boolean), origins: [mkOrigin('secret', `literal handed to ${c.name}`)] });
289
+ return;
290
+ }
291
+ }
278
292
  // evaluate the value for secret origins
279
293
  if (!v || v.type === 'attrset' || v.type === 'lambda' || v.type === 'list') return;
280
294
  if (this.isRuntimeRef(v, env)) { this.safe.push({ file, line: bspan && bspan.startLine, attr: full.filter(Boolean).join('.'), reason: 'runtime secret reference' }); return; }
@@ -339,8 +339,11 @@ export function analyzeNixosHardening(opts, extra = {}) {
339
339
  const sv = view(sudoCfg);
340
340
  if (sv.state === 'known' && typeof sv.value === 'string' && sudoState !== 'inactive' && sudoState !== 'unknown') {
341
341
  const text = stripNixHash(sv.value);
342
- if (/NOPASSWD\s*:\s*ALL\b/.test(text)) {
343
- addFinding('sudo-nopasswd-all', { anchor: anchorOf(sudoCfg), scope: 'security.sudo.extraConfig', state: sudoState === 'conditional' ? 'conditional' : 'active', uncertain: !sv.definite, evidence: [evidenceOf(sudoCfg)] });
342
+ // `Defaults !authenticate` (optionally scoped, `Defaults:%wheel !authenticate`) turns the password off for every rule the
343
+ // scope matches, which is the same grant as NOPASSWD: ALL written as a default
344
+ const noAuth = /^\s*Defaults(?:[:>@!]\S+)?\s+(?:[\w,]+\s*,\s*)?!authenticate\b/m.test(text);
345
+ if (/NOPASSWD\s*:\s*ALL\b/.test(text) || noAuth) {
346
+ addFinding('sudo-nopasswd-all', { anchor: anchorOf(sudoCfg), scope: 'security.sudo.extraConfig', state: sudoState === 'conditional' ? 'conditional' : 'active', uncertain: !sv.definite, evidence: [evidenceOf(sudoCfg)], ...(noAuth ? { extra: { directive: 'Defaults !authenticate' } } : {}) });
344
347
  entryPoints.push({ id: 'sudo-nopasswd-all', kind: 'sudo-nopasswd', state: sudoState === 'conditional' ? 'conditional' : 'active', privilege: 'root', evidence: [evidenceOf(sudoCfg)] });
345
348
  }
346
349
  }
@@ -525,6 +528,18 @@ export function analyzeNixosHardening(opts, extra = {}) {
525
528
  }
526
529
  }
527
530
 
531
+ // ── TLS private keys of other services (postfix `sslKey`, dovecot `sslServerKey`, httpd `sslServerKey`, a `keyFile`/`tlsKey`/`privateKeyFile`)
532
+ // The same rule as nginx above: a path literal or a store-writer call puts the key in the store; a string is a runtime path.
533
+ for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^services\.(?!nginx\.)[^.]+\.(?:.*\.)?(?:sslKey|sslServerKey|sslCertificateKey|tlsKey|keyFile|privateKeyFile|tlsKeyFile)$/.test(x.path))) {
534
+ const svc = o.path.split('.')[1];
535
+ const svcState = stateOf(nixos(`services.${svc}.enable`));
536
+ if (svcState !== 'active' && svcState !== 'conditional') continue;
537
+ const st = svcState === 'conditional' ? 'conditional' : 'active';
538
+ const node = rhsNodeOf(o, opts.files);
539
+ if (node && (hasPathNode(node) || isStoreCopy(node))) addFinding('tls-key-in-store', { anchor: anchorOf(o), scope: o.path, state: st, uncertain: false, evidence: [evidenceOf(o)] });
540
+ else if (view(o).state === 'known' && typeof view(o).value === 'string') controls.push({ control: 'tls-key-runtime-path', option: o.path, applies: true });
541
+ }
542
+
528
543
  // ── firewall ──
529
544
  const listeningNow = inventory.filter((i) => i.state === 'active' && i.listening !== 'no' && i.listening !== 'localhost-only');
530
545
  if (fwOff === 'yes' || fwOff === 'conditional') {
@@ -635,7 +650,9 @@ export function analyzeNixosHardening(opts, extra = {}) {
635
650
  const r = sc(key);
636
651
  if (!r) continue;
637
652
  const v = view(r);
638
- const items = v.state === 'known' ? asList(v.value) : v.state === 'conditional' ? asList(v.definiteItems) : [];
653
+ // systemd accepts the capability set as a list or as one space-separated string (`AmbientCapabilities = "CAP_SYS_ADMIN"`)
654
+ const raw = v.state === 'known' ? v.value : v.state === 'conditional' ? v.definiteItems : [];
655
+ const items = typeof raw === 'string' ? raw.split(/\s+/).filter(Boolean) : asList(raw);
639
656
  const bad = items.filter((c) => typeof c === 'string' && !c.startsWith('~') && DANGEROUS_CAPS.has(c));
640
657
  // a bounding set only limits; an ambient set grants, so only Ambient is a hard finding for non-root-looking services
641
658
  if (bad.length && (key === 'AmbientCapabilities' || identity.state !== 'root-default')) {
@@ -243,6 +243,8 @@ export function evaluateSupport(language, input) {
243
243
  for (const [cap, req] of Object.entries(REQUIRES_TOOL[language] || {})) {
244
244
  const r = out.rows[cap];
245
245
  if (r && !tools[req.tool]) out.rows[cap] = { ...r, status: 'blocked', reasons: [`${req.tool} is not available on the measuring host: ${req.why}`, ...r.reasons] };
246
+ // a GHC other than the recorded toolchain compiles the fixtures against an empty package set: not a verdict on the engine
247
+ else if (r && req.tool === 'ghc' && tools.ghcMatchesRecorded === false && r.status !== 'supported') out.rows[cap] = { ...r, status: 'blocked', reasons: [`ghc ${tools.ghcVersion} on the measuring host is not the recorded toolchain (${tools.ghcRecorded}); the criterion that needs it could not be judged`, ...r.reasons] };
246
248
  }
247
249
  const rows = Object.values(out.rows);
248
250
  out.summary = { required: rows.length, supported: rows.filter((r) => r.status === 'supported').length, notSupported: rows.filter((r) => r.status !== 'supported').map((r) => `${r.capability}:${r.status}`), dataOk: dataOk.ok };