@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.
@@ -14,7 +14,7 @@
14
14
  // Nothing here executes code, evaluates a type, or reads a path outside the files it is handed.
15
15
 
16
16
  import { buildHaskellIR } from './haskell-ir.js';
17
- import { HS_LOG_SINKS, HS_MODEL_VERSION } from './haskell-models.js';
17
+ import { HS_LOG_SINKS, HS_MODEL_VERSION, sourceInfo } from './haskell-models.js';
18
18
  import { codeOfLine } from './haskell-comments.js';
19
19
 
20
20
  export const HS_RULES_VERSION = 'haskell-security-rules/1';
@@ -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,12 +75,18 @@ 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)) };
78
83
  const INPUT_READS = new Set(['Prelude.getContents', 'System.IO.getContents', 'System.IO.hGetContents', 'Data.ByteString.getContents', 'Data.ByteString.hGetContents', 'Data.ByteString.Char8.getContents', 'Data.ByteString.Char8.hGetContents', 'Data.ByteString.Lazy.getContents', 'Data.ByteString.Lazy.hGetContents', 'Data.ByteString.Lazy.Char8.getContents', 'Data.ByteString.Lazy.Char8.hGetContents', 'Data.Text.IO.getContents', 'Data.Text.IO.hGetContents', 'Data.Text.Lazy.IO.getContents', 'Data.Text.Lazy.IO.hGetContents']);
79
84
  const BOUNDING = /(?:^|\.)(?:take|takeE|isolate|hGetSome|hGet|splitAt|limit|limitedRead)$/;
85
+ // Source provenances (haskell-models.js) that mean "the HTTP client supplied this text": a read with one of these inside a function makes the
86
+ // value caller-supplied for the partial-function, allocation and bounding rules, whatever the function's own parameters are.
87
+ const REQUEST_PROVENANCE = new Set(['url-param', 'path-param', 'http-body', 'header', 'cookie', 'upload']);
88
+ // wai-extra's `requestSizeCheck (Just n) req` wraps the request so the body read after it fails past n bytes: a bound on every body read of the function.
89
+ const WAI_SIZE_CHECK = /(?:^|\.)requestSizeCheck$/;
80
90
  // `replicateM n getLine` reads n lines: the count is an allocation (and an input loop) sized by whoever supplied n.
81
91
  const ALLOCATORS = new Set(['replicate', 'Prelude.replicate', 'Data.List.replicate', 'Data.ByteString.replicate', 'Data.ByteString.Char8.replicate', 'Data.ByteString.Lazy.replicate', 'Data.Text.replicate', 'Data.Vector.replicate', 'Control.Monad.replicateM', 'Control.Monad.replicateM_', 'Data.Vector.replicateM', 'Data.List.genericReplicate']);
82
92
  const CLAMPS = /^(?:(?:Prelude|Data\.Ord)\.)?(?:min|clamp)$/;
@@ -89,14 +99,72 @@ const PARTIALS = new Set([...PARTIAL_NAMES, ...PARTIAL_NAMES.flatMap((n) => PART
89
99
  ...['Data.Map', 'Data.Map.Strict'].flatMap((m) => ['findMin', 'findMax', 'deleteFindMin', 'elemAt'].map((n) => `${m}.${n}`)), 'Data.Set.findMin', 'Data.Set.findMax', 'Data.Set.elemAt']);
90
100
  // A call that tests whether a container is empty, a key is present or a value is available: the partial call beside it is conditional on that test.
91
101
  const EMPTINESS_CHECK = /(?:^|\.)(?:null|notNull|isEmpty|length|size|isJust|isNothing|member|notMember|lookup|elem|notElem|nonEmpty|<match>)$/;
102
+ // A call whose result has a different shape from its argument (pieces of it, or a parse of it): a test of the argument says nothing about the result.
103
+ const SPLITTER = /(?:^|\.)(?:words|lines|splitOn|split|splitWhen|chunksOf|breakOn|decode|decodeStrict|eitherDecode|decodeEither|parseMaybe|parseEither|parse|readMaybe|readEither|reads)$/;
92
104
  const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
93
105
 
94
106
  // What makes a bound variable security-shaped, and what makes a value sensitive for logging.
95
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;
96
108
  const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
97
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
+ }
98
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;
99
- 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;
100
168
 
