@clear-capabilities/agentic-security-scanner 0.187.0 → 0.188.0

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.
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { buildHaskellIR } from './haskell-ir.js';
17
17
  import { HS_LOG_SINKS, HS_MODEL_VERSION } from './haskell-models.js';
18
+ import { codeOfLine } from './haskell-comments.js';
18
19
 
19
20
  export const HS_RULES_VERSION = 'haskell-security-rules/1';
20
21
 
@@ -68,7 +69,9 @@ const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'S
68
69
  const SECRET_KEY_CALLS = new Set(['Web.JWT.hmacSecret', 'Web.JWT.secret', 'Servant.Auth.Server.fromSecret', 'Web.ClientSession.initKey']);
69
70
  const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(e.value))) || (e.kind === 'ident' && /(?:^|\.)True$/.test(e.name || '')));
70
71
  const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
71
- const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|salt|apikey|resetcode|passcode|verification)/i;
72
+ // 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
+ // 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;
72
75
  // Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
73
76
  const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
74
77
  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)) };
@@ -89,7 +92,7 @@ const EMPTINESS_CHECK = /(?:^|\.)(?:null|notNull|isEmpty|length|size|isJust|isNo
89
92
  const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
90
93
 
91
94
  // What makes a bound variable security-shaped, and what makes a value sensitive for logging.
92
- const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|salt|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
95
+ const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
93
96
  const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
94
97
  const LOW_ENTROPY = /(?:pass(?:word|wd|phrase)?|pwd|ssn|creditcard|cardnumber|cvv|\bpin\b)/i;
95
98
  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;
@@ -128,6 +131,15 @@ const isLiteralLike = (e, depth = 0) => {
128
131
  return false;
129
132
  };
130
133
 
134
+ // A concatenation of literals only (`"a=" <> "0" <> "; Path=/"`).
135
+ const constantConcat = (e, depth = 0) => {
136
+ if (!e || depth > 12) return false;
137
+ if (e.kind === 'literal') return true;
138
+ if (e.kind === 'binary' && e.hs && e.hs.concat) return constantConcat(e.left, depth + 1) && constantConcat(e.right, depth + 1);
139
+ if (e.kind === 'call' && /(?:^|\.)(?:pack|fromString|encodeUtf8|fromStrict|toStrict)$/.test(e.callee || '') && e.args && e.args.length === 1) return constantConcat(e.args[0], depth + 1);
140
+ return false;
141
+ };
142
+
131
143
  function fnNodes(fn) { return Object.values(fn.cfg.nodes); }
132
144
 
133
145
  // The text a concatenation spells out, in order, skipping the parts that are not literals (`"session_id=" <> sid <> "; Path=/"` -> `session_id=; Path=/`).
@@ -142,7 +154,80 @@ function literalTextOf(e, depth = 0) {
142
154
  function snippetOf(text, line) {
143
155
  if (!text || !Number.isInteger(line) || line < 1) return '';
144
156
  const l = text.split('\n')[line - 1];
145
- return typeof l === 'string' ? l.trim().slice(0, 240) : '';
157
+ return typeof l === 'string' ? codeOfLine(l).slice(0, 240) : '';
158
+ }
159
+
160
+ // An unbounded read is a resource-exhaustion finding when the party that fills the input is not the operator. A handle opened on a
161
+ // local file, a child process's pipe or a handle passed in is the operator's own (a file's size is what the file system says); a
162
+ // socket or connection handle is filled by the peer. A read of standard input is the operator's in a program's own control flow, and
163
+ // the caller's only in an entry point nothing in the analysed code calls (a library function handing back whatever its caller pipes in).
164
+ const NETWORK_HANDLE_CALL = /(?:^|\.)(?:socketToHandle|connectTo|accept|acceptOn|openTCPConnection)$/;
165
+ function readsOutsideInput(fn, callee, args, importsNetwork) {
166
+ const h = args && args[0];
167
+ const isStdin = !!h && h.kind === 'ident' && /(?:^|\.)stdin$/.test(h.name || '');
168
+ const handleRead = /hGetContents$/.test(callee) && !isStdin;
169
+ // the networking packages are not all in the model registry, so a call keeps its bare name; the module imports say which library it is
170
+ if (handleRead) return !!importsNetwork && (fn.calls || []).some((x) => typeof x.callee === 'string' && NETWORK_HANDLE_CALL.test(x.callee));
171
+ const hs = fn.hs || {};
172
+ return !hs.reached && !/(?:^|\.)main$/.test(fn.name || '');
173
+ }
174
+
175
+ const EQUALITY_OPS = new Set(['==', '/=', '!=']);
176
+ const VERIFY_CALL = /(?:^|\.)(?:constEq|secureCompare|verify\w*|authenticate\w*|checkPassword|validate\w*|hmac\w*|sign\w*|jwt\w*|encode(?:Signed)?)$/i;
177
+ // Whole camelCase / snake_case words: `sig` must not match `design`, `mac` must not match `machine`.
178
+ const CREDENTIAL_WORDS = new Set(['token', 'secret', 'nonce', 'otp', 'csrf', 'apikey', 'session', 'signature', 'sig', 'mac', 'hmac', 'credential', 'credentials', 'auth', 'cookie', 'passcode', 'cert', 'fingerprint']);
179
+ const CREDENTIAL_NAME = { test: (name) => String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).some((w) => CREDENTIAL_WORDS.has(w)) };
180
+
181
+ /**
182
+ * Why a weak-hash call is a security matter: the list is empty when the digest is only an identifier or its use is not visible.
183
+ * Everything is read from the function's own IR (a value compared, bound to a credential-shaped name, hashing a secret-shaped value,
184
+ * handed to a signing or verifying call); nothing is read from comments or from the file's name.
185
+ */
186
+ function weakDigestUse(fn, node, hashCall, hashArgs, fnBase) {
187
+ const reasons = [];
188
+ const nodes = Object.values(fn.cfg.nodes);
189
+ const exprsOf = (n) => [n.source, n.value, n.cond, ...(n.args || [])].filter(Boolean);
190
+ const hasNode = (x, target) => { for (const y of walk(x)) if (y === target) return true; return false; };
191
+ // 1. what is hashed
192
+ const hashed = (hashArgs || []).flatMap((a) => [...names(a)]).map((n) => n.replace(/^\./, ''));
193
+ if (hashed.some((n) => SENSITIVE.test(n) && !PASSWORD_VAR.test(n))) reasons.push('hashes a secret-shaped value');
194
+ // 2. values carrying the digest: the assignment target and everything derived from it
195
+ const carriers = new Set();
196
+ // the digest travels through formatting (`show`, `pack`, hex encoding, concatenation), not through an arbitrary call that merely receives it
197
+ const FORMAT = /(?:^|\.)(?:show|pack|unpack|take|drop|toLower|toUpper|showDigest|convertToBase|encode\w*|decode\w*|fromStrict|toStrict|toString|fromString|digestToHexByteString|toHex|hexlify|concat|append|map|fmap|fromText|toText)$/;
198
+ const flowsFrom = (e, depth = 0) => {
199
+ if (!e || depth > 10) return false;
200
+ if (e === hashCall) return true;
201
+ if (e.kind === 'ident') return carriers.has(e.name);
202
+ if (e.kind === 'binary' && e.hs && e.hs.concat) return flowsFrom(e.left, depth + 1) || flowsFrom(e.right, depth + 1);
203
+ if (e.kind === 'call' && typeof e.callee === 'string' && FORMAT.test(e.callee)) return (e.args || []).some((a) => flowsFrom(a, depth + 1));
204
+ if (e.kind === 'call' && e === hashCall) return true;
205
+ return false;
206
+ };
207
+ if (node.kind === 'assign' && node.target && node.source && flowsFrom(node.source)) carriers.add(node.target);
208
+ for (let round = 0; round < 8; round++) {
209
+ let grew = false;
210
+ for (const n of nodes) if (n.kind === 'assign' && n.target && n.source && !carriers.has(n.target) && flowsFrom(n.source)) { carriers.add(n.target); grew = true; }
211
+ if (!grew) break;
212
+ }
213
+ const carries = (x) => !!x && (flowsFrom(x) || [...names(x)].some((y) => carriers.has(y)));
214
+ let compared = false; let signed = false;
215
+ for (const n of nodes) {
216
+ for (const root of exprsOf(n)) {
217
+ for (const x of walk(root)) {
218
+ if (x.kind === 'binary' && EQUALITY_OPS.has(x.op) && (carries(x.left) || carries(x.right))) compared = true;
219
+ if (x.kind === 'call' && typeof x.callee === 'string' && VERIFY_CALL.test(x.callee) && (x.args || []).some((a) => carries(a))) signed = true;
220
+ }
221
+ }
222
+ }
223
+ if (compared) reasons.push('the digest is compared with another value');
224
+ if (signed) reasons.push('the digest is passed to a verifying or signing call');
225
+ // 3. what it becomes: a credential-shaped binding, or the result of a credential-shaped function
226
+ const bound = [...carriers].find((c) => CREDENTIAL_NAME.test(c) || SECURITY_VAR.test(c));
227
+ if (bound) reasons.push(`the digest is bound to \`${bound}\``);
228
+ const returned = nodes.some((n) => n.kind === 'return' && n.value && carries(n.value));
229
+ if (returned && (SECURITY_FN.test(fnBase) || CREDENTIAL_NAME.test(fnBase))) reasons.push(`the digest is the result of \`${fnBase}\``);
230
+ return reasons;
146
231
  }
147
232
 
148
233
  /**
@@ -186,6 +271,7 @@ export function analyzeHaskellRules(files, opts = {}) {
186
271
  };
187
272
 
188
273
  for (const [file, fir] of Object.entries(ir.perFile)) {
274
+ const importsNetwork = (fir.imports || []).some((im) => /^Network(?:\.|$)/.test(im.module));
189
275
  // imports of obsolete primitives (type-directed use is not resolved: disclosed on the finding)
190
276
  for (const imp of fir.imports || []) {
191
277
  if (WEAK_CIPHER_MODULES.has(imp.module)) {
@@ -199,7 +285,14 @@ export function analyzeHaskellRules(files, opts = {}) {
199
285
 
200
286
  const fnBase = fn.name.replace(/^.*\./, '');
201
287
  // text-typed parameters of an exported function, and every local value derived from them
202
- const derivedText = new Set([...((fn.hs && fn.hs.textParams) || []), ...((fn.hs && fn.hs.inputParams) || [])]);
288
+ // A parameter a web framework hands to a handler IS request input wherever the handler is registered. Any other exported
289
+ // function's text parameter is "caller-supplied" only while nothing in the analysed code calls the function: once something does,
290
+ // its parameters take the values those callers pass (a check the taint engine makes per call), and a rule that does not follow
291
+ // calls cannot say the text comes from outside.
292
+ const fnHs = fn.hs || {};
293
+ const strongParam = new Set((fnHs.paramSources || []).filter((p) => /^hs:handler-param:/.test(p.decorator || '')).map((p) => p.name));
294
+ const entryLike = !fnHs.reached;
295
+ const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
203
296
  if (derivedText.size) {
204
297
  for (let round = 0; round < 8; round++) {
205
298
  let grew = false;
@@ -255,7 +348,7 @@ export function analyzeHaskellRules(files, opts = {}) {
255
348
  if (TIME_SOURCES.has(e.name) && SECURITY_FN.test(fnBase)) emitRandom(node.line, { vuln: 'Clock value used as a security value', detail: `${e.name} feeds \`${fnBase}\``, evidence: { function: fnBase, source: e.name } });
256
349
  // a generator named as the whole value of a security-shaped action (`newCsrfToken = randomIO`): no call is written, the name is the call
257
350
  if (RANDOM_CALLS.has(e.name) && SECURITY_FN.test(fnBase) && !(e.hs && e.hs.functionRef)) emitRandom(node.line, { detail: `${e.name} feeds \`${fnBase}\``, evidence: { function: fnBase, source: e.name } });
258
- if (INPUT_READS.has(e.name) && !bounded) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
351
+ 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 });
259
352
  continue;
260
353
  }
261
354
  // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`
@@ -268,7 +361,8 @@ export function analyzeHaskellRules(files, opts = {}) {
268
361
  const sessionLike = !nm || SESSION_COOKIE.test(nm[1]);
269
362
  const secure = /;\s*Secure\b/i.test(text); const httpOnly = /;\s*HttpOnly\b/i.test(text);
270
363
  // an attribute string that is not written here (a rendered cookie value) cannot be judged: only a cookie whose text is visible is
271
- if (text && sessionLike && (!secure || !httpOnly)) emit('hs-insecure-cookie', file, e.line || node.line, { detail: `${!httpOnly ? 'HttpOnly missing' : ''}${!httpOnly && !secure ? ', ' : ''}${!secure ? 'Secure missing' : ''}`, evidence: { httpOnly, secure, cookie: nm ? nm[1] : null } });
364
+ // a header value that is entirely constant text (`authn=0` written to clear a login) carries no per-user credential
365
+ if (text && sessionLike && !isLiteralLike(v) && !constantConcat(v) && (!secure || !httpOnly)) emit('hs-insecure-cookie', file, e.line || node.line, { detail: `${!httpOnly ? 'HttpOnly missing' : ''}${!httpOnly && !secure ? ', ' : ''}${!secure ? 'Secure missing' : ''}`, evidence: { httpOnly, secure, cookie: nm ? nm[1] : null } });
272
366
  }
273
367
  }
274
368
  if (e.kind === 'object' && e.hs && e.hs.update && Array.isArray(e.props)) {
@@ -324,7 +418,17 @@ export function analyzeHaskellRules(files, opts = {}) {
324
418
  // weak hash
325
419
  if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
326
420
  const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
327
- if (!pw) emit('hs-weak-hash', file, line, { detail: c });
421
+ // MD5 and SHA-1 are broken for collision and preimage resistance, which only matters where an attacker gains from a
422
+ // collision: a digest that is VERIFIED (compared with an expected value), that authenticates or signs, that hashes a
423
+ // secret, or that is a credential. A digest that only NAMES something (a file name, a cache key, a bookmark, an ETag) is
424
+ // an identifier, and a hash helper handed on to callers says nothing about its use: neither is a finding.
425
+ if (!pw) {
426
+ const use = weakDigestUse(fn, node, e, args, fnBase);
427
+ // The API identity is always reported (a reviewer wants to know where MD5/SHA-1 is computed); it is a vulnerability
428
+ // finding only where a use that depends on the hash's strength is visible. Otherwise it is an `info` note.
429
+ if (use.length) emit('hs-weak-hash', file, line, { detail: `${c}: ${use.join('; ')}`, evidence: { call: c, use } });
430
+ else emit('hs-weak-hash', file, line, { severity: 'info', confidence: 0.4, detail: `${c}: no use that depends on the digest's collision or preimage resistance is visible (it may only name or key something)`, evidence: { call: c, use: [] } });
431
+ }
328
432
  }
329
433
  // password hashed with a fast hash
330
434
  if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
@@ -346,7 +450,7 @@ export function analyzeHaskellRules(files, opts = {}) {
346
450
  emitRandom(line, { detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
347
451
  }
348
452
  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 } });
349
- if (INPUT_READS.has(c) && !bounded) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
453
+ 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 });
350
454
  // an allocation whose size is parsed caller text and is not clamped
351
455
  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))) {
352
456
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
@@ -369,7 +473,7 @@ export function analyzeHaskellRules(files, opts = {}) {
369
473
  if (yes) emit('hs-xml-entity-substitution', file, line, { detail: 'withSubstDTDEntities yes' });
370
474
  }
371
475
  // boundaries
372
- if (UNSAFE_CALLS[c]) {
476
+ if (Object.hasOwn(UNSAFE_CALLS, c)) {
373
477
  const b = UNSAFE_CALLS[c];
374
478
  emit('hs-unsafe-io-boundary', file, line, { vuln: `Unsafe boundary: ${b}`, confidence: 0.5, modelNote: BOUNDARIES[b], detail: BOUNDARIES[b] });
375
479
  }
@@ -0,0 +1,268 @@
1
+ // Trust-boundary adjudication of Haskell taint findings.
2
+ //
3
+ // A taint finding says "data from a SOURCE reaches a SINK". Whether that is a vulnerability depends on whether the party that controls the
4
+ // source is a different party from the one the program runs for. This module reads the origins the taint engine recorded for each finding
5
+ // (`sourceRoots`, or the evidence chain) and decides, from code facts only, which side of that line they sit on:
6
+ //
7
+ // attacker-reachable a request, a socket, a header, a cookie, a remote response: the peer is not the operator
8
+ // operator-controlled command-line arguments and environment variables (the person who starts the process chooses them), and, in a
9
+ // command-line program, standard input and the files it is pointed at: no boundary is crossed. The finding
10
+ // is lowered to `info`, never dropped
11
+ // api-contract the value is a parameter of an exported function that nothing in the analysed code calls, and the function
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`
14
+ // entry-parameter a parameter of such a function used inside a larger value the function builds: its own construction, kept
15
+ // data-input standard input or a file's content in a service or a library: data the program processes, kept
16
+ // unattributed the engine recorded no origin: kept
17
+ //
18
+ // A command-line program is one reached only from `main`s whose reachable modules import no server framework or socket library
19
+ // (`entryRole` in haskell-ir.js). The role is a fact about the call graph, not about a name or a path.
20
+ //
21
+ // Nothing here reads a comment, a name or a path: the inputs are the recorded origins, the lowered expression at the sink and the
22
+ // parameter annotations the IR derived from the signature.
23
+
24
+ import { findSites, identNames } from './haskell-guards.js';
25
+
26
+ export const TRUST_VERDICTS = Object.freeze(['attacker-reachable', 'operator-controlled', 'api-contract', 'entry-parameter', 'data-input', 'unattributed']);
27
+
28
+ const OPERATOR = new Set(['cli', 'env']);
29
+ const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network']);
30
+ const DATA = new Set(['stdin', 'file-read']);
31
+ 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)$/;
34
+ const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
35
+ // list plumbing that only lays the parameters out as text (`unwords args`, `concatMap (' ':) args`): no fixed words are added
36
+ const LIST_LAYOUT = /(?:^|\.)(?:unwords|unlines|intercalate|concatMap|concat|map|fmap)$/;
37
+ const SPEC_CTORS = new Set(['System.Process.shell', 'System.Process.proc', 'System.Process.Typed.shell', 'System.Process.Typed.proc']);
38
+ const hasAlnum = (v) => /[A-Za-z0-9]/.test(String(v));
39
+
40
+ function rootsOf(f) {
41
+ // A hop through a function's return that names no origin behind it (`interproc`, `param`) is not an origin.
42
+ if (Array.isArray(f.sourceRoots) && f.sourceRoots.length) return f.sourceRoots.filter((r) => r && r.provenance !== 'interproc' && r.provenance !== 'param').map((r) => ({ label: r.label, provenance: r.provenance || null, ...(r.file ? { file: r.file } : {}), ...(Number.isInteger(r.line) ? { line: r.line } : {}) }));
43
+ const out = [];
44
+ for (const c of f.chain || []) {
45
+ if (!c || !c.label || c.kind === 'sink') continue;
46
+ if (c.provenance === 'interproc' || c.provenance === 'param') continue;
47
+ out.push({ label: c.label, provenance: c.provenance || null });
48
+ }
49
+ if (!out.length && f.source && f.source.label && !/^(?:interproc-return:|parameter )/.test(f.source.label)) out.push({ label: f.source.label, provenance: f.sourceProvenance || null });
50
+ return out;
51
+ }
52
+
53
+ const classOf = (r) => {
54
+ if (r.provenance && OPERATOR.has(r.provenance)) return 'operator';
55
+ if (r.provenance && EXTERNAL.has(r.provenance)) return 'external';
56
+ if (r.provenance && DATA.has(r.provenance)) return 'data';
57
+ if (WEAK_PARAM_LABEL.test(String(r.label || ''))) return 'weak-param';
58
+ return 'other';
59
+ };
60
+
61
+ // The expression a sink was handed, with the conversions that only change its type (`unpack`, `fromString`, `show`) peeled off.
62
+ function peel(e, depth = 0) {
63
+ while (e && e.kind === 'call' && depth++ < 8 && typeof e.callee === 'string' && CONVERSIONS.test(e.callee) && (e.args || []).length === 1) e = e.args[0];
64
+ return e;
65
+ }
66
+
67
+ // Flatten a concatenation / path join into its operands, left to right.
68
+ function operandsOf(e, out = [], depth = 0, resolve = null) {
69
+ e = peel(e);
70
+ if (!e || depth > 12) { out.push(e); return out; }
71
+ if (resolve && e.kind === 'ident') { const r = resolve(e); if (r && r !== e) return operandsOf(r, out, depth + 1, resolve); }
72
+ if (e.kind === 'binary' && e.hs && e.hs.concat) { operandsOf(e.left, out, depth + 1, resolve); operandsOf(e.right, out, depth + 1, resolve); return out; }
73
+ if (e.kind === 'binary' && (e.op === '</>' || e.op === '<>' || e.op === '++')) { operandsOf(e.left, out, depth + 1, resolve); operandsOf(e.right, out, depth + 1, resolve); return out; }
74
+ if (e.kind === 'call' && typeof e.callee === 'string' && PATH_JOIN.test(e.callee) && (e.args || []).length === 2) { operandsOf(e.args[0], out, depth + 1, resolve); operandsOf(e.args[1], out, depth + 1, resolve); return out; }
75
+ out.push(e);
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Is the sink argument a value the function merely passes on? True when every non-literal operand is one of `params` (or list plumbing over
81
+ * them) and either (a) nothing in the value is fixed text that names something (only separators), or (b) for a path, the parameter is the
82
+ * LEFTMOST operand, so it chooses the whole location and the fixed tail is only a file name inside it.
83
+ */
84
+ export function isPassThrough(arg, params, family, resolve = null) {
85
+ const ops = operandsOf(arg, [], 0, resolve);
86
+ if (!ops.length || ops.some((o) => !o)) return false;
87
+ const dyn = ops.filter((o) => o.kind !== 'literal');
88
+ if (!dyn.length) return false;
89
+ // a dynamic operand is acceptable when it is made only of parameters and separators (a list laid out as text, a member of a parameter)
90
+ const ok = (o, depth = 0) => {
91
+ if (!o || depth > 10) return false;
92
+ if (o.kind === 'literal') return !hasAlnum(o.value);
93
+ if (o.kind === 'ident') return params.has(o.name);
94
+ if (o.kind === 'member') return ok(o.object, depth + 1);
95
+ if (o.kind === 'array') return o.elements.length > 0 && o.elements.every((x) => ok(x, depth + 1));
96
+ if (o.kind === 'call' && typeof o.callee === 'string' && LIST_LAYOUT.test(o.callee)) return (o.args || []).length > 0 && o.args.every((x) => ok(x, depth + 1));
97
+ return false;
98
+ };
99
+ if (!dyn.every((o) => ok(o))) return false;
100
+ const namedLiteral = ops.some((o) => o.kind === 'literal' && hasAlnum(o.value));
101
+ if (!namedLiteral) return true;
102
+ return family === 'path' && ops[0].kind === 'ident' && params.has(ops[0].name);
103
+ }
104
+
105
+ function weakParamsOf(fn) {
106
+ const out = new Set();
107
+ for (const a of fn.paramAnnotations || []) if (a && a.decorator === 'hs:caller-controlled' && a.name) out.add(a.name);
108
+ return out;
109
+ }
110
+
111
+ // A local name bound exactly once (`let cmd = exe <> " " <> args`) stands for its definition when the sink argument is judged.
112
+ function localResolver(fn) {
113
+ const defs = new Map();
114
+ for (const n of Object.values(fn.cfg.nodes)) if (n.kind === 'assign' && typeof n.target === 'string' && n.source) defs.set(n.target, [...(defs.get(n.target) || []), n.source]);
115
+ const params = new Set(Array.isArray(fn.params) ? fn.params : []);
116
+ return (e) => (e && e.kind === 'ident' && !params.has(e.name) && defs.has(e.name) && defs.get(e.name).length === 1 ? defs.get(e.name)[0] : e);
117
+ }
118
+
119
+ const fnByQid = (perFile, qid) => {
120
+ if (!qid) return null;
121
+ const file = String(qid).split('::')[0];
122
+ const ir = perFile && perFile[file];
123
+ return ir ? ir.functions.find((x) => x.qid === qid) || null : null;
124
+ };
125
+
126
+ // The role the IR gave the function the finding was found in: `cli` (reached only from mains that import no server framework), `service`, or
127
+ // `library` (no main reaches it, or the walk could not).
128
+ function roleOf(f, perFile) {
129
+ const fn = fnByQid(perFile, f._funcQid);
130
+ return (fn && fn.hs && fn.hs.entryRole) || 'library';
131
+ }
132
+
133
+ // The function that holds the sink call, and the sink's argument expressions there. A finding found while analysing a CALLER (its `_funcQid`) is
134
+ // located at the callee's line; the sink function is the one in the finding's file that has the call at that line.
135
+ function locateSink(perFile, f) {
136
+ const ir = perFile && perFile[f.file];
137
+ if (!ir || !f.callee) return null;
138
+ const direct = fnByQid(perFile, f._funcQid);
139
+ const candidates = direct && direct.file === f.file ? [direct, ...ir.functions.filter((x) => x !== direct)] : ir.functions;
140
+ for (const fn of candidates) {
141
+ const sites = findSites(fn, f.callee, f.line);
142
+ if (sites.length) return { fn, sites };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Is this finding a thin wrapper's pass-through? `weakParams` are the caller-controlled parameters of the function the engine analysed. When the
149
+ * sink sits in a callee, the callee must itself hand its own parameter on whole, and every call of it from the analysed function must pass
150
+ * values built only from that function's caller-controlled parameters (one hop; a longer chain is not judged).
151
+ */
152
+ function passThroughVerdict(perFile, f) {
153
+ if (!WRAPPER_FAMILIES.has(f.cwe)) return null;
154
+ const family = f.cwe === 'CWE-22' ? 'path' : 'cmd';
155
+ const sink = locateSink(perFile, f);
156
+ if (!sink) return null;
157
+ const argIdx = Number.isInteger(f.argIndex) ? f.argIndex : 0;
158
+ let args = sink.sites.map((s) => (s.call.args || [])[argIdx]).filter(Boolean);
159
+ // A process is started from a SPEC value (`createProcess ((shell cmd) { cwd = d })`): what decides the verdict is what the spec constructor
160
+ // was handed, so the constructor's own arguments are judged instead of the record update around it.
161
+ const specArgs = [];
162
+ const findSpecs = (e, d = 0) => {
163
+ if (!e || typeof e !== 'object' || d > 20) return;
164
+ if (e.kind === 'call' && typeof e.callee === 'string' && SPEC_CTORS.has(e.callee)) { for (const a of (e.args || []).slice(0, 2)) if (a) specArgs.push(a); return; }
165
+ for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') findSpecs(e[k], d + 1);
166
+ for (const k of ['args', 'elements', 'branches']) if (Array.isArray(e[k])) e[k].forEach((x) => findSpecs(x, d + 1));
167
+ if (Array.isArray(e.props)) for (const p of e.props) findSpecs(p && p.value, d + 1);
168
+ };
169
+ for (const a of args) findSpecs(a);
170
+ if (specArgs.length) args = specArgs;
171
+ if (!args.length) return null;
172
+ const analysed = fnByQid(perFile, f._funcQid);
173
+ if (!analysed) return null;
174
+ if (sink.fn.qid === analysed.qid) {
175
+ const weak = weakParamsOf(analysed);
176
+ if (!weak.size) return null;
177
+ const own = new Set(Array.isArray(analysed.params) ? analysed.params : []);
178
+ 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;
180
+ }
181
+ // the sink is in a callee
182
+ const own = new Set(Array.isArray(sink.fn.params) ? sink.fn.params : []);
183
+ const ownVars = [...new Set(args.flatMap((a) => [...identNames(a)]))];
184
+ if (!own.size || !ownVars.length || !ownVars.every((v) => own.has(v)) || !args.every((a) => isPassThrough(a, own, family))) return null;
185
+ const weak = weakParamsOf(analysed);
186
+ if (!weak.size) return null;
187
+ const calls = findCallsTo(analysed, sink.fn);
188
+ if (!calls.length) return null;
189
+ const used = new Set(ownVars);
190
+ for (const call of calls) {
191
+ for (let i = 0; i < (call.args || []).length; i++) {
192
+ const p = sink.fn.params[i];
193
+ if (!p || !used.has(p)) continue;
194
+ const a = call.args[i];
195
+ if (!a) return null;
196
+ if (a.kind === 'literal') continue;
197
+ if (!isPassThrough(a, weak, family)) return null;
198
+ }
199
+ }
200
+ return 'chain';
201
+ }
202
+
203
+ function findCallsTo(fn, target) {
204
+ const out = [];
205
+ const visit = (e, d) => {
206
+ if (!e || typeof e !== 'object' || d > 50) return;
207
+ if (e.kind === 'call' && e.callee === target.name) out.push(e);
208
+ for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') visit(e[k], d + 1);
209
+ for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) for (const x of e[k]) visit(x, d + 1);
210
+ if (Array.isArray(e.props)) for (const p of e.props) visit(p && p.value, d + 1);
211
+ };
212
+ for (const n of Object.values(fn.cfg.nodes)) {
213
+ if (n.kind === 'call' && n.callee === target.name) out.push({ kind: 'call', callee: n.callee, args: n.args || [] });
214
+ for (const k of ['source', 'value', 'cond']) if (n[k]) visit(n[k], 0);
215
+ if (Array.isArray(n.args)) for (const a of n.args) visit(a, 0);
216
+ }
217
+ return out;
218
+ }
219
+
220
+ /**
221
+ * Stamps `trustBoundary` on every Haskell IR-TAINT finding and lowers to `info` the verdicts that name no boundary crossed
222
+ * (`operator-controlled`, `api-contract`). Mutates the findings; returns counts.
223
+ */
224
+ export function applyTrustBoundary(findings, perFile) {
225
+ const stats = { attackerReachable: 0, operatorControlled: 0, apiContract: 0, entryParameter: 0, dataInput: 0, unattributed: 0 };
226
+ for (const f of findings || []) {
227
+ if (!f || f.parser !== 'IR-TAINT' || !/\.(?:l?hs|hsc)$/i.test(String(f.file || ''))) continue;
228
+ const roots = rootsOf(f);
229
+ const classes = new Set(roots.map(classOf));
230
+ const role = roleOf(f, perFile);
231
+ let verdict;
232
+ let reason;
233
+ let inferred = false;
234
+ if (classes.has('external')) { verdict = 'attacker-reachable'; reason = 'a request, socket, header, cookie or remote response supplies the value'; }
235
+ else if (roots.length && [...classes].every((c) => c === 'operator')) {
236
+ verdict = 'operator-controlled';
237
+ reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
238
+ } else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance))) {
239
+ verdict = 'operator-controlled';
240
+ 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';
241
+ } else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
242
+ else if (classes.has('weak-param')) {
243
+ verdict = 'entry-parameter';
244
+ reason = 'a parameter of an exported function that nothing in the analysed code calls';
245
+ const pt = passThroughVerdict(perFile, f);
246
+ if (pt) { verdict = 'api-contract'; reason = 'the function hands its parameter on whole (or as the root of a fixed tail) to a path or command operation: a thin wrapper, whose caller chose what to read or run'; }
247
+ } else if (!roots.length && role === 'cli') {
248
+ // No origin was recorded. Every external origin the model knows (a request, a socket, a remote response) is recorded when it is read, so
249
+ // an empty record in a command-line program means the value came from the operator's side: its arguments, environment, files or
250
+ // configuration reached the sink through a path the engine could not attribute. Stated as an inference on the finding.
251
+ verdict = 'operator-controlled';
252
+ reason = 'no origin was recorded for the tainted value and the function belongs to a command-line program; no request, socket or remote-response read feeds it (inferred, not proven)';
253
+ inferred = true;
254
+ } else { verdict = 'unattributed'; reason = roots.length ? 'the recorded origins are not classified' : 'the engine recorded no origin for the value'; }
255
+
256
+ f.trustBoundary = { verdict, reason, role, ...(inferred ? { inferred: true } : {}), roots: roots.slice(0, 5) };
257
+ const key = verdict.replace(/-(\w)/g, (_, c) => c.toUpperCase());
258
+ stats[key] = (stats[key] || 0) + 1;
259
+ if ((verdict === 'operator-controlled' || verdict === 'api-contract') && !f._trustLowered) {
260
+ f._trustLowered = true;
261
+ f.originalSeverity = f.originalSeverity || f.severity;
262
+ f.severity = 'info';
263
+ f.controls = [...(f.controls || []), { kind: verdict === 'operator-controlled' ? 'operator-controlled-input' : 'api-contract-wrapper', evidenceKind: 'source' }];
264
+ f.guardNote = { kind: verdict, reason };
265
+ }
266
+ }
267
+ return stats;
268
+ }
@@ -19,6 +19,7 @@
19
19
  // is recorded as `unknown` coverage, never as protected and never omitted.
