@clear-capabilities/agentic-security-scanner 0.195.7 → 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.
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { buildHaskellIR, lowerHandlerExpr, resolveName } from './haskell-ir.js';
22
22
  import { codeOfLine } from './haskell-comments.js';
23
- import { qualifyAmbiguous, HS_WEB_FRAMEWORKS, HS_WEB_MODEL_VERSION, modelStatus } from './haskell-models.js';
23
+ import { qualifyAmbiguous, HS_WEB_FRAMEWORKS, HS_WEB_MODEL_VERSION, modelStatus, handlerFrameworkOf } from './haskell-models.js';
24
24
 
25
25
  export const HS_WEB_RULES_VERSION = 'haskell-web-rules/1';
26
26
 
@@ -38,11 +38,28 @@ 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;
50
+ // Evidence that an object HAS an owner, read from the data the handler touches, never from a route or file name: a column of the statement
51
+ // it runs (`owner_id`, `tenant_id`, `created_by`) or a field of a record declared in the project (`invOwner`, `profileUser`). An id lookup that
52
+ // selects such an object without consulting the owner (the column is not in the WHERE clause; the field is never read in the handler) is a
53
+ // lookup the client steers, whether or not a principal was authenticated.
54
+ const OWNER_COL = /\b(?:owner|tenant|account|customer|author|created_by|user)_?id\b|\bowner\b/i;
55
+ const OWNER_FIELD = /(?:^|_)(?:owner|tenant|author|created_?by)(?:_?id)?$|(?:^|_)(?:user|account|customer)_?id$/i;
56
+ const SQL_VERB = /\b(?:select|update|delete|insert)\b/i;
57
+ // A container lookup by key (`M.lookup invoiceId m`, `HM.!? key`): not a sensitive operation (no database, file or process), but the step at which an
58
+ // object is selected by a client-supplied id, so it counts for the ownership judgement only.
59
+ const LOOKUP_CALL = /(?:^|\.)(?:lookup|!\?|!|find|findWithDefault|elemAt)$/;
60
+ // A call that only builds the response or lays values out: a handler whose every call is one of these does nothing a client could abuse,
61
+ // so a missing authentication check on it is a question about the route (its method and path), not a visible operation.
62
+ const RESPONSE_ONLY = /^(?:Web\.Scotty(?:\.Trans)?\.(?:text|json|html|status|setHeader|addHeader|raw|redirect|finish|raise|raiseStatus|param|params|queryParam|captureParam|header|headers|body|jsonData)|Servant(?:\.Server)?\.(?:throwError|throwAll)|Yesod(?:\.Core)?\.(?:redirect|returnJson|defaultLayout|setTitle|sendResponse|notFound|permissionDenied|invalidArgs|requireJsonBody|lookupGetParam|lookupPostParam|toWidget|hamletToRepHtml)|Prelude\.(?:pure|return|fmap|map|concatMap|unlines|lines|show|length|not|maybe|either|fst|snd|const|id|flip|unwords|words|filter|zip|lookup|elem|notElem)|Control\.Monad\.(?:when|unless|void|forM|forM_|mapM|mapM_|guard)|Control\.Monad\.IO\.Class\.liftIO|Control\.Monad\.Trans\.Class\.lift|Data\.(?:Text|Text\.Lazy|Text\.Encoding|ByteString(?:\.Char8|\.Lazy|\.Lazy\.Char8)?|Aeson|Maybe|List|Map|Map\.Strict|Char|String|Functor|Foldable)\.[^.]+)$/;
46
63
 
47
64
  // ── AST helpers ──────────────────────────────────────────────────────────────
48
65
  function unparen(e) { while (e && e.t === 'paren') e = e.e; return e; }