101
169
  const kids = (e) => {
102
170
  const out = [];
@@ -108,6 +176,19 @@ const kids = (e) => {
108
176
  return out;
109
177
  };
110
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
+
111
192
  function* walk(e, depth = 0) {
112
193
  if (!e || typeof e !== 'object' || depth > 80) return;
113
194
  yield e;
@@ -272,11 +353,13 @@ export function analyzeHaskellRules(files, opts = {}) {
272
353
  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; }
273
354
  }
274
355
 
356
+ let currentFn = null;
275
357
  const emit = (rule, file, line, extra = {}) => {
276
358
  const r = RULES[rule];
277
359
  const sev = extra.severity || r.severity;
278
360
  const f = {
279
361
  id: `hs-rule:${rule}:${file}:${line}`, severity: sev, file, line, vuln: extra.vuln || r.vuln, cwe: r.cwe,
362
+ _fnQid: currentFn ? currentFn.qid : null, ...(extra.inlineCtx ? { inlineCtx: extra.inlineCtx } : {}),
280
363
  description: `${extra.vuln || r.vuln}${extra.detail ? `: ${extra.detail}` : ''}`,
281
364
  remediation: r.remediation, parser: 'HS-RULES', family: r.family,
282
365
  language: 'haskell', capability: 'sast', analysisKind: 'application', evidenceKind: 'source',
@@ -299,6 +382,14 @@ export function analyzeHaskellRules(files, opts = {}) {
299
382
  }
300
383
  }
301
384
  for (const fn of fir.functions) {
385
+ currentFn = fn;
386
+ // A project helper the IR inlined at its call site (`digestFor (credPassword creds)` becomes the helper's body, rooted at a node with
387
+ // `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
388
+ // a rule that reports the same thing inside the helper can drop the call-site copy (the filter before the return).
389
+ for (const n of Object.values(fn.cfg.nodes)) {
390
+ for (const k of ['source', 'value', 'cond']) if (n[k]) markInlined(n[k], null, 0);
391
+ if (Array.isArray(n.args)) for (const a of n.args) markInlined(a, null, 0);
392
+ }
302
393
  if (fn.name.endsWith('.<module>')) continue;
303
394
  const bound = new Map(); // target var -> {callee, line}
304
395
  const sensitive = new Map(); // var -> {class:'low'|'high', hashed:boolean, line}
@@ -312,45 +403,89 @@ export function analyzeHaskellRules(files, opts = {}) {
312
403
  const fnHs = fn.hs || {};
313
404
  const strongParam = new Set((fnHs.paramSources || []).filter((p) => /^hs:handler-param:/.test(p.decorator || '')).map((p) => p.name));
314
405
  const entryLike = callersUnknown(fn);
406
+ const callsIn = (e) => { const out = []; for (const x of walk(e)) if (x.kind === 'call' && typeof x.callee === 'string') out.push(x); return out; };
407
+ // A value READ from the request inside the function (`raw <- param "page"`, `body <- strictRequestBody req`, `lookup h (requestHeaders req)`)
408
+ // is caller-supplied text wherever the function sits: the framework's own read says who supplied it, so no parameter label is needed.
409
+ const requestRead = (c) => { const s = sourceInfo(c.callee); return !!s && REQUEST_PROVENANCE.has(s.provenance); };
315
410
  const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
316
- if (derivedText.size) {
317
- for (let round = 0; round < 8; round++) {
318
- let grew = false;
319
- for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && !derivedText.has(n.target) && [...names(n.source)].some((x) => derivedText.has(x))) { derivedText.add(n.target); grew = true; }
320
- if (!grew) break;
411
+ const fromRequest = new Set();
412
+ for (let round = 0; round < 8; round++) {
413
+ let grew = false;
414
+ for (const n of fnNodes(fn)) {
415
+ if (n.kind !== 'assign' || !n.target || !n.source) continue;
416
+ const nm = [...names(n.source)];
417
+ const reads = callsIn(n.source).some(requestRead);
418
+ if (!derivedText.has(n.target) && (reads || nm.some((x) => derivedText.has(x)))) { derivedText.add(n.target); grew = true; }
419
+ if (!fromRequest.has(n.target) && (reads || nm.some((x) => fromRequest.has(x)))) { fromRequest.add(n.target); grew = true; }
321
420
  }
421
+ if (!grew) break;
322
422
  }
323
- const mentionsText = (e) => [...names(e)].some((x) => derivedText.has(x));
324
- const callsIn = (e) => { const out = []; for (const x of walk(e)) if (x.kind === 'call' && typeof x.callee === 'string') out.push(x); return out; };
423
+ const mentionsText = (e) => [...names(e)].some((x) => derivedText.has(x)) || callsIn(e).some(requestRead);
424
+ const mentionsRequest = (e) => [...names(e)].some((x) => fromRequest.has(x)) || callsIn(e).some(requestRead);
325
425
  const fnReturns = fnNodes(fn).filter((n) => n.kind === 'return' && n.value);
326
426
  const bounded = fnNodes(fn).some((n) => [n.value, n.source, ...(n.args || [])].some((e) => e && callsIn(e).some((c) => BOUNDING.test(c.callee))));
327
427
 
328
428
  // Values a condition in this function tests for emptiness or presence (`null ws`, `M.member k t`, `xs /= []`, a pattern match on it) and,
329
429
  // transitively, the values they were built from: a partial call on one of them is the other branch of that test.
430
+ // A test covers a partial call only on the value it tested, not on a value SPLIT or PARSED from it afterwards: `maybe "" id (lookup h hs)`
431
+ // proves the header is present, not that `words auth !! 1` has a second word; `isJust (decode body)` does cover `fromJust (decode body)`
432
+ // (the same split, of the same value, is what was tested). Names are therefore followed through assignments but not into a splitting call.
433
+ const directNames = (e) => { const out = new Set(); const visit = (x, d) => { if (!x || typeof x !== 'object' || d > 60) return; if (x.kind === 'call' && typeof x.callee === 'string' && SPLITTER.test(x.callee)) return; if (x.kind === 'ident' && x.name && !(x.hs && x.hs.functionRef)) out.add(x.name); for (const c of kids(x)) visit(c, d + 1); }; visit(e, 0); return out; };
434
+ const splitsIn = (e) => callsIn(e).filter((x) => SPLITTER.test(x.callee)).map((x) => `${x.callee.replace(/^.*\./, '')}|${[...names(x)].sort().join(',')}`);
330
435
  const assignSources = new Map();
331
- for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source) assignSources.set(n.target, [...(assignSources.get(n.target) || []), ...names(n.source)]);
436
+ for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source) assignSources.set(n.target, [...(assignSources.get(n.target) || []), ...directNames(n.source)]);
332
437
  const withOrigins = (seed) => { const out = new Set(seed); const queue = [...seed]; while (queue.length) { const v = queue.pop(); for (const o of assignSources.get(v) || []) if (!out.has(o)) { out.add(o); queue.push(o); } } return out; };
333
438
  const testedVars = new Set();
439
+ const testedSplits = new Set();
334
440
  for (const n of fnNodes(fn)) {
335
441
  if (n.kind !== 'if' || !n.cond) continue;
336
442
  for (const x of walk(n.cond)) {
337
- if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) for (const nm of names(x)) testedVars.add(nm);
338
- if (x.kind === 'binary' && ['==', '/=', '!='].includes(x.op) && [x.left, x.right].some((side) => side && side.kind === 'array' && side.elements.length === 0)) for (const nm of names(x)) testedVars.add(nm);
443
+ if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) { for (const nm of directNames(x)) testedVars.add(nm); for (const s of splitsIn(x)) testedSplits.add(s); }
444
+ if (x.kind === 'binary' && ['==', '/=', '!='].includes(x.op) && [x.left, x.right].some((side) => side && side.kind === 'array' && side.elements.length === 0)) for (const nm of directNames(x)) testedVars.add(nm);
339
445
  }
340
446
  }
341
447
  const testedAll = withOrigins(testedVars);
342
- const isTested = (args) => { const mine = withOrigins([...args.flatMap((a) => [...names(a)])]); for (const v of mine) if (testedAll.has(v) && derivedText.has(v)) return true; return false; };
448
+ const isTested = (args) => {
449
+ if (args.some((a) => splitsIn(a).some((s) => testedSplits.has(s)))) return true;
450
+ const mine = withOrigins([...args.flatMap((a) => [...directNames(a)])]);
451
+ for (const v of mine) if (testedAll.has(v) && derivedText.has(v)) return true;
452
+ return false;
453
+ };
343
454
 
344
455
  // A relational test against a number or a length (`total >= limit`, `size <= 65536`) in this function: the evidence that a read or a body is capped.
345
456
  const hasSizeTest = fnNodes(fn).some((n) => n.kind === 'if' && n.cond && [...walk(n.cond)].some((x) => x.kind === 'binary' && RELATIONAL.has(x.op)));
457
+ // The values a relational test in this function compares with something (`n > maxBatch`, `count <= 100`): a count one of them sizes is bounded
458
+ // by that test (the direction is not judged; a test that only rejects negatives still counts, which errs toward silence).
459
+ const sizeTestedVars = new Set();
460
+ for (const n of fnNodes(fn)) if (n.kind === 'if' && n.cond) for (const x of walk(n.cond)) if (x.kind === 'binary' && RELATIONAL.has(x.op)) for (const nm of names(x)) sizeTestedVars.add(nm);
346
461
  const readsRequestLength = fnNodes(fn).some((n) => [n.value, n.source, n.cond, ...(n.args || [])].some((ex) => ex && [...walk(ex)].some((x) => (x.kind === 'ident' && REQUEST_LENGTH.has(x.name)) || (x.kind === 'call' && REQUEST_LENGTH.has(x.callee)))));
347
- const sizeChecked = hasSizeTest && readsRequestLength;
462
+ const waiSizeChecked = fnNodes(fn).some((n) => [n.value, n.source, n.cond, ...(n.args || [])].some((ex) => ex && callsIn(ex).some((x) => WAI_SIZE_CHECK.test(x.callee))));
463
+ const sizeChecked = (hasSizeTest && readsRequestLength) || waiSizeChecked;
348
464
  // One predictable-random finding per function: a clock reading that seeds a generator in a token-shaped function is a single flaw,
349
465
  // however many of its steps (the clock, the seed, the generator) the rule can see.
350
466
  let randomEmitted = false;
351
467
  const emitRandom = (line, extra) => { if (randomEmitted) return; randomEmitted = true; emit('hs-predictable-random', file, line, extra); };
352
468
 
353
469
  const seeds = (e) => { for (const n of names(e)) if (SENSITIVE.test(n.replace(/^\./, ''))) return n.replace(/^\./, ''); return null; };
470
+ // A parameter whose declared type is a record of this project with a credential or card field (`Card { cardNumber, cardCvv }`), written
471
+ // to a log WHOLE: `show card`, or the value spliced into the message. Handed to any other function (`masked card`) it is that function's
472
+ // result that is logged, which is judged by the names it carries, not here.
473
+ const sensitiveRecords = new Map((fnHs.recordParams || []).filter((r) => (r.fields || []).some((x) => SENSITIVE.test(x))).map((r) => [r.name, r]));
474
+ const FORMAT_WHOLE = /(?:^|\.)(?:show|tshow|showt|pack|unpack|encode|toJSON|encodeUtf8|decodeUtf8|fromStrict|toStrict|toLazyText|fromString|toString|displayShow|pShow|ppShow|textDisplay|toLogStr|logStr)$/;
475
+ const wholeRecord = (arg) => {
476
+ if (!sensitiveRecords.size) return null;
477
+ let found = null;
478
+ const visit = (e, d) => {
479
+ if (!e || typeof e !== 'object' || d > 40 || found) return;
480
+ if (e.kind === 'ident' && sensitiveRecords.has(e.name)) { const r = sensitiveRecords.get(e.name); found = { name: e.name, type: r.type, field: (r.fields || []).find((x) => SENSITIVE.test(x)) }; return; }
481
+ if (e.kind === 'binary') { visit(e.left, d + 1); visit(e.right, d + 1); return; }
482
+ if (e.kind === 'call' && typeof e.callee === 'string') { if (FORMAT_WHOLE.test(e.callee)) for (const a of e.args || []) visit(a, d + 1); return; }
483
+ if (e.kind === 'array') { for (const a of e.elements || []) visit(a, d + 1); return; }
484
+ if (e.hs && e.hs.inner) visit(e.hs.inner, d + 1);
485
+ };
486
+ visit(arg, 0);
487
+ return found;
488
+ };
354
489
 
355
490
  // pass over nodes in program order
356
491
  for (const node of fnNodes(fn)) {
@@ -371,9 +506,13 @@ export function analyzeHaskellRules(files, opts = {}) {
371
506
  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 });
372
507
  continue;
373
508
  }
