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

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.
@@ -10,7 +10,11 @@
10
10
  // is lowered to `info`, never dropped
11
11
  // api-contract the value is a parameter of an exported function that nothing in the analysed code calls, and the function
12
12
  // hands it WHOLE (or as the root it appends fixed text to) to a path or command operation: a thin wrapper.
13
- // The caller chose what to read or run, and the wrapper has no fixed part to escape from. Lowered to `info`
13
+ // The caller chose what to read or run, and the wrapper has no fixed part to escape from. Lowered to `info`.
14
+ // Path operations joined this way since 0.195.9: `renderFile path = responseFile .. path` and
15
+ // `store path = readFile (cs path)` were 2 of the 29 flagged path sites on the real-code sets and no true
16
+ // positive had that shape; a parameter spliced UNDER a fixed directory (`"/srv/" ++ name`) is still kept,
17
+ // because the function chose the directory and lets the caller leave it
14
18
  // entry-parameter a parameter of such a function used inside a larger value the function builds: its own construction, kept
15
19
  // data-input standard input or a file's content in a service or a library: data the program processes, kept
16
20
  // unattributed the engine recorded no origin: kept
@@ -30,10 +34,13 @@ const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'coo
30
34
  const DATA = new Set(['stdin', 'file-read']);
31
35
  const WEAK_PARAM_LABEL = /\(caller-controlled\)$/;
32
36
  // CWE-918 is deliberately NOT a wrapper family: `fetch url = parseRequest url >>= httpNoBody` is pinned as a finding in
33
- // test/haskell/haskell-generalization.test.js (a URL the caller chooses is the SSRF shape of the development sets), the same way path
34
- // wrappers are kept. unseen-v4 labels that shape safe when the URL is configuration; that disagreement is recorded, not decided here.
35
- const WRAPPER_FAMILIES = new Set(['CWE-78', 'CWE-88']);
36
- const CONVERSIONS = /(?:^|\.)(?:pack|unpack|fromString|toString|show|encodeUtf8|decodeUtf8|toStrict|fromStrict|toText|unpackFS|fromText)$/;
37
+ // test/haskell/haskell-generalization.test.js (a URL the caller chooses is the SSRF shape of the development sets). unseen-v4 labels
38
+ // that shape safe when the URL is configuration; that disagreement is recorded, not decided here. CWE-22 joined the set in 0.195.9
39
+ // (see the header): the synthetic development sets label `load name = readFile name` vulnerable, the real-code sets label every such
40
+ // site a false positive, and the real code won; a parameter under a fixed directory is still `entry-parameter`.
41
+ const WRAPPER_FAMILIES = new Set(['CWE-78', 'CWE-88', 'CWE-22']);
42
+ // `cs`/`convertString` (string-conversions) and `toS` (protolude) change the type of a text value and nothing else
43
+ const CONVERSIONS = /(?:^|\.)(?:pack|unpack|fromString|toString|show|encodeUtf8|decodeUtf8|toStrict|fromStrict|toText|unpackFS|fromText|cs|convertString|toS|toSL|toST)$/;
37
44
  const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
38
45
  // list plumbing that only lays the parameters out as text (`unwords args`, `concatMap (' ':) args`): no fixed words are added
39
46
  const LIST_LAYOUT = /(?:^|\.)(?:unwords|unlines|intercalate|concatMap|concat|map|fmap)$/;
@@ -261,9 +268,14 @@ export function applyTrustBoundary(findings, perFile) {
261
268
  else if (roots.length && [...classes].every((c) => c === 'operator')) {
262
269
  verdict = 'operator-controlled';
263
270
  reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
264
- } else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance))) {
271
+ } else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance) || (f.cwe === 'CWE-22' && r.provenance === 'stdin'))) {
272
+ // Standard input counts here for a PATH operation only: a command-line program reading a file name from its own standard input
273
+ // is the person at the keyboard naming a file (14 of the 29 flagged path sites on the real-code sets, every one a false positive).
274
+ // A command built from standard input stays reported (`getLine >>= callCommand`, pinned in test/haskell/haskell-trust-boundary.test.js).
265
275
  verdict = 'operator-controlled';
266
- reason = 'the function belongs to a command-line program (reached from a main that imports no server framework) and the value comes from files the person running it pointed the program at, or from its arguments and environment; standard input is not covered by this rule';
276
+ reason = f.cwe === 'CWE-22' && roots.some((r) => r.provenance === 'stdin')
277
+ ? 'the function belongs to a command-line program (reached from a main that imports no server framework) and the file it works on is named by the person running it: on its standard input, in files it was pointed at, or in its arguments and environment'
278
+ : 'the function belongs to a command-line program (reached from a main that imports no server framework) and the value comes from files the person running it pointed the program at, or from its arguments and environment; standard input is covered by this rule only for a path operation';
267
279
  } else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
