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

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
 
@@ -43,6 +43,19 @@ const ADMIN_PATH = /(?:^|\/)(?:admin|internal|manage|superuser)(?:\/|$|:)/i;
43
43
  const ROLE_CALL = /(?:^|\.)(?:isAdmin|hasRole|requireAdmin|checkRole|hasPermission|requireRole|requirePermission|isSuperuser)$/i;
44
44
  const OWNER_CALL = /(?:^|\.)(?:checkOwner|ownedBy|belongsTo|isOwner|requireOwner|authorizeOwner)$/i;
45
45
  const CSRF_CALL = /csrf|xsrf/i;
46
+ // 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
47
+ // it runs (`owner_id`, `tenant_id`, `created_by`) or a field of a record declared in the project (`invOwner`, `profileUser`). An id lookup that
48
+ // 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
49
+ // lookup the client steers, whether or not a principal was authenticated.
50
+ const OWNER_COL = /\b(?:owner|tenant|account|customer|author|created_by|user)_?id\b|\bowner\b/i;
51
+ const OWNER_FIELD = /(?:^|_)(?:owner|tenant|author|created_?by)(?:_?id)?$|(?:^|_)(?:user|account|customer)_?id$/i;
52
+ const SQL_VERB = /\b(?:select|update|delete|insert)\b/i;
53
+ // 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
54
+ // object is selected by a client-supplied id, so it counts for the ownership judgement only.
55
+ const LOOKUP_CALL = /(?:^|\.)(?:lookup|!\?|!|find|findWithDefault|elemAt)$/;
56
+ // 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,
57
+ // so a missing authentication check on it is a question about the route (its method and path), not a visible operation.
58
+ 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
59
 
47
60
  // ── AST helpers ──────────────────────────────────────────────────────────────
48
61
  function unparen(e) { while (e && e.t === 'paren') e = e.e; return e; }
@@ -360,9 +373,14 @@ function handlerFacts(ctx, fn, opts = {}) {
360
373
  for (const a of assigns) if (a.source.kind === 'call' && isGuardCall(a.source)) principals.add(a.target);
361
374
  // ids read from the request
362
375
  const idVars = new Set();
376
+ const SCOTTY_PARAM = /^Web\.Scotty(?:\.Trans)?\.(?:param|captureParam|queryParam|pathParam)$/;
377
+ // `docId <- param "id" :: ActionM Int` lowers to a source temporary (`$src0 <- param "id"`) and a number whose `hs.inner` names that temporary
378
+ const paramReads = new Map();
379
+ for (const a of assigns) if (a.source.kind === 'call' && SCOTTY_PARAM.test(a.source.callee)) paramReads.set(a.target, a.source);
363
380
  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)) {
381
+ let s = a.source.kind === 'literal' && a.source.hs && a.source.hs.inner ? a.source.hs.inner : a.source;
382
+ if (s.kind === 'ident' && paramReads.has(s.name)) s = paramReads.get(s.name);
383
+ if (s.kind === 'call' && SCOTTY_PARAM.test(s.callee)) {
366
384
  const nm = headerArg(s);
367
385
  if (nm && /id$/i.test(nm)) idVars.add(a.target);
368
386
  }
@@ -388,6 +406,45 @@ function hasOwnership(ctx, facts) {
388
406
  return null;
389
407
  }
390
408
 
409
+ // Owner evidence for the lookups of one handler (see OWNER_COL / OWNER_FIELD). Returns the first piece found, or null.
410
+ function ownerEvidence(ctx, fn, facts, lookups) {
411
+ // 1. a statement literal handed to a lookup names an owner column outside its WHERE clause
412
+ for (const o of lookups) {
413
+ for (const a of o.call.args || []) for (const x of walkIr(a)) {
414
+ if (x.kind !== 'literal' || typeof x.value !== 'string' || !SQL_VERB.test(x.value)) continue;
415
+ const [head, ...tail] = x.value.split(/\bwhere\b/i);
416
+ const col = OWNER_COL.exec(head);
417
+ 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 };
418
+ }
419
+ }
420
+ // 2. a record declared in the project has an owner field, and the handler never reads it
421
+ const recTypes = ctx.project && typeof ctx.project.recordTypes === 'function' ? ctx.project.recordTypes() : null;
422
+ if (!recTypes || !recTypes.size) return null;
423
+ const mentioned = new Set();
424
+ for (const n of Object.values(fn.cfg.nodes)) for (const root of exprsOfNode(n)) for (const e of walkIr(root)) {
425
+ if (e.kind === 'ident' && e.name) mentioned.add(e.name.replace(/^.*\./, ''));
426
+ if (e.kind === 'call' && typeof e.callee === 'string') mentioned.add(e.callee.replace(/^.*\./, ''));
427
+ if (e.kind === 'member' && e.prop) mentioned.add(String(e.prop));
428
+ }
429
+ 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(/^.*\./, '')); }
430
+ for (const [type, fields] of recTypes) {
431
+ const list = (Array.isArray(fields) ? fields : [...(fields || [])]).map((f) => (typeof f === 'string' ? f : f && f.name)).filter(Boolean);
432
+ const ownerField = list.find((f) => OWNER_FIELD.test(String(f).replace(/([a-z0-9])([A-Z])/g, '$1_$2')));
433
+ // 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
434
+ // module that the handler never touches says nothing about what the lookup selected (a `Post` type beside a download handler).
435
+ const inPlay = list.some((f) => f !== ownerField && mentioned.has(f)) || mentioned.has(type);
436
+ if (ownerField && inPlay && !mentioned.has(ownerField)) return { kind: 'owner-field-unread', type, field: ownerField, line: lookups[0] ? lookups[0].line : fn.line };
437
+ }
438
+ return null;
439
+ }
440
+
441
+ // 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
442
+ // console write. Its presence means the handler does something the engine cannot see into.
443
+ function effectfulCall(facts) {
444
+ const o = facts.ordered.find((x) => !facts.isSensitive(x.call) && !RESPONSE_ONLY.test(x.call.callee || '') && !/^(?:>>=|>>|<\$>|<\*>|\$|\.|fmap|pure|return)$/.test(x.call.callee || ''));
445
+ return o ? { callee: o.call.callee, line: o.line } : null;
446
+ }
447
+
391
448
  // ── structural order of an inline guard (WAI, Servant, Yesod) ──────────────────