374
- // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`
375
- if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) {
376
- const [k, v] = e.elements;
509
+ // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`, or handed to a framework's header
510
+ // setter: Scotty `setHeader "Set-Cookie" ("session=" <> tok <> "; Path=/")`, Spock `setHeader`, Yesod `addHeader`
511
+ let cookiePair = null;
512
+ if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) cookiePair = e.elements;
513
+ 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]];
514
+ if (cookiePair) {
515
+ const [k, v] = cookiePair;
377
516
  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))));
378
517
  if (isSetCookie && v) {
379
518
  const text = literalTextOf(v);
@@ -426,7 +565,10 @@ export function analyzeHaskellRules(files, opts = {}) {
426
565
  // scotty-cookie's helpers build a cookie that carries neither Secure nor HttpOnly
427
566
  if ((c === 'Web.Scotty.Cookie.setSimpleCookie' || c === 'Web.Scotty.Cookie.makeSimpleCookie') && args[0]) {
428
567
  const nm = args[0];
429
- 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 } });
568
+ // A name that is a PARAMETER of this function is decided by the caller: the function is itself a cookie helper
569
+ // (scotty's own `setSimpleCookie n v`, `deleteCookie c`), and the call that passes a session name is the site to report.
570
+ const ownParam = nm.kind === 'ident' && Array.isArray(fn.params) && fn.params.includes(nm.name);
571
+ 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 } });
430
572
  }