@@ -360,9 +377,14 @@ function handlerFacts(ctx, fn, opts = {}) {
360
377
  for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
361
378
  // ids read from the request
362
379
  const idVars = new Set();
380
+ const SCOTTY_PARAM = /^Web\.Scotty(?:\.Trans)?\.(?:param|captureParam|queryParam|pathParam)$/;
381
+ // `docId <- param "id" :: ActionM Int` lowers to a source temporary (`$src0 <- param "id"`) and a number whose `hs.inner` names that temporary
382
+ const paramReads = new Map();
383
+ for (const a of assigns) if (a.source.kind === 'call' && SCOTTY_PARAM.test(a.source.callee)) paramReads.set(a.target, a.source);
363
384
  for (const a of assigns) {
364
- const s = a.source;
365
- if (s.kind === 'call' && /^Web\.Scotty(?:\.Trans)?\.(?:param|captureParam|queryParam|pathParam)$/.test(s.callee)) {
385
+ let s = a.source.kind === 'literal' && a.source.hs && a.source.hs.inner ? a.source.hs.inner : a.source;
386
+ if (s.kind === 'ident' && paramReads.has(s.name)) s = paramReads.get(s.name);
387
+ if (s.kind === 'call' && SCOTTY_PARAM.test(s.callee)) {
366
388
  const nm = headerArg(s);
367
389
  if (nm && /id$/i.test(nm)) idVars.add(a.target);
368
390
  }
@@ -388,6 +410,45 @@ function hasOwnership(ctx, facts) {
388
410
  return null;
389
411
  }
390
412
 
413
+ // Owner evidence for the lookups of one handler (see OWNER_COL / OWNER_FIELD). Returns the first piece found, or null.
414
+ function ownerEvidence(ctx, fn, facts, lookups) {
415
+ // 1. a statement literal handed to a lookup names an owner column outside its WHERE clause
416
+ for (const o of lookups) {
417
+ for (const a of o.call.args || []) for (const x of walkIr(a)) {
418
+ if (x.kind !== 'literal' || typeof x.value !== 'string' || !SQL_VERB.test(x.value)) continue;
419
+ const [head, ...tail] = x.value.split(/\bwhere\b/i);
420
+ const col = OWNER_COL.exec(head);
421
+ if (col && !OWNER_COL.test(tail.join(' '))) return { kind: 'owner-column-not-in-where', column: col[0], statement: x.value.slice(0, 120), line: o.line };
422
+ }
423
+ }
424
+ // 2. a record declared in the project has an owner field, and the handler never reads it
425
+ const recTypes = ctx.project && typeof ctx.project.recordTypes === 'function' ? ctx.project.recordTypes() : null;
426
+ if (!recTypes || !recTypes.size) return null;
427
+ const mentioned = new Set();
428
+ for (const n of Object.values(fn.cfg.nodes)) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
429
+ if (e.kind === 'ident' && e.name) mentioned.add(e.name.replace(/^.*\./, ''));
430
+ if (e.kind === 'call' && typeof e.callee === 'string') mentioned.add(e.callee.replace(/^.*\./, ''));
431
+ if (e.kind === 'member' && e.prop) mentioned.add(String(e.prop));
432
+ }
433
+ for (const c of facts.conds) for (const e of walkIr(c)) { if (e.kind === 'ident' && e.name) mentioned.add(e.name.replace(/^.*\./, '')); if (e.kind === 'call' && typeof e.callee === 'string') mentioned.add(e.callee.replace(/^.*\./, '')); }
434
+ for (const [type, fields] of recTypes) {
435
+ const list = (Array.isArray(fields) ? fields : [...(fields || [])]).map((f) => (typeof f === 'string' ? f : f && f.name)).filter(Boolean);
436
+ const ownerField = list.find((f) => OWNER_FIELD.test(String(f).replace(/([a-z0-9])([A-Z])/g, '$1_$2')));
437
+ // the record must be the object the handler works with: it reads another of its fields (`invTotal inv`), or builds it. A record declared in the
438
+ // module that the handler never touches says nothing about what the lookup selected (a `Post` type beside a download handler).
439
+ const inPlay = list.some((f) => f !== ownerField && mentioned.has(f)) || mentioned.has(type);
440
+ if (ownerField && inPlay && !mentioned.has(ownerField)) return { kind: 'owner-field-unread', type, field: ownerField, line: lookups[0] ? lookups[0].line : fn.line };
441
+ }
442
+ return null;
443
+ }
444
+
445
+ // The first call of a handler that is neither a sensitive operation nor response plumbing: a project function, a parameter called as a function, a
446
+ // console write. Its presence means the handler does something the engine cannot see into.
447
+ function effectfulCall(facts) {
448
+ const o = facts.ordered.find((x) => !facts.isSensitive(x.call) && !RESPONSE_ONLY.test(x.call.callee || '') && !/^(?:>>=|>>|<\$>|<\*>|\$|\.|fmap|pure|return)$/.test(x.call.callee || ''));
449
+ return o ? { callee: o.call.callee, line: o.line } : null;
450
+ }
451
+
391
452
  // ── structural order of an inline guard (WAI, Servant, Yesod) ──────────────────
392
453
  // For these frameworks the IR reports a call in tail position (the last statement, a case alternative) at the ENCLOSING line, so a line
393
454
  // number cannot say whether the rejection runs before the first sensitive operation. The handler's own syntax tree can: this walks it in
@@ -615,7 +676,13 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
615
676
  R.sensitive = facts.sensIdx >= 0;
616
677
  // ownership / BOLA inputs
617
678
  R.ownership = hasOwnership(ctx, facts);
618
- R.idLookups = facts.ordered.filter((o) => facts.isSensitive(o.call) && (o.call.args || []).some((a) => [...identsIr(a)].some((n) => facts.idVars.has(n))));
679
+ // a lookup's key sits in `hs.selector` (`M.lookup invoiceId m` keeps the key beside the container), so it is judged with the arguments
680
+ const operands = (c) => [...(c.args || []), ...(c.hs && c.hs.selector ? [c.hs.selector] : [])];
681
+ const byId = (c) => operands(c).some((a) => [...identsIr(a)].some((n) => facts.idVars.has(n)));
682
+ R.idLookups = facts.ordered.filter((o) => facts.isSensitive(o.call) && byId(o.call));
683
+ R.idSelections = facts.ordered.filter((o) => !facts.isSensitive(o.call) && LOOKUP_CALL.test(o.call.callee || '') && byId(o.call));
684
+ R.ownerEvidence = R.idLookups.length || R.idSelections.length ? ownerEvidence(ctx, handlerFn, facts, [...R.idLookups, ...R.idSelections]) : null;
685
+ R.effectful = facts.sensIdx >= 0 ? null : effectfulCall(facts);
619
686
  }
620
687
 
621
688
  // ── Scotty ─────────────────────────────────────────────────────────────────
@@ -762,7 +829,7 @@ function expandChain(chain, acc) {
762
829
  else if (name === 'ReqBody') a.order.push('body');
763
830
  else if (name === 'BasicAuth') { a.auth = { kind: 'basic', tag: 'BasicAuth' }; a.order.push('auth'); }
764
831
  else if (name === 'AuthProtect') { const nm = head.find((t) => t.t === 'str'); a.auth = { kind: 'token', tag: nm ? nm.v : 'AuthProtect' }; a.order.push('auth'); if (nm && /admin|role|perm/i.test(nm.v)) a.role = true; }
765
- else if (name === 'Auth') { a.auth = { kind: 'token', tag: 'Auth' }; a.order.push('auth'); }
832
+ else if (name === 'Auth') { a.auth = { kind: 'token', tag: 'Auth' }; a.order.push('auth'); if (head.some((t) => t.t === 'con' && t.v !== 'Auth' && /admin|role|perm/i.test(t.v))) a.role = true; } // `Auth '[JWT] Admin`: the principal type names the role, as an AuthProtect tag does
766
833
  else if (name && /^(?:Get|Post|Put|Delete|Patch)(?:NoContent)?$/.test(name)) return [finishServant(a, head)];
767
834
  return rest.length ? expandChain(rest, a) : [finishServant(a, head)];
768
835
  }
@@ -927,6 +994,27 @@ export function analyzeHaskellWeb(files, opts = {}) {
927
994
  }
928
995
  }