392
449
  // 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
450
  // 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 +672,13 @@ function analyzeRoute(ctx, route, handlerFn, opts) {
615
672
  R.sensitive = facts.sensIdx >= 0;
616
673
  // ownership / BOLA inputs
617
674
  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))));
675
+ // 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
676
+ const operands = (c) => [...(c.args || []), ...(c.hs && c.hs.selector ? [c.hs.selector] : [])];
677
+ const byId = (c) => operands(c).some((a) => [...identsIr(a)].some((n) => facts.idVars.has(n)));
678
+ R.idLookups = facts.ordered.filter((o) => facts.isSensitive(o.call) && byId(o.call));
679
+ R.idSelections = facts.ordered.filter((o) => !facts.isSensitive(o.call) && LOOKUP_CALL.test(o.call.callee || '') && byId(o.call));
680
+ R.ownerEvidence = R.idLookups.length || R.idSelections.length ? ownerEvidence(ctx, handlerFn, facts, [...R.idLookups, ...R.idSelections]) : null;
681
+ R.effectful = facts.sensIdx >= 0 ? null : effectfulCall(facts);
619
682
  }
620
683
 
621
684
  // ── Scotty ─────────────────────────────────────────────────────────────────
@@ -762,7 +825,7 @@ function expandChain(chain, acc) {
762
825
  else if (name === 'ReqBody') a.order.push('body');
763
826
  else if (name === 'BasicAuth') { a.auth = { kind: 'basic', tag: 'BasicAuth' }; a.order.push('auth'); }
764
827
  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'); }
828
+ 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
829
  else if (name && /^(?:Get|Post|Put|Delete|Patch)(?:NoContent)?$/.test(name)) return [finishServant(a, head)];
767
830
  return rest.length ? expandChain(rest, a) : [finishServant(a, head)];
768
831
  }
@@ -927,6 +990,27 @@ export function analyzeHaskellWeb(files, opts = {}) {
927
990
  }
928
991
  }
929
992
 
993
+ // Scotty handlers typed by the framework (`getDocument :: Connection -> ActionM ()`) that no `get`/`post` in an analysed file registers: the route
994
+ // table is elsewhere, so the method and path are unknown. They are analysed for object authorization only (the method decides nothing here):
995
+ // `UNKNOWN` is neither a write nor a privileged path, so no missing-authentication finding can come from one.
996
+ if (fw.has('scotty')) {
997
+ const registered = new Set();
998
+ 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); }
999
+ for (const mod of ctx.modules) {
1000
+ const mods = mod.imports.map((i) => i.module);
1001
+ if (!mods.some((m) => /^Web\.Scotty(?:\.|$)/.test(m))) continue;
1002
+ for (const fun of mod.groups.funs) {
1003
+ const sig = mod.sigs.get(fun.name);
1004
+ if (!sig || registered.has(fun.name)) continue;
1005
+ const resultText = sig.resultText || String(sig.text || '').split('->').pop();
1006
+ const h = handlerFrameworkOf(mods, resultText);
1007
+ // a handler's result is unit (`ActionM ()`); a guard or helper returns a value (`requireUser :: ActionM User`) and is not a route
1008
+ if (!h || h.framework !== 'scotty' || !/\(\s*\)\s*\)?\s*$/.test(resultText)) continue;
1009
+ 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 });
1010
+ }
1011
+ }
1012
+ }
1013
+
930
1014
  // ── resolve handlers and analyse ─────────────────────────────────────────