431
573
  // a socket read repeated by a recursive loop, with no size test anywhere in it
432
574
  if (SOCKET_READS.has(c) && !hasSizeTest && !bounded && fn.calls.some((x) => x.callee === fn.name && x.hs && x.hs.target === fn.qid)) {
@@ -451,8 +593,9 @@ export function analyzeHaskellRules(files, opts = {}) {
451
593
  }
452
594
  }
453
595
  // password hashed with a fast hash
454
- if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
455
- emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value` });
596
+ // (a hash call the IR inlined from a project helper, `digestFor (credPassword creds)` at the comparison, is reported once, in the helper)
597
+ 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(/^\./, ''))))) {
598
+ emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value`, inlineCtx: e.hs && e.hs.inlineCtx });
456
599
  }
457
600
  if (PBKDF2_CALLS.has(c)) {
458
601
  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) {
@@ -463,7 +606,7 @@ export function analyzeHaskellRules(files, opts = {}) {
463
606
  if (ECB_CALLS.has(c)) emit('hs-ecb-mode', file, line, { detail: c });
464
607
  if (IV_CALLS.has(c) && args[0] && isLiteralLike(args[0])) emit('hs-static-iv', file, line, { detail: `${c} of a constant value` });
465
608
  // randomness bound to a security-shaped name
466
- if (RANDOM_CALLS.has(c) && node.kind === 'assign' && SECURITY_VAR.test(node.target || '')) {
609
+ if (RANDOM_CALLS.has(c) && node.kind === 'assign' && (SECURITY_VAR.test(node.target || '') || (CONTEXT_CODE_VAR.test(node.target || '') && fnMentions(fn, CODE_CONTEXT)))) {
467
610
  emitRandom(line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
468
611
  }
469
612
  if (RANDOM_CALLS.has(c) && SECURITY_FN.test(fnBase) && node.kind !== 'assign') {
@@ -472,7 +615,8 @@ export function analyzeHaskellRules(files, opts = {}) {
472
615
  if (TIME_SOURCES.has(c) && SECURITY_FN.test(fnBase)) emitRandom(line, { vuln: 'Clock value used as a security value', detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
473
616
  if (INPUT_READS.has(c) && !bounded && readsOutsideInput(fn, c, args, importsNetwork)) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
474
617
  // an allocation whose size is parsed caller text and is not clamped
475
- if (ALLOCATORS.has(c) && args[0] && mentionsText(args[0]) && callsIn(args[0]).some((x) => /(?:^|\.)(?:read|readMaybe|readEither)$/.test(x.callee)) && !callsIn(args[0]).some((x) => CLAMPS.test(x.callee))) {
618
+ // (a number the framework parsed from the request, `n <- param "count" :: ActionM Int`, is parsed caller text too)
619
+ if (ALLOCATORS.has(c) && args[0] && mentionsText(args[0]) && (mentionsRequest(args[0]) || callsIn(args[0]).some((x) => /(?:^|\.)(?:read|readMaybe|readEither)$/.test(x.callee))) && !callsIn(args[0]).some((x) => CLAMPS.test(x.callee)) && ![...names(args[0])].some((v) => sizeTestedVars.has(v))) {
476
620
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
477
621
  }
478
622
  // a partial function applied to caller text: one malformed value raises an uncaught exception
@@ -510,6 +654,14 @@ export function analyzeHaskellRules(files, opts = {}) {
510
654
  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; }
511
655
  if (cls) {
512
656
  let cleared = false;
657
+ // a yes/no answer about the credential (`accepted <- checkCredentials attempt`, a comparison, a conjunction) carries no credential text
658
+ 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];
659
+ if (root && ((root.kind === 'binary' && ['==', '/=', '!=', '&&', '||', '<', '>', '<=', '>='].includes(root.op)) || root.kind === 'logical')) cleared = true;
660
+ // the binding says it holds a hash or digest (`hashedPassword <- hashPassword plain`): the stored form, whatever made it
661
+ if (HASH_NAMED.test(node.target || '')) cleared = true;
662
+ if (root && root.kind === 'call' && typeof root.callee === 'string' && /(?:^|\.)(?:not|elem|notElem|null|isJust|isNothing|any|all|and|or)$/.test(root.callee)) cleared = true;
663
+ // a project helper whose every return is such an answer (`checkCredentials a = pure (user a == "x" && password a == "y")`)
664
+ if (root && root.kind === 'call' && typeof root.callee === 'string' && returnsYesNo(fir, root.callee)) cleared = true;
513
665
  for (const x of walk(src)) {
514
666
  if (x.kind !== 'call' || typeof x.callee !== 'string') continue;
515
667
  if (REDACTOR.test(x.callee)) cleared = true;
@@ -532,7 +684,8 @@ export function analyzeHaskellRules(files, opts = {}) {
532
684
  if (!argList.length || argList.every((a) => !a)) continue;
533
685
  // a file append is a log only when its path says so (a log, audit or trace file)
534
686
  if (sink.channel === 'file') { const p = (e.args || [])[0]; if (!(p && p.kind === 'literal' && /(?:log|audit|trace|journal)/i.test(String(p.value)))) continue; }
535
- const isRedacted = (a) => { for (const x of walk(a)) if (x.kind === 'call' && typeof x.callee === 'string' && (REDACTOR.test(x.callee) || STRONG_KDF.test(x.callee) || x.callee === 'const')) return true; return false; };
687
+ // (a project function the IR inlined into the argument, `masked card`, keeps its origin in `hs.inlinedFrom`: a redactor by name earns the same credit inlined as called)
688
+ const isRedacted = (a) => { for (const x of walk(a)) { if (x.kind === 'call' && typeof x.callee === 'string' && (REDACTOR.test(x.callee) || STRONG_KDF.test(x.callee) || x.callee === 'const')) return true; if (x.hs && typeof x.hs.inlinedFrom === 'string' && REDACTOR.test(x.hs.inlinedFrom.replace(/^.*::/, ''))) return true; } return false; };
536
689
  for (const arg of argList) {
537
690
  if (!arg) continue;
538
691
  let hit = null;
@@ -547,12 +700,19 @@ export function analyzeHaskellRules(files, opts = {}) {
547
700
  for (const n of names(arg)) {
548
701
  if (hit) break;
549
702
  const bare = n.replace(/^\./, '');
550
- if (sensitive.has(n)) hit = { ...sensitive.get(n), var: n };
551
- 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 };
703
+ // a value built from a credential (`attempt = Attempt user password`) logged through a field that is not the credential
704
+ // (`attemptUser attempt`, lowered to a member of `attempt`) is that field, not the secret
705
+ if (sensitive.has(n) && !usedOnlyAsNonSensitiveField(arg, n)) hit = { ...sensitive.get(n), var: n };
706
+ // a name that says it holds a hash or digest of the credential (`hashedPassword`, `tokenDigest`) is the stored form, not the secret
707
+ 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 };
708
+ // the record itself, logged whole (`show attempt`), when one of its fields was built from a credential (`attempt.attemptPassword`)
709
+ 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 }; }
552
710
  if (hit) break;
553
711
  }
712
+ 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 }; }
554
713
  if (hit && !isRedacted(arg)) {
555
714
  emit('hs-sensitive-logging', file, e.line || node.line, {
715
+ inlineCtx: e.hs && e.hs.inlineCtx,
556
716
  severity: hit.hashed ? 'low' : 'high',
557
717
  detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
558
718
  evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
@@ -563,6 +723,20 @@ export function analyzeHaskellRules(files, opts = {}) {
563
723
  }
564
724
  }
565
725
  }
726
+ // A security-shaped function (`generateApiKey`, `newResetCode`) whose RESULT is built from a System.Random value: the generator was bound
727
+ // to an ordinary name (`g <- newStdGen`, `let bytes = take 16 (randoms g)`), so neither the binding rule nor the "feeds the function" rule
728
+ // above saw it; the value reaching the function's return is what makes it the token.
729
+ if (!randomEmitted && SECURITY_FN.test(fnBase)) {
730
+ const hasRandom = (e) => { for (const x of walk(e)) if ((x.kind === 'call' && RANDOM_CALLS.has(x.callee)) || (x.kind === 'ident' && RANDOM_CALLS.has(x.name) && !(x.hs && x.hs.functionRef))) return true; return false; };
731
+ const carriers = new Set(); let seedLine = null;
732
+ for (let round = 0; round < 8; round++) {
733
+ let grew = false;
734
+ for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && !carriers.has(n.target) && (hasRandom(n.source) || [...names(n.source)].some((x) => carriers.has(x)))) { carriers.add(n.target); if (seedLine === null || (Number.isFinite(n.line) && n.line < seedLine)) seedLine = n.line; grew = true; }
735
+ if (!grew) break;
736
+ }
737
+ const ret = fnReturns.find((n) => hasRandom(n.value) || [...names(n.value)].some((x) => carriers.has(x)));
738
+ if (ret) emitRandom(ret.line || seedLine || fn.line, { detail: `a System.Random value reaches the result of \`${fnBase}\``, evidence: { function: fnBase, via: [...carriers].slice(0, 4) } });
739
+ }
566
740
  }
567
741
  }
568
742
 
@@ -571,5 +745,16 @@ export function analyzeHaskellRules(files, opts = {}) {
571
745
  const out = [];
572
746
  for (const f of findings) { if (seen.has(f.id)) continue; seen.add(f.id); out.push(f); }
573
747
  void notes; void opts;
574
- return { findings: out, controls, notes };
748
+ // 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
749
+ // whose helper reports nothing (the password shape is visible only at the call) is kept.
750
+ // The kept finding lists the call sites it also covers (`alsoAt`), so a reviewer still sees every line of the one weakness.
751
+ const byKey = new Map(out.map((f) => [`${f.rule}|${f.file}|${f._fnQid}`, f]));
752
+ const deduped = out.filter((f) => {
753
+ const helper = f.inlineCtx && f._fnQid !== f.inlineCtx ? byKey.get(`${f.rule}|${f.file}|${f.inlineCtx}`) : null;
754
+ if (!helper) return true;
755
+ helper.alsoAt = [...(helper.alsoAt || []), { line: f.line, reason: 'the same helper inlined at this call' }];
756
+ return false;
757
+ });
758
+ for (const f of deduped) delete f._fnQid;
759
+ return { findings: deduped, controls, notes };
575
760
  }
@@ -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
@@ -26,11 +30,17 @@ import { findSites, identNames } from './haskell-guards.js';
26
30
  export const TRUST_VERDICTS = Object.freeze(['attacker-reachable', 'operator-controlled', 'api-contract', 'entry-parameter', 'data-input', 'unattributed']);
27
31
 
28
32
  const OPERATOR = new Set(['cli', 'env']);
29
- const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network']);
33
+ const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network', 'upload']);
30
34
  const DATA = new Set(['stdin', 'file-read']);
31
35
  const WEAK_PARAM_LABEL = /\(caller-controlled\)$/;
32
- const WRAPPER_FAMILIES = new Set(['CWE-78', 'CWE-88']);
33
- const CONVERSIONS = /(?:^|\.)(?:pack|unpack|fromString|toString|show|encodeUtf8|decodeUtf8|toStrict|fromStrict|toText|unpackFS|fromText)$/;
36
+ // CWE-918 is deliberately NOT a wrapper family: `fetch url = parseRequest url >>= httpNoBody` is pinned as a finding in
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)$/;
34
44
  const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
35
45
  // list plumbing that only lays the parameters out as text (`unwords args`, `concatMap (' ':) args`): no fixed words are added
36
46
  const LIST_LAYOUT = /(?:^|\.)(?:unwords|unlines|intercalate|concatMap|concat|map|fmap)$/;
@@ -176,7 +186,8 @@ function passThroughVerdict(perFile, f) {
176
186
  if (!weak.size) return null;
177
187
  const own = new Set(Array.isArray(analysed.params) ? analysed.params : []);
178
188
  const res = localResolver(analysed);
179
- return args.every((a) => isPassThrough(a, own, family, res)) && args.some((a) => { const seen = new Set(); const walkIds = (e, d = 0) => { if (!e || d > 12) return; if (e.kind === 'ident') seen.add(e.name); for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') walkIds(e[k], d + 1); for (const k of ['args', 'elements']) if (Array.isArray(e[k])) e[k].forEach((x) => walkIds(x, d + 1)); }; let x = a; for (let i = 0; i < 4; i++) { const r = res(x); if (r === x) break; x = r; } walkIds(x); return [...seen].some((n) => weak.has(n)); }) ? 'direct' : null;
189
+ // (a name bound once in the function stands for its definition here too, so `req <- parseRequest url; httpLbs req m` reaches `url`; a record update is a union of base and update)
190
+ return args.every((a) => isPassThrough(a, own, family, res)) && args.some((a) => { const seen = new Set(); const walkIds = (e, d = 0) => { if (!e || d > 12) return; if (e.kind === 'ident') { seen.add(e.name); const r = res(e); if (r && r !== e) walkIds(r, d + 1); } for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') walkIds(e[k], d + 1); for (const k of ['args', 'elements', 'branches']) if (Array.isArray(e[k])) e[k].forEach((x) => walkIds(x, d + 1)); }; let x = a; for (let i = 0; i < 4; i++) { const r = res(x); if (r === x) break; x = r; } walkIds(x); return [...seen].some((n) => weak.has(n)); }) ? 'direct' : null;
180
191
  }
181
192
  // the sink is in a callee
182
193
  const own = new Set(Array.isArray(sink.fn.params) ? sink.fn.params : []);
@@ -257,9 +268,14 @@ export function applyTrustBoundary(findings, perFile) {
257
268
  else if (roots.length && [...classes].every((c) => c === 'operator')) {
258
269
  verdict = 'operator-controlled';
259
270
  reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
260
- } 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).
261
275
  verdict = 'operator-controlled';
262
- 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';
263
279
  } else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
264
280
  else if (classes.has('weak-param')) {
265
281
  verdict = 'entry-parameter';