929
996
 
997
+ // Scotty handlers typed by the framework (`getDocument :: Connection -> ActionM ()`) that no `get`/`post` in an analysed file registers: the route
998
+ // table is elsewhere, so the method and path are unknown. They are analysed for object authorization only (the method decides nothing here):
999
+ // `UNKNOWN` is neither a write nor a privileged path, so no missing-authentication finding can come from one.
1000
+ if (fw.has('scotty')) {
1001
+ const registered = new Set();
1002
+ for (const p of pending) if (p.framework === 'scotty' && p.handlerAst) { const sp = spine(p.handlerAst); const h = sp && sp.head ? sp.head : (p.handlerAst.t === 'var' ? p.handlerAst : null); if (h && h.name) registered.add(h.name); for (const n of walkAst(p.handlerAst)) if (n.t === 'var' && n.name) registered.add(n.name); }
1003
+ for (const mod of ctx.modules) {
1004
+ const mods = mod.imports.map((i) => i.module);
1005
+ if (!mods.some((m) => /^Web\.Scotty(?:\.|$)/.test(m))) continue;
1006
+ for (const fun of mod.groups.funs) {
1007
+ const sig = mod.sigs.get(fun.name);
1008
+ if (!sig || registered.has(fun.name)) continue;
1009
+ const resultText = sig.resultText || String(sig.text || '').split('->').pop();
1010
+ const h = handlerFrameworkOf(mods, resultText);
1011
+ // a handler's result is unit (`ActionM ()`); a guard or helper returns a value (`requireUser :: ActionM User`) and is not a route
1012
+ if (!h || h.framework !== 'scotty' || !/\(\s*\)\s*\)?\s*$/.test(resultText)) continue;
1013
+ add({ framework: 'scotty', method: 'UNKNOWN', path: `<${fun.name}>`, file: mod.file, line: fun.line, mod, handlerName: fun.name, owner: `${mod.name}.${fun.name}`, conventional: true });
1014
+ }
1015
+ }
1016
+ }
1017
+
930
1018
  // ── resolve handlers and analyse ─────────────────────────────────────────