931
1015
  for (const p of pending) {
932
1016
  const R = newRoute({ framework: p.framework, method: p.method, path: p.path, file: p.file, line: p.line, owner: p.owner });
@@ -967,11 +1051,14 @@ export function analyzeHaskellWeb(files, opts = {}) {
967
1051
  const g = waiGlobals.find((w) => w.file === p.file && w.routers.has((p.routerFn || '').split('.').pop()));
968
1052
  if (g) { o.globalAuth = g; if (g.role) o.globalRole = true; }
969
1053
  }
1054
+ // A handler written as a function may take closure arguments BEFORE the endpoint's values (`invoiceById store currentUser invoiceId` for
1055
+ // `Capture "invoiceId" Int :> Get ..`): the endpoint's values are the LAST parameters, so they are paired from the end.
1056
+ const servantOffset = p.framework === 'servant' ? Math.max(0, paramNames.length - (p.servant.order || []).length) : 0;
970
1057
  if (p.framework === 'servant' && p.servant.auth) {
971
1058
  o.typeAuth = p.servant.auth; if (p.servant.role) o.typeRole = true;
972
1059
  o.principalVars = []; o.idVars = [];
973
1060
  (p.servant.order || []).forEach((kind, i) => {
974
- const nm = paramNames[i]; if (!nm) return;
1061
+ const nm = paramNames[i + servantOffset]; if (!nm) return;
975
1062
  if (kind === 'auth') o.principalVars.push(nm);
976
1063
  else if (/^header:/.test(kind) && CREDENTIAL_HEADER.test(kind.slice(7))) (o.credentialParams ||= []).push({ name: nm, kind: /cookie/i.test(kind) ? 'cookie' : 'token' });
977
1064
  else if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
@@ -979,7 +1066,7 @@ export function analyzeHaskellWeb(files, opts = {}) {
979
1066
  } else if (p.framework === 'servant') {
980
1067
  o.idVars = [];
981
1068
  (p.servant.order || []).forEach((kind, i) => {
982
- const nm = paramNames[i]; if (!nm) return;
1069
+ const nm = paramNames[i + servantOffset]; if (!nm) return;
983
1070
  if (/^(?:capture|query):.*id$/i.test(kind)) o.idVars.push(nm);
984
1071
  else if (/^header:/.test(kind) && CREDENTIAL_HEADER.test(kind.slice(7))) (o.credentialParams ||= []).push({ name: nm, kind: /cookie/i.test(kind) ? 'cookie' : 'token' });
985
1072
  });
@@ -1030,6 +1117,9 @@ function collectYesodAuthz(ctx) {
1030
1117
  const names = [];
1031
1118
  for (const n of walkAst(body)) if (n.t === 'var') names.push(n.name);
1032
1119
  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';
1120
+ // A clause that can answer AuthenticationRequired or Unauthorized has a rejection path: it decides from something it read (a session
1121
+ // key, a header), so the route is guarded. Only a clause that can ONLY answer Authorized is public.
1122
+ else if (/\b(?:AuthenticationRequired|Unauthorized)\b/.test(text)) status = 'authenticated';
1033
1123
  else if (/"Authorized"/.test(text) || names.includes('Authorized')) status = 'public';
1034
1124
  if (names.some((n) => ROLE_CALL.test(n))) role = true;
1035
1125
  const rec = { status, role, file: mod.file, line: dcl.line || c.line };
@@ -1071,12 +1161,25 @@ function emitFindings(ctx, result, yesodAuthz) {
1071
1161
  const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
1072
1162
  const privileged = ADMIN_PATH.test(R.path) || /admin/i.test(R.handler.name || '');
1073
1163
  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' });
1164
+ else if (R.auth.status === 'none' && (writes || privileged) && !(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) {
1165
+ const how = R.method === 'ANY' ? 'any method' : writes ? 'state-changing method' : 'privileged path';
1166
+ if (R.sensitive !== false) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: how });
1167
+ // No database, file or process call is visible, but the handler calls something the engine cannot see into (a project function, a
1168
+ // parameter): on a PRIVILEGED path (admin, internal, manage) the route is privileged by its path, so the finding stands at a lower severity
1169
+ // 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
1170
+ // upload demo or a health check (23 such routes on four projects), and only the handler's own text claimed otherwise.
1171
+ 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
1172
  }
1077
1173
  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 } });
1174
+ // Object authorization (BOLA). The lookups are the sensitive calls and the container selections the request id reaches.
1175
+ const lookups = [...(R.idLookups || []), ...(R.idSelections || [])];
1176
+ if (lookups.length && !R.ownership && R.auth.status !== 'late') {
1177
+ const first = lookups[0];
1178
+ 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 } : {}) } });
1179
+ // 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
1180
+ // read, the finding needs evidence that the object has an owner at all (a public article by id is not an authorization matter).
1181
+ 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 } : {}) } });
1182
+ 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
1183
  }
1081
1184
  const cookie = R.credentialKinds && (R.credentialKinds.has('cookie') || R.credentialKinds.has('session'));
1082
1185
  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' });
@@ -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' }),