@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.
@@ -17,6 +17,9 @@
17
17
  // has the prefix. A weak guard is reported as a note on the finding and does not refute it.
18
18
 
19
19
  const CANON_CALLS = new Set(['System.Directory.canonicalizePath', 'System.Directory.makeAbsolute', 'System.FilePath.normalise']);
20
+ import { HS_SANITIZERS } from './haskell-models.js';
21
+ // the catalog's HTML escapers (blaze, lucid): the same list the taint catalog is generated from
22
+ const CATALOG_HTML_ESCAPERS = new Set(HS_SANITIZERS.filter((s) => (s.appliesTo || []).includes('xss')).map((s) => `${s.module}.${s.name}`));
20
23
  const EXIT_CALLS = new Set(['Control.Exception.throwIO', 'Control.Exception.throw', 'System.Exit.die', 'System.Exit.exitFailure', 'System.Exit.exitWith', 'throwIO', 'throw', 'die', 'exitFailure', 'exitWith', 'error', 'fail', 'ioError', 'Control.Monad.Fail.fail']);
21
24
  const WHEN = new Set(['Control.Monad.when', 'when']);
22
25
  const UNLESS = new Set(['Control.Monad.unless', 'unless']);
@@ -336,10 +339,15 @@ export function judgeFinding(f, perFile) {
336
339
  const fam = FAMILY_BY_CWE[f.cwe];
337
340
  const ir = perFile && perFile[f.file];
338
341
  if (!fam || !ir || !f._funcQid || !f.callee) return { status: 'none' };
339
- const fn = ir.functions.find((x) => x.qid === f._funcQid);
340
- if (!fn) return { status: 'none' };
341
- const sites = findSites(fn, f.callee, f.line);
342
- if (!sites.length) return { status: 'none' };
342
+ // A finding found while analysing a CALLER (`_funcQid`) is located at the callee's line: the sink function is the one in the
343
+ // finding's file that has the call at that line (the caller first, then the others), the way haskell-trust.js locates it.
344
+ const direct = ir.functions.find((x) => x.qid === f._funcQid);
345
+ let fn = null; let sites = [];
346
+ for (const cand of [direct, ...ir.functions.filter((x) => x !== direct)].filter(Boolean)) {
347
+ const s = findSites(cand, f.callee, f.line);
348
+ if (s.length) { fn = cand; sites = s; break; }
349
+ }
350
+ if (!fn || !sites.length) return { status: 'none' };
343
351
  let weak = null;
344
352
  let first = null;
345
353
  for (const site of sites) {
@@ -354,7 +362,46 @@ export function judgeFinding(f, perFile) {
354
362
  const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
355
363
  let refuted = null;
356
364
  if (fam === 'xss') {
357
- const escapers = htmlEscapers(perFile);
365
+ // the project's own escapers plus the catalog's HTML escapers (blaze/lucid `toHtml`, `text`, ...): the taint walk records the latter on
366
+ // the path for the proof gate, but a sink argument built by a HELPER (`html (renderHtml (greeting name))`) is judged here, where the
367
+ // helper's body can be read: a helper that uses the parameter receiving the tainted value only under an escaper escapes it
368
+ const escapers = new Set([...htmlEscapers(perFile), ...CATALOG_HTML_ESCAPERS]);
369
+ const helperByName = (callee) => ir.functions.find((x) => x.name === callee || x.qid === callee || (x.qid || '').endsWith(`::${callee}`)) || null;
370
+ const paramEscaped = (helper, param, depth) => {
371
+ if (depth > 2) return false;
372
+ const under = (e, inEsc, d) => {
373
+ if (!e || typeof e !== 'object' || d > 40) return true;
374
+ if (e.kind === 'ident' && e.name === param) return inEsc;
375
+ if (e.kind === 'call' && typeof e.callee === 'string') {
376
+ if (escapers.has(e.callee)) return true;
377
+ const h = helperByName(e.callee);
378
+ if (h && Array.isArray(h.params)) {
379
+ // a nested helper: each argument that mentions the parameter must land on a parameter the nested helper escapes
380
+ return (e.args || []).every((a, i) => !identNames(a).has(param) || (h.params[i] && paramEscaped(h, h.params[i], depth + 1) && under(a, true, d + 1)));
381
+ }
382
+ }
383
+ for (const [, c] of exprChildren(e)) if (!under(c, inEsc, d + 1)) return false;
384
+ return true;
385
+ };
386
+ for (const n of nodeList(helper)) {
387
+ for (const k of ['source', 'value', 'cond']) if (n[k] && !under(n[k], false, 0)) return false;
388
+ if (Array.isArray(n.args)) for (const a of n.args) if (!under(a, false, 0)) return false;
389
+ }
390
+ return true;
391
+ };
392
+ const addHelperEscapers = (e, d) => {
393
+ if (!e || typeof e !== 'object' || d > 40) return;
394
+ if (e.kind === 'call' && typeof e.callee === 'string' && !escapers.has(e.callee)) {
395
+ const h = helperByName(e.callee);
396
+ if (h && Array.isArray(h.params) && (e.args || []).length) {
397
+ const taintedPositions = (e.args || []).map((a, i) => ([...identNames(a)].some((v) => vars.has(v) || derived.has(v)) ? i : -1)).filter((i) => i >= 0);
398
+ if (taintedPositions.length && taintedPositions.every((i) => h.params[i] && paramEscaped(h, h.params[i], 0))) escapers.add(e.callee);
399
+ }
400
+ }
401
+ for (const [, c] of exprChildren(e)) addHelperEscapers(c, d + 1);
402
+ };
403
+ addHelperEscapers(argExpr, 0);
404
+ for (const v of identNames(argExpr)) for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === v && n.source) addHelperEscapers(n.source, 0);
358
405
  const calls = [];
359
406
  const find = (e, d) => { if (!e || typeof e !== 'object' || d > 40) return; if (e.kind === 'call' && escapers.has(e.callee)) calls.push(e); for (const [, c] of exprChildren(e)) find(c, d + 1); };
360
407
  // the escaper may sit behind one or more local bindings (`page = ... path ...`, `path = escape (...)`): follow them, each once
@@ -507,6 +554,75 @@ export function waiHtmlContext(f, perFile) {
507
554
  return 'unknown';
508
555
  }
509
556
 
557
+ // ── a request whose host is fixed ────────────────────────────────────────────
558
+ // SSRF is the client choosing the HOST. A URL built as `fixedBase <> clientText` where the fixed part is a literal (or a constant bound to
559
+ // one) that already names a scheme, a host and a "/" cannot reach another host: the client text lands in the path, query or fragment.
560
+ // Likewise a request value built by http-conduit's modifiers (`setRequestQueryString q base`, `setRequestPath p base`, ...) over a fixed
561
+ // base request keeps that base's host; the modifiers take the base as their LAST argument. A record update of such a value is judged by
562
+ // its base unless it sets a field that names the host.
563
+ const HOST_ANCHORED = /^[a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/[^\s]*$/i;
564
+ const REQUEST_BUILDERS = /(?:^|\.)(?:parseRequest|parseRequest_|parseUrlThrow|parseUrl|requestFromURI|requestFromURI_)$/;
565
+ const REQUEST_MODIFIERS = /(?:^|\.)(?:setRequestQueryString|setRequestQuery|setQueryString|setRequestPath|setRequestBody|setRequestBodyLBS|setRequestBodyJSON|setRequestBodyURLEncoded|setRequestBodyFile|setRequestBodySource|setRequestHeader|setRequestHeaders|addRequestHeader|setRequestMethod|setRequestBasicAuth|setRequestBearerAuth|setRequestIgnoreStatus|setRequestResponseTimeout|setRequestCheckStatus|setRequestManager|urlEncodedBody|applyBasicAuth|applyBearerAuth)$/;
566
+ const PEEL_WRAPPERS = /(?:^|\.)(?:liftIO|pure|return|unpack|pack|fromString|toStrict|fromStrict|encodeUtf8|decodeUtf8|toText|fromText)$/;
567
+ const HOST_FIELD = /^(?:host|port|secure|url|uri|requestHost)$/i;
568
+
569
+ function fixedHostRequest(f, perFile) {
570
+ if (!f || f.cwe !== 'CWE-918') return null;
571
+ const ir = perFile && perFile[f.file];
572
+ if (!ir || !f.callee) return null;
573
+ const direct = ir.functions.find((x) => x.qid === f._funcQid);
574
+ let fn = null; let sites = [];
575
+ for (const cand of [direct, ...ir.functions.filter((x) => x !== direct)].filter(Boolean)) {
576
+ const s = findSites(cand, f.callee, f.line);
577
+ if (s.length) { fn = cand; sites = s; break; }
578
+ }
579
+ if (!fn || !sites.length) return null;
580
+ const argIdx = Number.isInteger(f.argIndex) ? f.argIndex : 0;
581
+ // a local name bound once stands for its definition; a zero-parameter function of the module whose body is one literal is a constant
582
+ const localDef = (name) => { const defs = []; for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === name && n.source) defs.push(n.source); return defs.length === 1 ? defs[0] : null; };
583
+ const constDef = (name) => {
584
+ const bare = name.replace(/^.*\./, '');
585
+ const c = ir.functions.find((x) => (x.name === name || x.qid === name || (x.name || '').replace(/^.*\./, '') === bare) && (!Array.isArray(x.params) || x.params.length === 0));
586
+ if (!c) return null;
587
+ const rets = Object.values(c.cfg.nodes).filter((n) => n.kind === 'return' && n.value);
588
+ return rets.length === 1 ? rets[0].value : null;
589
+ };
590
+ let evidence = null;
591
+ const fixed = (e, seen, depth) => {
592
+ if (!e || typeof e !== 'object' || depth > 24) return false;
593
+ if (e.kind === 'literal') { const ok = typeof e.value === 'string' && HOST_ANCHORED.test(e.value); if (ok) evidence = e.value.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/).*$/i, '$1'); return ok; }
594
+ if (e.kind === 'ident') {
595
+ if (seen.has(e.name)) return false;
596
+ seen.add(e.name);
597
+ const d = localDef(e.name) || constDef(e.name);
598
+ return d ? fixed(d, seen, depth + 1) : false;
599
+ }
600
+ if (e.kind === 'union') return Array.isArray(e.branches) && e.branches.length > 0 && e.branches.every((b) => fixed(b, new Set(seen), depth + 1));
601
+ if (e.kind === 'object' && Array.isArray(e.props)) {
602
+ // a record update: `{ method = "POST" }` joined (as a union) with its base; a field that names the host makes it unknown
603
+ if (e.props.some((p) => HOST_FIELD.test(String(p.key || p.name || '')))) return false;
604
+ return e.object ? fixed(e.object, seen, depth + 1) : true;
605
+ }
606
+ if (e.kind === 'binary' && (e.op === '<>' || e.op === '++' || (e.hs && e.hs.concat))) return fixed(e.left, seen, depth + 1);
607
+ if (e.kind === 'call' && typeof e.callee === 'string') {
608
+ const args = e.args || [];
609
+ if (PEEL_WRAPPERS.test(e.callee) && args.length === 1) return fixed(args[0], seen, depth + 1);
610
+ if (REQUEST_BUILDERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
611
+ if (REQUEST_MODIFIERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
612
+ if (/(?:^|\.)(?:<\/>|combine|joinPath)$/.test(e.callee) && args.length === 2) return fixed(args[0], seen, depth + 1);
613
+ }
614
+ return false;
615
+ };
616
+ for (const site of sites) {
617
+ const arg = (site.call.args || [])[argIdx];
618
+ if (!arg) return null;
619
+ // a record update is a union of the base and the update: a union judged branch by branch above; an `object` branch with no base is the
620
+ // update alone, which is fixed when it names no host field, so the base branch decides
621
+ if (!fixed(arg, new Set(), 0)) return null;
622
+ }
623
+ return { kind: 'fixed-host-request', base: evidence };
624
+ }
625
+
510
626
  export function applyHaskellGuards(findings, perFile) {
511
627
  const stats = { refuted: 0, weak: 0 };
512
628
  for (const f of findings || []) {
@@ -522,6 +638,16 @@ export function applyHaskellGuards(findings, perFile) {
522
638
  }
523
639
  if (ctx === 'unknown' && !f._htmlContextUnknown) { f._htmlContextUnknown = true; f.uncertainty = [...(f.uncertainty || []), { kind: 'unread-content-type', detail: 'the response headers at this call are not literal text, so whether the body is served as HTML was not read' }]; }
524
640
  } catch { /* judged below */ }
641
+ try {
642
+ const fh = fixedHostRequest(f, perFile);
643
+ if (fh && !f._fixedHost) {
644
+ f._fixedHost = true; f.severity = 'info';
645
+ f.controls = [...(f.controls || []), { kind: 'fixed-host-request', evidenceKind: 'source' }];
646
+ f.guardNote = { kind: 'fixed-host-request', reason: `the request's host is fixed by the literal${fh.base ? ` "${fh.base}"` : ''} the URL or base request starts from; the client's text can only choose the path, query or fragment, which is not a server-side request forgery (a path it chooses on that host is still worth a look)` };
647
+ stats.weak++;
648
+ continue;
649
+ }
650
+ } catch { /* judged below */ }
525
651
  try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
526
652
  try {
527
653
  const qp = quotedPositionals(f, perFile);
@@ -148,14 +148,20 @@ function servantEndpointParams(mod, fn, sig, exported) {
148
148
  };
149
149
  const body = slice.slice(eq + 1);
150
150
  if (split(body, ':<|>').filter((p) => p.length).length !== 1) return [];
151
+ const parts = split(body, ':>').filter((p) => p.length);
152
+ // The endpoint's values are the LAST parameters of the handler: `orderServer conn ref` for `QueryParam "ref" Text` takes `conn` from the
153
+ // closure. Pair from the end (the same rule haskell-web.js applies); a handler with fewer parameters than the API names is not paired.
154
+ const reads = parts.filter((p) => p[0] && p[0].t === 'con' && (SERVANT_PRINCIPAL.has(p[0].v) || SERVANT_READS.has(p[0].v))).length;
155
+ const offset = fn.params.length - reads;
156
+ if (offset < 0) return [];
151
157
  const out = [];
152
158
  let idx = 0;
153
- for (const part of split(body, ':>').filter((p) => p.length)) {
159
+ for (const part of parts) {
154
160
  const head = part[0];
155
161
  if (!head || head.t !== 'con') continue;
156
162
  const isPrincipal = SERVANT_PRINCIPAL.has(head.v);
157
163
  if (!isPrincipal && !SERVANT_READS.has(head.v)) continue;
158
- const name = fn.params[idx];
164
+ const name = fn.params[offset + idx];
159
165
  idx++;
160
166
  if (isPrincipal || !name) continue;
161
167
  // the carried type is what follows the name string (or the content-type list for a body): its text decides whether it is text
@@ -163,7 +169,7 @@ function servantEndpointParams(mod, fn, sig, exported) {
163
169
  let rest = part.slice(lastStr >= 0 ? lastStr + 1 : 1);
164
170
  if (/^ReqBody/.test(head.v)) { const close = rest.findIndex((t) => t.t === 'sp' && t.v === ']'); rest = close >= 0 ? rest.slice(close + 1) : rest; }
165
171
  const type = rest.map((t) => t.v).join(' ').trim();
166
- if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: idx - 1, name, decorator: 'hs:handler-param:servant' });
172
+ if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: offset + idx - 1, name, decorator: 'hs:handler-param:servant' });
167
173
  }
168
174
  return out;
169
175
  }
@@ -380,6 +380,8 @@ export const HS_LOG_SINKS = Object.freeze([
380
380
  ...E('Text.Printf', ['hPrintf']).map((e) => ({ ...e, argIndex: 1, argFrom: 1, channel: 'handle' })),
381
381
  ...E('Katip', ['logFM', 'logTM', 'logItem']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
382
382
  ...E('System.Log.Logger', ['debugM', 'infoM', 'noticeM', 'warningM', 'errorM', 'criticalM']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
383
+ // fast-logger: the logger set is argument 0, the message argument 1
384
+ ...E('System.Log.FastLogger', ['pushLogStr', 'pushLogStrLn']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
383
385
  ]);
384
386
 
385
387
  // Library surfaces the structural rules (haskell-security-rules.js) look for; registering them here makes
@@ -393,6 +395,9 @@ export const HS_RULE_APIS = Object.freeze([
393
395
  ...E('Crypto.Cipher.ChaChaPoly1305', ['nonce12', 'nonce8']),
394
396
  ...E('Crypto.KDF.PBKDF2', ['generate', 'fastPBKDF2_SHA1', 'fastPBKDF2_SHA256', 'fastPBKDF2_SHA512']),
395
397
  ...E('System.Random', ['randomRIO', 'randomIO', 'newStdGen', 'getStdGen', 'mkStdGen', 'randomR', 'random', 'randoms', 'randomRs', 'getStdRandom', 'uniformR', 'uniform', 'initStdGen']),
398
+ // mwc-random and splitmix are statistical generators, not CSPRNGs (createSystemRandom only seeds from the OS)
399
+ ...E('System.Random.MWC', ['uniformR', 'uniform', 'uniformRM', 'uniformM', 'createSystemRandom', 'create', 'initialize', 'withSystemRandom', 'withSystemRandomST']),
400
+ ...E('System.Random.SplitMix', ['nextWord64', 'nextInt', 'nextDouble', 'mkSMGen', 'newSMGen', 'initSMGen']),
396
401
  ...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
397
402
  ...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
398
403
  ...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
@@ -53,7 +53,11 @@ const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.
53
53
  const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
54
54
  const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
55
55
  const IV_CALLS = new Set(['Crypto.Cipher.Types.makeIV', 'Crypto.Cipher.ChaChaPoly1305.nonce12', 'Crypto.Cipher.ChaChaPoly1305.nonce8']);
56
- const RANDOM_CALLS = new Set(['System.Random.randomRIO', 'System.Random.randomIO', 'System.Random.newStdGen', 'System.Random.getStdGen', 'System.Random.mkStdGen', 'System.Random.randomR', 'System.Random.random', 'System.Random.randoms', 'System.Random.randomRs', 'System.Random.getStdRandom', 'System.Random.uniformR', 'System.Random.uniform', 'System.Random.initStdGen']);
56
+ const RANDOM_CALLS = new Set(['System.Random.randomRIO', 'System.Random.randomIO', 'System.Random.newStdGen', 'System.Random.getStdGen', 'System.Random.mkStdGen', 'System.Random.randomR', 'System.Random.random', 'System.Random.randoms', 'System.Random.randomRs', 'System.Random.getStdRandom', 'System.Random.uniformR', 'System.Random.uniform', 'System.Random.initStdGen',
57
+ // mwc-random is a statistical generator (multiply-with-carry); `createSystemRandom` only seeds it from the OS, its output is not a CSPRNG
58
+ 'System.Random.MWC.uniformR', 'System.Random.MWC.uniform', 'System.Random.MWC.uniformRM', 'System.Random.MWC.uniformM', 'System.Random.MWC.createSystemRandom', 'System.Random.MWC.create', 'System.Random.MWC.initialize', 'System.Random.MWC.withSystemRandom', 'System.Random.MWC.withSystemRandomST',
59
+ // splitmix is the pure generator behind random
60
+ 'System.Random.SplitMix.nextWord64', 'System.Random.SplitMix.nextInt', 'System.Random.SplitMix.nextDouble', 'System.Random.SplitMix.mkSMGen', 'System.Random.SplitMix.newSMGen', 'System.Random.SplitMix.initSMGen']);
57
61
  const BODY_CALLS = new Set(['Network.Wai.strictRequestBody', 'Network.Wai.lazyRequestBody', 'Web.Scotty.body', 'Web.Scotty.Trans.body', 'Network.Wai.Conduit.sourceRequestBody']);
58
62
  const SOCKET_READS = new Set(['Network.Socket.ByteString.recv', 'Network.Socket.ByteString.recvFrom', 'Network.Socket.ByteString.Lazy.recv']);
59
63
  const SET_COOKIE_HEADER = new Set(['Network.HTTP.Types.Header.hSetCookie', 'Network.HTTP.Types.hSetCookie']);
@@ -71,7 +75,8 @@ const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(
71
75
  const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
72
76
  // A salt is deliberately absent: it must be unique, not unpredictable or secret (it is stored beside the hash), so a non-cryptographic generator
73
77
  // producing one is not a weakness the way it is for a token, nonce or key.
74
- const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|apikey|resetcode|passcode|verification)/i;
78
+ // (`newKey`, `generateApiKey`, `mkKey`: a function that MAKES a key is security-shaped; `key` alone is not, it names map keys too)
79
+ const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|apikey|resetcode|passcode|verification|(?:new|gen|generate|make|mk|create|issue|mint|fresh)_?(?:api_?)?key)/i;
75
80
  // Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
76
81
  const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
77
82
  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)) };
@@ -102,8 +107,64 @@ const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secre
102
107
  const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
103
108
  const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
104
109
  const LOW_ENTROPY = /(?:pass(?:word|wd|phrase)?|pwd|ssn|creditcard|cardnumber|cvv|\bpin\b)/i;
110
+ // a variable whose name says it holds the HASH of a credential (`hashedPassword`, `passwordHash`, `tokenDigest`): the stored form, not the secret
111
+ const HASH_NAMED = /(?:hash|digest)/i;
112
+ // Every use of the identifier `n` inside `arg` is as the object of a member access whose field is not credential-shaped (`attemptUser attempt`).
113
+ function usedOnlyAsNonSensitiveField(arg, n) {
114
+ let uses = 0; let fields = 0;
115
+ const visit = (e, parent, d) => {
116
+ if (!e || typeof e !== 'object' || d > 40) return;
117
+ if (e.kind === 'ident' && e.name === n) {
118
+ uses++;
119
+ if (parent && parent.kind === 'member' && parent.object === e && typeof parent.prop === 'string' && !SENSITIVE.test(parent.prop)) fields++;
120
+ return;
121
+ }
122
+ for (const k of Object.keys(e)) {
123
+ if (k === 'hs') continue;
124
+ const v = e[k];
125
+ if (Array.isArray(v)) { for (const x of v) visit(x, e, d + 1); } else if (v && typeof v === 'object') visit(v, e, d + 1);
126
+ }
127
+ };
128
+ visit(arg, null, 0);
129
+ return uses > 0 && uses === fields;
130
+ }
131
+ // a value that is a one-time code only in context: `code` is a security value where the function talks about a reset, a verification, an OTP
132
+ const CONTEXT_CODE_VAR = /^(?:code|pin|passcode|otp)$/i;
133
+ const CODE_CONTEXT = /reset|verif|otp|one.?time|activation|confirm|2fa|mfa|two.?factor/i;
134
+ // A project function of this file whose every return value is a comparison, a conjunction or a boolean predicate (after `pure`/`return`).
135
+ function returnsYesNo(fir, callee) {
136
+ const fn = (fir.functions || []).find((x) => x.name === callee || x.qid === callee || (x.qid || '').endsWith(`::${callee}`));
137
+ if (!fn) return false;
138
+ const rets = Object.values(fn.cfg.nodes).filter((n) => n.kind === 'return' && n.value);
139
+ if (!rets.length) return false;
140
+ return rets.every((n) => {
141
+ let e = n.value;
142
+ for (let i = 0; i < 6 && e && e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:pure|return|liftIO)$/.test(e.callee) && (e.args || []).length === 1; i++) e = e.args[0];
143
+ if (!e) return false;
144
+ if (e.kind === 'binary' && ['==', '/=', '!=', '&&', '||', '<', '>', '<=', '>='].includes(e.op)) return true;
145
+ if (e.kind === 'logical') return true;
146
+ if (e.kind === 'literal' && typeof e.value === 'boolean') return true;
147
+ return e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:not|elem|notElem|null|isJust|isNothing|any|all|and|or)$/.test(e.callee);
148
+ });
149
+ }
150
+
151
+ function fnMentions(fn, re) {
152
+ for (const n of Object.values(fn.cfg.nodes)) {
153
+ for (const k of ['source', 'value', 'cond']) {
154
+ if (!n[k]) continue;
155
+ for (const x of walk(n[k])) {
156
+ if (x.kind === 'literal' && typeof x.value === 'string' && re.test(x.value)) return true;
157
+ if (x.kind === 'call' && typeof x.callee === 'string' && re.test(x.callee)) return true;
158
+ }
159
+ }
160
+ if (n.kind === 'call' && typeof n.callee === 'string' && re.test(n.callee)) return true;
161
+ if (Array.isArray(n.args)) for (const a of n.args) for (const x of walk(a)) if (x.kind === 'literal' && typeof x.value === 'string' && re.test(x.value)) return true;
162
+ }
163
+ return false;
164
+ }
105
165
  export const SENSITIVE = /(?:pass(?:word|wd|phrase)?|pwd|(?:^|[^a-z])pw(?:[^a-z]|$)|secret|token|api_?key|ssn|credit_?card|card_?number|cvv|authorization|bearer|private_?key|session_?id)/i;
106
- const REDACTOR = /(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|length|null)$|(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor)/i;
166
+ // (a helper that keeps only a few characters of the value, `lastFour`, `last4`, `fingerprint`, `takeEnd`, is a masking helper by its name too)
167
+ const REDACTOR = /(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|length|null|takeEnd|fingerprint|abbreviate|truncate)$|(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|last(?:four|4|digits|chars)|fingerprint)/i;
107
168
 
108
169
  const kids = (e) => {
109
170
  const out = [];
@@ -115,6 +176,19 @@ const kids = (e) => {
115
176
  return out;
116
177
  };
117
178
 
179
+ // Marks every node under an inlined helper's root with the helper's qid (`hs.inlineCtx`); idempotent, so a cached IR is marked once.
180
+ function markInlined(e, ctx, depth) {
181
+ if (!e || typeof e !== 'object' || depth > 60) return;
182
+ const here = (e.hs && e.hs.inlinedFrom) || ctx;
183
+ if (here && !(e.hs && e.hs.inlineCtx)) e.hs = { ...(e.hs || {}), inlineCtx: here };
184
+ for (const k of Object.keys(e)) {
185
+ if (k === 'hs') continue;
186
+ const v = e[k];
187
+ if (Array.isArray(v)) { for (const x of v) markInlined(x, here, depth + 1); }
188
+ else if (v && typeof v === 'object') markInlined(v, here, depth + 1);
189
+ }
190
+ }
191
+
118
192
  function* walk(e, depth = 0) {
119
193
  if (!e || typeof e !== 'object' || depth > 80) return;
120
194
  yield e;
@@ -257,6 +331,44 @@ function weakDigestUse(fn, node, hashCall, hashArgs, fnBase) {
257
331
  return reasons;
258
332
  }
259
333
 
334
+ /**
335
+ * The migration shape: a fast password hash kept ONLY to verify hashes stored before a password KDF was introduced. True when every
336
+ * use of the function, in every file, is an operand of an equality (nothing new is stored with it) and a function that compares with
337
+ * it also computes a password KDF, directly or through one project function (`verify stored salt pw = if legacy stored then stored ==
338
+ * oldHash salt pw else stored == kdfHash salt pw`). The weak hash is still computed, so the finding is kept at `info` with this reason;
339
+ * a function that is also used to STORE a hash, or whose comparisons sit beside no KDF, is the full finding. Decided from the IR only,
340
+ * never from a name (`hashPasswordLegacy` is not evidence; the comparison and the KDF are).
341
+ */
342
+ function legacyVerifierOnly(fn, ir, memo) {
343
+ if (memo.has(fn.qid)) return memo.get(fn.qid);
344
+ const allFns = Object.values(ir.perFile).flatMap((x) => x.functions || []);
345
+ const byQid = new Map(allFns.map((x) => [x.qid, x]));
346
+ const usesF = (e) => !!e.hs && ((e.kind === 'call' && e.hs.target === fn.qid) || e.hs.inlinedFrom === fn.qid);
347
+ const kdfCall = (c) => typeof c === 'string' && (STRONG_KDF.test(c) || PBKDF2_CALLS.has(c));
348
+ const hasKdf = (g, depth) => (g.calls || []).some((c) => kdfCall(c.callee) || (depth < 1 && c.hs && c.hs.target && c.hs.target !== g.qid && byQid.has(c.hs.target) && hasKdf(byQid.get(c.hs.target), depth + 1)));
349
+ let uses = 0; let compared = 0; let besideKdf = false;
350
+ const visit = (e, parent, g, depth) => {
351
+ if (!e || typeof e !== 'object' || depth > 60) return;
352
+ if (usesF(e)) {
353
+ uses++;
354
+ if (parent && parent.kind === 'binary' && EQUALITY_OPS.has(parent.op)) { compared++; if (hasKdf(g, 0)) besideKdf = true; }
355
+ return; // under an inlined root the nodes are the helper's own body, not further uses of it
356
+ }
357
+ for (const k of Object.keys(e)) {
358
+ if (k === 'hs') continue;
359
+ const v = e[k];
360
+ if (Array.isArray(v)) { for (const x of v) visit(x, e, g, depth + 1); } else if (v && typeof v === 'object') visit(v, e, g, depth + 1);
361
+ }
362
+ };
363
+ for (const g of allFns) {
364
+ if (g.qid === fn.qid) continue;
365
+ for (const n of Object.values(g.cfg.nodes)) for (const root of [n.source, n.value, n.cond, ...(n.args || [])]) visit(root, null, g, 0);
366
+ }
367
+ const r = uses > 0 && compared === uses && besideKdf;
368
+ memo.set(fn.qid, r);
369
+ return r;
370
+ }
371
+
260
372
  /**
261
373
  * @param {Record<string,string>} files path -> source (only Haskell files are read)
262
374
  * @param {object} [opts]
@@ -279,11 +391,14 @@ export function analyzeHaskellRules(files, opts = {}) {
279
391
  if (/import\s+(?:qualified\s+)?Network\.Wai\.Middleware\.RequestSizeLimit\b/.test(text) || /\brequestSizeLimitMiddleware\b/.test(text)) { requestLimit = { file, line: (text.split('\n').findIndex((l) => /RequestSizeLimit|requestSizeLimitMiddleware/.test(l)) + 1) || 1 }; break; }
280
392
  }
281
393
 
394
+ let currentFn = null;
395
+ const legacyMemo = new Map();
282
396
  const emit = (rule, file, line, extra = {}) => {
283
397
  const r = RULES[rule];
284
398
  const sev = extra.severity || r.severity;
285
399
  const f = {
286
400
  id: `hs-rule:${rule}:${file}:${line}`, severity: sev, file, line, vuln: extra.vuln || r.vuln, cwe: r.cwe,
401
+ _fnQid: currentFn ? currentFn.qid : null, ...(extra.inlineCtx ? { inlineCtx: extra.inlineCtx } : {}),
287
402
  description: `${extra.vuln || r.vuln}${extra.detail ? `: ${extra.detail}` : ''}`,
288
403
  remediation: r.remediation, parser: 'HS-RULES', family: r.family,
289
404
  language: 'haskell', capability: 'sast', analysisKind: 'application', evidenceKind: 'source',
@@ -306,6 +421,14 @@ export function analyzeHaskellRules(files, opts = {}) {
306
421
  }
307
422
  }
308
423
  for (const fn of fir.functions) {
424
+ currentFn = fn;
425
+ // A project helper the IR inlined at its call site (`digestFor (credPassword creds)` becomes the helper's body, rooted at a node with
426
+ // `hs.inlinedFrom`) is walked here as well as in the helper itself. Every node under such a root is marked with the helper's qid, so
427
+ // a rule that reports the same thing inside the helper can drop the call-site copy (the filter before the return).
428
+ for (const n of Object.values(fn.cfg.nodes)) {
429
+ for (const k of ['source', 'value', 'cond']) if (n[k]) markInlined(n[k], null, 0);
430
+ if (Array.isArray(n.args)) for (const a of n.args) markInlined(a, null, 0);
431
+ }
309
432
  if (fn.name.endsWith('.<module>')) continue;
310
433
  const bound = new Map(); // target var -> {callee, line}
311
434
  const sensitive = new Map(); // var -> {class:'low'|'high', hashed:boolean, line}
@@ -422,9 +545,13 @@ export function analyzeHaskellRules(files, opts = {}) {
422
545
  if (INPUT_READS.has(e.name) && !bounded && readsOutsideInput(fn, e.name, [], importsNetwork)) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
423
546
  continue;
424
547
  }
425
- // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`
426
- if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) {
427
- const [k, v] = e.elements;
548
+ // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`, or handed to a framework's header
549
+ // setter: Scotty `setHeader "Set-Cookie" ("session=" <> tok <> "; Path=/")`, Spock `setHeader`, Yesod `addHeader`
550
+ let cookiePair = null;
551
+ if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) cookiePair = e.elements;
552
+ else if (e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:setHeader|addHeader|setResponseHeader|modifyHeader)$/.test(e.callee) && (e.args || []).length >= 2) cookiePair = [e.args[0], e.args[1]];
553
+ if (cookiePair) {
554
+ const [k, v] = cookiePair;
428
555
  const isSetCookie = k && ((k.kind === 'ident' && SET_COOKIE_HEADER.has(k.name)) || (k.kind === 'literal' && /^set-cookie$/i.test(String(k.value))) || (k.kind === 'call' && /(?:^|\.)(?:pack|fromString)$/.test(k.callee || '') && k.args && k.args[0] && k.args[0].kind === 'literal' && /^set-cookie$/i.test(String(k.args[0].value))));
429
556
  if (isSetCookie && v) {
430
557
  const text = literalTextOf(v);
@@ -477,7 +604,10 @@ export function analyzeHaskellRules(files, opts = {}) {
477
604
  // scotty-cookie's helpers build a cookie that carries neither Secure nor HttpOnly
478
605
  if ((c === 'Web.Scotty.Cookie.setSimpleCookie' || c === 'Web.Scotty.Cookie.makeSimpleCookie') && args[0]) {
479
606
  const nm = args[0];
480
- if (nm.kind !== 'literal' || SESSION_COOKIE.test(String(nm.value))) emit('hs-insecure-cookie', file, line, { detail: 'HttpOnly missing, Secure missing', evidence: { httpOnly: false, secure: false, helper: c } });
607
+ // A name that is a PARAMETER of this function is decided by the caller: the function is itself a cookie helper
608
+ // (scotty's own `setSimpleCookie n v`, `deleteCookie c`), and the call that passes a session name is the site to report.
609
+ const ownParam = nm.kind === 'ident' && Array.isArray(fn.params) && fn.params.includes(nm.name);
610
+ if (!ownParam && (nm.kind !== 'literal' || SESSION_COOKIE.test(String(nm.value)))) emit('hs-insecure-cookie', file, line, { detail: 'HttpOnly missing, Secure missing', evidence: { httpOnly: false, secure: false, helper: c } });
481
611
  }
482
612
  // a socket read repeated by a recursive loop, with no size test anywhere in it
483
613
  if (SOCKET_READS.has(c) && !hasSizeTest && !bounded && fn.calls.some((x) => x.callee === fn.name && x.hs && x.hs.target === fn.qid)) {
@@ -502,8 +632,13 @@ export function analyzeHaskellRules(files, opts = {}) {
502
632
  }
503
633
  }
504
634
  // password hashed with a fast hash
505
- if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
506
- emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value` });
635
+ // (a hash call the IR inlined from a project helper, `digestFor (credPassword creds)` at the comparison, is reported once, in the helper)
636
+ if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && !(e.hs && e.hs.inlinedFrom) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
637
+ if (!(e.hs && e.hs.inlineCtx) && legacyVerifierOnly(fn, ir, legacyMemo)) {
638
+ emit('hs-fast-password-hash', file, line, { severity: 'info', confidence: 0.5, detail: `${c} applied to a password-shaped value, in a function used only to verify hashes made before a migration (every use of it is an equality, beside a password KDF): re-hash with the KDF at the next successful login and remove it once no stored hash needs it`, inlineCtx: e.hs && e.hs.inlineCtx, evidence: { legacyVerifier: true } });
639
+ } else {
640
+ emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value`, inlineCtx: e.hs && e.hs.inlineCtx });
641
+ }
507
642
  }
508
643
  if (PBKDF2_CALLS.has(c)) {
509
644
  for (const a of args) for (const x of walk(a)) if (x.kind === 'object') for (const p of x.props || []) if (p.key === 'iterCounts' && p.value && p.value.kind === 'literal' && typeof p.value.value === 'number' && p.value.value < 10000) {
@@ -514,7 +649,7 @@ export function analyzeHaskellRules(files, opts = {}) {
514
649
  if (ECB_CALLS.has(c)) emit('hs-ecb-mode', file, line, { detail: c });
515
650
  if (IV_CALLS.has(c) && args[0] && isLiteralLike(args[0])) emit('hs-static-iv', file, line, { detail: `${c} of a constant value` });
516
651
  // randomness bound to a security-shaped name
517
- if (RANDOM_CALLS.has(c) && node.kind === 'assign' && SECURITY_VAR.test(node.target || '')) {
652
+ if (RANDOM_CALLS.has(c) && node.kind === 'assign' && (SECURITY_VAR.test(node.target || '') || (CONTEXT_CODE_VAR.test(node.target || '') && fnMentions(fn, CODE_CONTEXT)))) {
518
653
  emitRandom(line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
519
654
  }
520
655
  if (RANDOM_CALLS.has(c) && SECURITY_FN.test(fnBase) && node.kind !== 'assign') {
@@ -562,6 +697,14 @@ export function analyzeHaskellRules(files, opts = {}) {
562
697
  for (const n of srcNames) if (sensitive.has(n)) { const s = sensitive.get(n); cls = { name: s.name, low: s.low }; hashed = hashed || s.hashed; }
563
698
  if (cls) {
564
699
  let cleared = false;
700
+ // a yes/no answer about the credential (`accepted <- checkCredentials attempt`, a comparison, a conjunction) carries no credential text
701
+ let root = src; for (let i = 0; i < 6 && root && root.kind === 'call' && typeof root.callee === 'string' && /(?:^|\.)(?:liftIO|pure|return|lift)$/.test(root.callee) && (root.args || []).length === 1; i++) root = root.args[0];
702
+ if (root && ((root.kind === 'binary' && ['==', '/=', '!=', '&&', '||', '<', '>', '<=', '>='].includes(root.op)) || root.kind === 'logical')) cleared = true;
703
+ // the binding says it holds a hash or digest (`hashedPassword <- hashPassword plain`): the stored form, whatever made it
704
+ if (HASH_NAMED.test(node.target || '')) cleared = true;
705
+ if (root && root.kind === 'call' && typeof root.callee === 'string' && /(?:^|\.)(?:not|elem|notElem|null|isJust|isNothing|any|all|and|or)$/.test(root.callee)) cleared = true;
706
+ // a project helper whose every return is such an answer (`checkCredentials a = pure (user a == "x" && password a == "y")`)
707
+ if (root && root.kind === 'call' && typeof root.callee === 'string' && returnsYesNo(fir, root.callee)) cleared = true;
565
708
  for (const x of walk(src)) {
566
709
  if (x.kind !== 'call' || typeof x.callee !== 'string') continue;
567
710
  if (REDACTOR.test(x.callee)) cleared = true;
@@ -600,13 +743,19 @@ export function analyzeHaskellRules(files, opts = {}) {
600
743
  for (const n of names(arg)) {
601
744
  if (hit) break;
602
745
  const bare = n.replace(/^\./, '');
603
- if (sensitive.has(n)) hit = { ...sensitive.get(n), var: n };
604
- 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 };
746
+ // a value built from a credential (`attempt = Attempt user password`) logged through a field that is not the credential
747
+ // (`attemptUser attempt`, lowered to a member of `attempt`) is that field, not the secret
748
+ if (sensitive.has(n) && !usedOnlyAsNonSensitiveField(arg, n)) hit = { ...sensitive.get(n), var: n };
749
+ // a name that says it holds a hash or digest of the credential (`hashedPassword`, `tokenDigest`) is the stored form, not the secret
750
+ else if (SENSITIVE.test(bare) && !/^[A-Z][A-Za-z0-9_.]*\./.test(n) && !HASH_NAMED.test(bare)) hit = { name: bare, low: LOW_ENTROPY.test(bare), hashed: false, var: n };
751
+ // the record itself, logged whole (`show attempt`), when one of its fields was built from a credential (`attempt.attemptPassword`)
752
+ else if (!sensitive.has(n)) { const field = [...sensitive.keys()].find((k) => k.startsWith(`${n}.`)); if (field && !usedOnlyAsNonSensitiveField(arg, n)) hit = { ...sensitive.get(field), name: field, var: n }; }
605
753
  if (hit) break;
606
754
  }
607
755
  if (!hit) { const rec = wholeRecord(arg); if (rec) hit = { name: `${rec.type}.${rec.field}`, low: LOW_ENTROPY.test(rec.field), hashed: false, var: rec.name }; }
608
756
  if (hit && !isRedacted(arg)) {
609
757
  emit('hs-sensitive-logging', file, e.line || node.line, {
758
+ inlineCtx: e.hs && e.hs.inlineCtx,
610
759
  severity: hit.hashed ? 'low' : 'high',
611
760
  detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
612
761
  evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
@@ -639,5 +788,16 @@ export function analyzeHaskellRules(files, opts = {}) {
639
788
  const out = [];
640
789
  for (const f of findings) { if (seen.has(f.id)) continue; seen.add(f.id); out.push(f); }
641
790
  void notes; void opts;
642
- return { findings: out, controls, notes };
791
+ // A call-site copy of a finding the helper itself reports (the helper's body was inlined at the call) is dropped; a call-site finding
792
+ // whose helper reports nothing (the password shape is visible only at the call) is kept.
793
+ // The kept finding lists the call sites it also covers (`alsoAt`), so a reviewer still sees every line of the one weakness.
794
+ const byKey = new Map(out.map((f) => [`${f.rule}|${f.file}|${f._fnQid}`, f]));
795
+ const deduped = out.filter((f) => {
796
+ const helper = f.inlineCtx && f._fnQid !== f.inlineCtx ? byKey.get(`${f.rule}|${f.file}|${f.inlineCtx}`) : null;
797
+ if (!helper) return true;
798
+ helper.alsoAt = [...(helper.alsoAt || []), { line: f.line, reason: 'the same helper inlined at this call' }];
799
+ return false;
800
+ });
801
+ for (const f of deduped) delete f._fnQid;
802
+ return { findings: deduped, controls, notes };
643
803
  }
@@ -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