931
1019
  for (const p of pending) {
932
1020
  const R = newRoute({ framework: p.framework, method: p.method, path: p.path, file: p.file, line: p.line, owner: p.owner });
@@ -967,11 +1055,14 @@ export function analyzeHaskellWeb(files, opts = {}) {
967
1055
  const g = waiGlobals.find((w) => w.file === p.file && w.routers.has((p.routerFn || '').split('.').pop()));
968
1056
  if (g) { o.globalAuth = g; if (g.role) o.globalRole = true; }
969
1057
  }
1058
+ // A handler written as a function may take closure arguments BEFORE the endpoint's values (`invoiceById store currentUser invoiceId` for
1059
+ // `Capture "invoiceId" Int :> Get ..`): the endpoint's values are the LAST parameters, so they are paired from the end.
1060
+ const servantOffset = p.framework === 'servant' ? Math.max(0, paramNames.length - (p.servant.order || []).length) : 0;
970
1061
  if (p.framework === 'servant' && p.servant.auth) {
971
1062
  o.typeAuth = p.servant.auth; if (p.servant.role) o.typeRole = true;
972
1063
  o.principalVars = []; o.idVars = [];
973
1064
  (p.servant.order || []).forEach((kind, i) => {
974
- const nm = paramNames[i]; if (!nm) return;
1065
+ const nm = paramNames[i + servantOffset]; if (!nm) return;
975
1066
  if (kind === 'auth') o.principalVars.push(nm);
976
1067
  else if (/^header:/.test(kind) && CREDENTIAL_HEADER.test(kind.slice(7))) (o.credentialParams ||= []).push({ name: nm, kind: /cookie/i.test(kind) ? 'cookie' : 'token' });
977
1068
  else if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
@@ -979,7 +1070,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
979
1070
  } else if (p.framework === 'servant') {
980
1071
  o.idVars = [];
981
1072
  (p.servant.order || []).forEach((kind, i) => {
982
- const nm = paramNames[i]; if (!nm) return;
1073
+ const nm = paramNames[i + servantOffset]; if (!nm) return;
983
1074
  if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
984
1075
  else if (/^header:/.test(kind) && CREDENTIAL_HEADER.test(kind.slice(7))) (o.credentialParams ||= []).push({ name: nm, kind: /cookie/i.test(kind) ? 'cookie' : 'token' });
985
1076
  });
@@ -1030,6 +1121,9 @@ function collectYesodAuthz(ctx) {
1030
1121
  const names = [];
1031
1122
  for (const n of walkAst(body)) if (n.t === 'var') names.push(n.name);
1032
1123
  if (names.some((n) => /^(?:requireAuthId|requireAuth|maybeAuthId|maybeAuth)$/.test(n)) || names.some((n) => { const q = ctx.qualify(mod, { name: n, qual: null }); return q && q.qid && guardSummary(ctx, ctx.fnFor(q)).auth; })) status = 'authenticated';
1124
+ // A clause that can answer AuthenticationRequired or Unauthorized has a rejection path: it decides from something it read (a session
1125
+ // key, a header), so the route is guarded. Only a clause that can ONLY answer Authorized is public.
1126
+ else if (/\b(?:AuthenticationRequired|Unauthorized)\b/.test(text)) status = 'authenticated';
1033
1127
  else if (/"Authorized"/.test(text) || names.includes('Authorized')) status = 'public';
1034
1128
  if (names.some((n) => ROLE_CALL.test(n))) role = true;
1035
1129
  const rec = { status, role, file: mod.file, line: dcl.line || c.line };
@@ -1070,13 +1164,29 @@ function emitFindings(ctx, result, yesodAuthz) {
1070
1164
  if (!R.handler || R.handler.kind === 'unknown') continue;
1071
1165
  const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
1072
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 || '');
1073
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 });
1074
- else if (R.auth.status === 'none' && (writes || privileged) && R.sensitive !== false) {
1075
- if (!(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: R.method === 'ANY' ? 'any method' : 'state-changing method' });
1171
+ else if (R.auth.status === 'none' && (writes || privileged) && !authEndpoint && !(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) {
1172
+ const how = R.method === 'ANY' ? 'any method' : writes ? 'state-changing method' : 'privileged path';
1173
+ if (R.sensitive !== false) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: how });
1174
+ // No database, file or process call is visible, but the handler calls something the engine cannot see into (a project function, a
1175
+ // parameter): on a PRIVILEGED path (admin, internal, manage) the route is privileged by its path, so the finding stands at a lower severity
1176
+ // and says what it could not see. A plain write with no visible operation is NOT reported: on real projects that shape is an echo, an
1177
+ // upload demo or a health check (23 such routes on four projects), and only the handler's own text claimed otherwise.
1178
+ else if (privileged && R.effectful) emit('hs-route-missing-auth', R, { severity: 'medium', confidence: 0.55, detail: `${how}; no database, file or process operation is visible in the handler (it calls ${R.effectful.callee}), so the impact is judged from the path alone`, evidence: { unseen: R.effectful.callee, line: R.effectful.line } });
1076
1179
  }
1077
1180
  if ((R.auth.status === 'authenticated') && privileged && R.role.status !== 'present') emit('hs-route-bfla', R, { detail: 'authenticated but no role/permission check was found', evidence: { auth: R.auth.evidence } });
1078
- if (R.auth.status === 'authenticated' && R.idLookups && R.idLookups.length && !R.ownership) {
1079
- emit('hs-route-bola', R, { detail: 'the id from the request reaches a lookup with no ownership or tenant check', line: R.idLookups[0].line, evidence: { lookup: R.idLookups[0].call.callee } });
1181
+ // Object authorization (BOLA). The lookups are the sensitive calls and the container selections the request id reaches.
1182
+ const lookups = [...(R.idLookups || []), ...(R.idSelections || [])];
1183
+ if (lookups.length && !R.ownership && R.auth.status !== 'late') {
1184
+ const first = lookups[0];
1185
+ if (R.auth.status === 'authenticated') emit('hs-route-bola', R, { detail: 'the id from the request reaches a lookup with no ownership or tenant check', line: first.line, evidence: { lookup: first.call.callee, ...(R.ownerEvidence ? { owner: R.ownerEvidence } : {}) } });
1186
+ // Unauthenticated: a WRITE to an object the client selected is the client's to make only if it owns the object, and nothing checked; for a
1187
+ // read, the finding needs evidence that the object has an owner at all (a public article by id is not an authorization matter).
1188
+ else if (writes) emit('hs-route-bola', R, { detail: 'the id from the request selects the object that is modified, with no authenticated principal and no ownership check', line: first.line, evidence: { lookup: first.call.callee, ...(R.ownerEvidence ? { owner: R.ownerEvidence } : {}) } });
1189
+ else if (R.ownerEvidence) emit('hs-route-bola', R, { detail: R.ownerEvidence.kind === 'owner-column-not-in-where' ? `the statement names an owner column (${R.ownerEvidence.column}) that its WHERE clause does not use: the object is selected by the client's id alone` : `the ${R.ownerEvidence.type} record has an owner field (${R.ownerEvidence.field}) the handler never reads: the object is selected by the client's id alone`, line: first.line, evidence: { lookup: first.call.callee, owner: R.ownerEvidence } });
1080
1190
  }
1081
1191
  const cookie = R.credentialKinds && (R.credentialKinds.has('cookie') || R.credentialKinds.has('session'));
1082
1192
  if (writes && R.auth.status === 'authenticated' && cookie && !R.csrf && R.framework !== 'yesod') emit('hs-route-csrf', R, { detail: 'cookie/session credential with no CSRF token check' });
@@ -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')) {
@@ -57,6 +57,7 @@ import { CATALOG } from '../dataflow/catalog.js';
57
57
  export const PROVENANCE_MAP = Object.freeze({
58
58
  'http-body': Object.freeze({ category: 'http-body', status: 'modeled', why: 'exact vocabulary match' }),
59
59
  'url-param': Object.freeze({ category: 'http-query', status: 'modeled', why: 'pure rename; the query string' }),
60
+ 'upload': Object.freeze({ category: 'http-upload', status: 'modeled', why: 'a field of an uploaded file the client wrote into the multipart body (its name, content type or content); 0.195.8, Haskell wai-extra' }),
60
61
  'path-param': Object.freeze({ category: 'http-route', status: 'modeled', why: 'pure rename; a route/path segment' }),
61
62
  'header': Object.freeze({ category: 'http-header', status: 'modeled', why: 'pure rename' }),
62
63
  'cookie': Object.freeze({ category: 'http-cookie', status: 'modeled', why: 'pure rename' }),