20
20
 
21
21
  import { buildHaskellIR, lowerHandlerExpr, resolveName } from './haskell-ir.js';
22
+ import { codeOfLine } from './haskell-comments.js';
22
23
  import { qualifyAmbiguous, HS_WEB_FRAMEWORKS, HS_WEB_MODEL_VERSION, modelStatus } from './haskell-models.js';
23
24
 
24
25
  export const HS_WEB_RULES_VERSION = 'haskell-web-rules/1';
@@ -1055,7 +1056,7 @@ function emitFindings(ctx, result, yesodAuthz) {
1055
1056
  const vs = result.versionStatus[R.framework];
1056
1057
  const untested = vs && vs.status !== 'tested';
1057
1058
  if (untested) extra = { ...extra, confidence: (extra.confidence ?? 0.75) * 0.8, uncertainty: [...(extra.uncertainty || []), { kind: 'untested-model', detail: `${vs.package} ${vs.version || '(version unknown)'} is not in the tested set (${vs.tested.join(', ')}); the route model is applied best-effort` }] };
1058
- const snip = (text.split('\n')[line - 1] || '').trim().slice(0, 240);
1059
+ const snip = codeOfLine(text.split('\n')[line - 1] || '').slice(0, 240);
1059
1060
  out.push({
1060
1061
  id: `hs-web:${rule}:${R.file}:${line}:${R.method}:${R.path}`, severity: extra.severity || r.severity, file: R.file, line, vuln: r.vuln, cwe: r.cwe,
1061
1062
  description: `${r.vuln}: ${R.method} ${R.path} (${R.framework})${extra.detail ? ` - ${extra.detail}` : ''}`,
@@ -363,7 +363,9 @@ export function scanCloudIam(fp, raw) {
363
363
  if (!raw || raw.length > 500_000) return [];
364
364
  const out = [];
365
365
  const seen = new Set();
366
- const isJson = /\.json$/i.test(fp) || raw.trimStart().startsWith('{');
366
+ // A file is JSON by its extension or because it OPENS like a JSON object: a brace, then a quoted key or a closing brace. A bare leading `{` is not
367
+ // enough: a Haskell module that starts with a `{-# LANGUAGE ... #-}` pragma (or a `{- ... -}` comment) begins with one too, and its prose was read as policy.
368
+ const isJson = /\.json$/i.test(fp) || /^\s*\{\s*(?:"|\})/.test(raw.slice(0, 4096));
367
369
  const isYaml = /\.(?:yaml|yml)$/i.test(fp);
368
370
  const isBicep = /\.bicep$/i.test(fp);
369
371
  const isTf = /\.tf$/i.test(fp);