268
280
  else if (classes.has('weak-param')) {
269
281
  verdict = 'entry-parameter';
@@ -38,8 +38,12 @@ const SCOTTY_METHODS = { get: 'GET', post: 'POST', put: 'PUT', delete: 'DELETE',
38
38
  const SENSITIVE_PREFIX = ['Database.', 'System.Process.', 'System.Directory.', 'Prelude.readFile', 'Prelude.writeFile', 'Prelude.appendFile', 'System.IO.', 'Network.HTTP.Simple.', 'Network.HTTP.Client.'];
39
39
  const PERSIST_FNS = new Set(['get', 'getBy', 'selectFirst', 'selectList', 'insert', 'insert_', 'update', 'updateWhere', 'delete', 'deleteWhere', 'replace', 'runDB', 'getJust', 'upsert']);
40
40
  const GUARD_PRIMITIVES = new Set(['Yesod.Core.requireAuthId', 'Yesod.Core.requireAuth', 'Yesod.Auth.requireAuthId', 'Yesod.Auth.requireAuth', 'Yesod.requireAuthId', 'Yesod.requireAuth', 'Network.Wai.Middleware.HttpAuth.basicAuth']);
41
- const CREDENTIAL_HEADER = /^(?:authorization|cookie|x-api-key|x-auth-token|x-access-token)$/i;
41
+ // a header that carries the caller's credential: the standard two, and the custom names projects give a token or session header
42
+ const CREDENTIAL_HEADER = /^(?:authorization|cookie|x-api-key|x-api-token|x-auth-token|x-auth|x-access-token|x-session(?:-(?:id|token|key))?|x-token|x-user-token|x-csrf-token|x-xsrf-token)$/i;
42
43
  const ADMIN_PATH = /(?:^|\/)(?:admin|internal|manage|superuser)(?:\/|$|:)/i;
44
+ // an authentication endpoint (sign-in, registration, password reset, logout, an OAuth callback) is public by construction
45
+ const AUTH_ENDPOINT_PATH = /(?:^|\/)(?:login|log-in|sign-?in|sign-?up|signup|register|registration|logout|log-out|sign-?out|password\/(?:reset|forgot)|reset-password|forgot-password|password-reset|oauth|callback|token|auth|authenticate|session)(?:\/|$|:|\.)/i;
46
+ const AUTH_ENDPOINT_NAME = /(?:login|signin|signup|register|logout|signout|resetpassword|passwordreset|forgotpassword|authenticate)/i;
43
47
  const ROLE_CALL = /(?:^|\.)(?:isAdmin|hasRole|requireAdmin|checkRole|hasPermission|requireRole|requirePermission|isSuperuser)$/i;
44
48
  const OWNER_CALL = /(?:^|\.)(?:checkOwner|ownedBy|belongsTo|isOwner|requireOwner|authorizeOwner)$/i;
45
49
  const CSRF_CALL = /csrf|xsrf/i;
@@ -1160,8 +1164,11 @@ function emitFindings(ctx, result, yesodAuthz) {
1160
1164
  if (!R.handler || R.handler.kind === 'unknown') continue;
1161
1165
  const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
1162
1166
  const privileged = ADMIN_PATH.test(R.path) || /admin/i.test(R.handler.name || '');
1167
+ // An authentication endpoint is public by construction: the request that signs in, registers or starts a password reset carries no
1168
+ // session yet. Its own flaws (a weak hash, a predictable code) are other families' findings; a missing-auth finding on it is noise.
1169
+ const authEndpoint = AUTH_ENDPOINT_PATH.test(R.path || '') || AUTH_ENDPOINT_NAME.test(R.handler.name || '');
1163
1170
  if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: R.auth.evidence[0].order === 'unknown' ? `the rejection ${R.auth.evidence[0].callee} cannot be shown to run before the first sensitive operation (${R.auth.evidence[0].reason})` : `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
1164
- else if (R.auth.status === 'none' && (writes || privileged) && !(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) {
1171
+ else if (R.auth.status === 'none' && (writes || privileged) && !authEndpoint && !(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) {
1165
1172
  const how = R.method === 'ANY' ? 'any method' : writes ? 'state-changing method' : 'privileged path';
1166
1173
  if (R.sensitive !== false) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: how });
1167
1174
  // No database, file or process call is visible, but the handler calls something the engine cannot see into (a project function, a
@@ -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')) {