@clear-capabilities/agentic-security-scanner 0.186.2 → 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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/1602.index.js +275 -19
  3. package/dist/{4d06c8944ae121d7085c.json → 92f78fd3e2dbe30fb58d.json} +5 -5
  4. package/dist/9560.index.js +61 -4
  5. package/dist/agentic-security.mjs +2 -2
  6. package/dist/agentic-security.mjs.sha256 +1 -1
  7. package/package.json +5 -5
  8. package/src/capabilities/decide.js +7 -1
  9. package/src/dataflow/CLAUDE.md +15 -0
  10. package/src/dataflow/catalog-haskell.js +7 -1
  11. package/src/dataflow/catalog.js +14 -0
  12. package/src/dataflow/engine.js +147 -10
  13. package/src/dataflow/privacy-catalog-haskell.js +2 -1
  14. package/src/engine.js +11 -3
  15. package/src/language/CLAUDE.md +26 -0
  16. package/src/language/compliance-map.js +6 -0
  17. package/src/language/context.js +20 -0
  18. package/src/language/haskell-comments.js +65 -0
  19. package/src/language/haskell-disclosure.js +16 -4
  20. package/src/language/haskell-findings.js +2 -1
  21. package/src/language/haskell-fix.js +118 -2
  22. package/src/language/haskell-guards.js +132 -5
  23. package/src/language/haskell-ir.js +228 -13
  24. package/src/language/haskell-models.js +108 -8
  25. package/src/language/haskell-security-rules.js +223 -41
  26. package/src/language/haskell-syntax.js +10 -1
  27. package/src/language/haskell-trust.js +268 -0
  28. package/src/language/haskell-web.js +46 -9
  29. package/src/language/support-registry.js +3 -2
  30. package/src/mcp/tools.js +54 -3
  31. package/src/posture/attack-taxonomy.js +10 -0
  32. package/src/posture/entrypoint-inventory.js +6 -2
  33. package/src/posture/proof-fabric/generate-haskell.js +266 -17
  34. package/src/posture/proof-fabric/generate.js +10 -3
  35. package/src/posture/proof-fabric/oracles.js +21 -0
  36. package/src/posture/stable-id.js +2 -1
  37. package/src/report/index.js +3 -0
  38. package/src/sast/cloud-iam.js +3 -1
  39. package/dist/2066.index.js +0 -851
@@ -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
 
@@ -52,8 +53,12 @@ const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.
52
53
  const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
53
54
  const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
54
55
  const IV_CALLS = new Set(['Crypto.Cipher.Types.makeIV', 'Crypto.Cipher.ChaChaPoly1305.nonce12', 'Crypto.Cipher.ChaChaPoly1305.nonce8']);
55
- 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']);
56
- const BODY_CALLS = new Set(['Network.Wai.strictRequestBody', 'Network.Wai.lazyRequestBody', 'Web.Scotty.body', 'Web.Scotty.Trans.body']);
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
+ const BODY_CALLS = new Set(['Network.Wai.strictRequestBody', 'Network.Wai.lazyRequestBody', 'Web.Scotty.body', 'Web.Scotty.Trans.body', 'Network.Wai.Conduit.sourceRequestBody']);
58
+ const SOCKET_READS = new Set(['Network.Socket.ByteString.recv', 'Network.Socket.ByteString.recvFrom', 'Network.Socket.ByteString.Lazy.recv']);
59
+ const SET_COOKIE_HEADER = new Set(['Network.HTTP.Types.Header.hSetCookie', 'Network.HTTP.Types.hSetCookie']);
60
+ const REQUEST_LENGTH = new Set(['Network.Wai.requestBodyLength']);
61
+ const RELATIONAL = new Set(['<', '<=', '>', '>=']);
57
62
  const PBKDF2_CALLS = new Set(['Crypto.KDF.PBKDF2.generate', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA1', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA256', 'Crypto.KDF.PBKDF2.fastPBKDF2_SHA512']);
58
63
  const STRONG_KDF = /^(?:Crypto\.KDF\.BCrypt\.|Crypto\.Argon2\.|Crypto\.Scrypt\.|Crypto\.KDF\.Scrypt\.|Crypto\.PasswordStore\.|Crypto\.BCrypt\.)/;
59
64
  const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'System.IO.Unsafe.unsafeInterleaveIO': 'unsafeInterleaveIO', 'System.IO.Unsafe.unsafeDupablePerformIO': 'unsafeDupablePerformIO', 'Unsafe.Coerce.unsafeCoerce': 'unsafeCoerce' };
@@ -64,20 +69,30 @@ const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'S
64
69
  const SECRET_KEY_CALLS = new Set(['Web.JWT.hmacSecret', 'Web.JWT.secret', 'Servant.Auth.Server.fromSecret', 'Web.ClientSession.initKey']);
65
70
  const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(e.value))) || (e.kind === 'ident' && /(?:^|\.)True$/.test(e.name || '')));
66
71
  const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
67
- 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;
68
75
  // Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
69
76
  const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
70
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)) };
71
78
  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']);
72
- const BOUNDING = /(?:^|\.)(?:take|hGetSome|hGet|splitAt|limit|limitedRead)$/;
79
+ const BOUNDING = /(?:^|\.)(?:take|takeE|isolate|hGetSome|hGet|splitAt|limit|limitedRead)$/;
73
80
  // `replicateM n getLine` reads n lines: the count is an allocation (and an input loop) sized by whoever supplied n.
74
81
  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']);
75
82
  const CLAMPS = /^(?:(?:Prelude|Data\.Ord)\.)?(?:min|clamp)$/;
76
- const PARTIALS = new Set(['read', 'Prelude.read', 'head', 'Prelude.head', 'Data.List.head', 'tail', 'Prelude.tail', 'Data.List.tail', 'last', 'Prelude.last', 'Data.List.last', 'init', 'Prelude.init', 'Data.List.init', 'Data.Maybe.fromJust', 'fromJust']);
83
+ const PARTIAL_NAMES = ['read', 'head', 'tail', 'last', 'init', 'maximum', 'minimum', 'foldr1', 'foldl1', 'maximumBy', 'minimumBy', 'toEnum', 'succ', 'pred', 'fromJust', 'cycle'];
84
+ const PARTIAL_MODULES = ['Prelude', 'Data.List', 'Data.Foldable', 'Data.Maybe'];
85
+ const PARTIALS = new Set([...PARTIAL_NAMES, ...PARTIAL_NAMES.flatMap((n) => PARTIAL_MODULES.map((m) => `${m}.${n}`)),
86
+ ...['head', 'last', 'tail', 'init', 'maximum', 'minimum', 'index'].map((n) => `Data.Text.${n}`),
87
+ // the Map/Set lookups and extremum selectors that raise on a missing key or an empty container
88
+ ...['Data.Map', 'Data.Map.Strict', 'Data.HashMap.Strict', 'Data.IntMap', 'Data.IntMap.Strict'].map((m) => `${m}.!`),
89
+ ...['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
+ // 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
+ const EMPTINESS_CHECK = /(?:^|\.)(?:null|notNull|isEmpty|length|size|isJust|isNothing|member|notMember|lookup|elem|notElem|nonEmpty|<match>)$/;
77
92
  const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
78
93
 
79
94
  // What makes a bound variable security-shaped, and what makes a value sensitive for logging.
80
- 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;
81
96
  const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
82
97
  const LOW_ENTROPY = /(?:pass(?:word|wd|phrase)?|pwd|ssn|creditcard|cardnumber|cvv|\bpin\b)/i;
83
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;
@@ -89,6 +104,7 @@ const kids = (e) => {
89
104
  for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) out.push(...e[k]);
90
105
  if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) out.push(p.value);
91
106
  if (e.hs && e.hs.selector && typeof e.hs.selector === 'object') out.push(e.hs.selector); // `lookup key t`: the key decides the outcome
107
+ if (e.hs && e.hs.inner && typeof e.hs.inner === 'object') out.push(e.hs.inner); // `read s :: Int`: a number to the taint engine, still an expression here
92
108
  return out;
93
109
  };
94
110
 
@@ -115,12 +131,103 @@ const isLiteralLike = (e, depth = 0) => {
115
131
  return false;
116
132
  };
117
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
+
118
143
  function fnNodes(fn) { return Object.values(fn.cfg.nodes); }
119
144
 
145
+ // The text a concatenation spells out, in order, skipping the parts that are not literals (`"session_id=" <> sid <> "; Path=/"` -> `session_id=; Path=/`).
146
+ function literalTextOf(e, depth = 0) {
147
+ if (!e || depth > 12) return '';
148
+ if (e.kind === 'literal' && typeof e.value === 'string') return e.value;
149
+ if (e.kind === 'binary' && e.hs && e.hs.concat) return literalTextOf(e.left, depth + 1) + literalTextOf(e.right, depth + 1);
150
+ if (e.kind === 'call' && /(?:^|\.)(?:pack|fromString|encodeUtf8|fromStrict|toStrict)$/.test(e.callee || '') && e.args && e.args.length === 1) return literalTextOf(e.args[0], depth + 1);
151
+ return '';
152
+ }
153
+
120
154
  function snippetOf(text, line) {
121
155
  if (!text || !Number.isInteger(line) || line < 1) return '';
122
156
  const l = text.split('\n')[line - 1];
123
- 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;
124
231
  }
125
232
 
126
233
  /**
@@ -164,6 +271,7 @@ export function analyzeHaskellRules(files, opts = {}) {
164
271
  };
165
272
 
166
273
  for (const [file, fir] of Object.entries(ir.perFile)) {
274
+ const importsNetwork = (fir.imports || []).some((im) => /^Network(?:\.|$)/.test(im.module));
167
275
  // imports of obsolete primitives (type-directed use is not resolved: disclosed on the finding)
168
276
  for (const imp of fir.imports || []) {
169
277
  if (WEAK_CIPHER_MODULES.has(imp.module)) {
@@ -177,7 +285,14 @@ export function analyzeHaskellRules(files, opts = {}) {
177
285
 
178
286
  const fnBase = fn.name.replace(/^.*\./, '');
179
287
  // text-typed parameters of an exported function, and every local value derived from them
180
- const derivedText = new Set((fn.hs && fn.hs.textParams) || []);
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 || []) : [])]);
181
296
  if (derivedText.size) {
182
297
  for (let round = 0; round < 8; round++) {
183
298
  let grew = false;
@@ -190,6 +305,31 @@ export function analyzeHaskellRules(files, opts = {}) {
190
305
  const fnReturns = fnNodes(fn).filter((n) => n.kind === 'return' && n.value);
191
306
  const bounded = fnNodes(fn).some((n) => [n.value, n.source, ...(n.args || [])].some((e) => e && callsIn(e).some((c) => BOUNDING.test(c.callee))));
192
307
 
308
+ // Values a condition in this function tests for emptiness or presence (`null ws`, `M.member k t`, `xs /= []`, a pattern match on it) and,
309
+ // transitively, the values they were built from: a partial call on one of them is the other branch of that test.
310
+ const assignSources = new Map();
311
+ 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)]);
312
+ 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; };
313
+ const testedVars = new Set();
314
+ for (const n of fnNodes(fn)) {
315
+ if (n.kind !== 'if' || !n.cond) continue;
316
+ for (const x of walk(n.cond)) {
317
+ if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) for (const nm of names(x)) testedVars.add(nm);
318
+ 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);
319
+ }
320
+ }
321
+ const testedAll = withOrigins(testedVars);
322
+ 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; };
323
+
324
+ // 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.
325
+ const hasSizeTest = fnNodes(fn).some((n) => n.kind === 'if' && n.cond && [...walk(n.cond)].some((x) => x.kind === 'binary' && RELATIONAL.has(x.op)));
326
+ 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)))));
327
+ const sizeChecked = hasSizeTest && readsRequestLength;
328
+ // One predictable-random finding per function: a clock reading that seeds a generator in a token-shaped function is a single flaw,
329
+ // however many of its steps (the clock, the seed, the generator) the rule can see.
330
+ let randomEmitted = false;
331
+ const emitRandom = (line, extra) => { if (randomEmitted) return; randomEmitted = true; emit('hs-predictable-random', file, line, extra); };
332
+
193
333
  const seeds = (e) => { for (const n of names(e)) if (SENSITIVE.test(n.replace(/^\./, ''))) return n.replace(/^\./, ''); return null; };
194
334
 
195
335
  // pass over nodes in program order
@@ -205,12 +345,33 @@ export function analyzeHaskellRules(files, opts = {}) {
205
345
  if (e.kind === 'ident') {
206
346
  if (e.name === 'Crypto.Cipher.Types.nullIV') emit('hs-static-iv', file, node.line, { vuln: 'Static IV (nullIV)', detail: 'nullIV is a constant all-zero IV' });
207
347
  // a clock reading used as the value of a security-shaped function (a "nonce" that is the time)
208
- if (TIME_SOURCES.has(e.name) && SECURITY_FN.test(fnBase)) emit('hs-predictable-random', file, node.line, { vuln: 'Clock value used as a security value', detail: `${e.name} feeds \`${fnBase}\``, evidence: { function: fnBase, source: e.name } });
209
- if (INPUT_READS.has(e.name) && !bounded) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
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 } });
349
+ // a generator named as the whole value of a security-shaped action (`newCsrfToken = randomIO`): no call is written, the name is the call
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 } });
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 });
210
352
  continue;
211
353
  }
354
+ // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`
355
+ if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) {
356
+ const [k, v] = e.elements;
357
+ 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))));
358
+ if (isSetCookie && v) {
359
+ const text = literalTextOf(v);
360
+ const nm = /^\s*([^=;\s]+)\s*=/.exec(text);
361
+ const sessionLike = !nm || SESSION_COOKIE.test(nm[1]);
362
+ const secure = /;\s*Secure\b/i.test(text); const httpOnly = /;\s*HttpOnly\b/i.test(text);
363
+ // an attribute string that is not written here (a rendered cookie value) cannot be judged: only a cookie whose text is visible is
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 } });
366
+ }
367
+ }
212
368
  if (e.kind === 'object' && e.hs && e.hs.update && Array.isArray(e.props)) {
213
369
  const prop = (k) => e.props.find((p) => p && p.key === k);
370
+ // servant-auth: `cookieIsSecure = NotSecure` sends the session cookie over plain HTTP too
371
+ const secureSetting = prop('cookieIsSecure');
372
+ if (secureSetting && secureSetting.value && /(?:^|\.)NotSecure$/.test(String(secureSetting.value.value || secureSetting.value.name || '')) && (fir.imports || []).some((im) => im.module === 'Servant.Auth.Server')) {
373
+ emit('hs-insecure-cookie', file, e.line || node.line, { vuln: 'Session cookie sent without the Secure attribute', detail: 'cookieIsSecure = NotSecure', evidence: { setting: 'cookieIsSecure', value: 'NotSecure' } });
374
+ }
214
375
  if (prop('setCookieName')) {
215
376
  const nm = prop('setCookieName').value;
216
377
  const sessionLike = !nm || nm.kind !== 'literal' || SESSION_COOKIE.test(String(nm.value));
@@ -233,7 +394,7 @@ export function analyzeHaskellRules(files, opts = {}) {
233
394
  if (e.hs.con === 'VerifyNone' && e.kind === 'literal' && importsFrom(['OpenSSL.Session'])) emit('hs-tls-validation-disabled', file, e.line || node.line, { detail: 'OpenSSL verification mode VerifyNone', evidence: { mode: 'VerifyNone' } });
234
395
  }
235
396
  // `xs !! n` raises on a short list: a partial function, written as an operator, applied to caller-supplied text
236
- if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left)) {
397
+ if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left) && !isTested([e.left])) {
237
398
  emit('hs-partial-function-on-input', file, e.line || node.line, { detail: '!! on a caller-supplied list', evidence: { partial: '!!' } });
238
399
  continue;
239
400
  }
@@ -242,13 +403,32 @@ export function analyzeHaskellRules(files, opts = {}) {
242
403
  const line = e.line || node.line;
243
404
  const args = e.args || [];
244
405
 
406
+ // scotty-cookie's helpers build a cookie that carries neither Secure nor HttpOnly
407
+ if ((c === 'Web.Scotty.Cookie.setSimpleCookie' || c === 'Web.Scotty.Cookie.makeSimpleCookie') && args[0]) {
408
+ const nm = args[0];
409
+ 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 } });
410
+ }
411
+ // a socket read repeated by a recursive loop, with no size test anywhere in it
412
+ if (SOCKET_READS.has(c) && !hasSizeTest && !bounded && fn.calls.some((x) => x.callee === fn.name && x.hs && x.hs.target === fn.qid)) {
413
+ emit('hs-unbounded-input-read', file, line, { detail: `${c} is repeated by a loop that never compares the amount read with a limit`, confidence: 0.6, evidence: { read: c, loop: fnBase } });
414
+ }
245
415
  // TLS certificate validation switched off
246
416
  // a signing/encryption secret that is a constant in the source
247
417
  if (SECRET_KEY_CALLS.has(c) && args[0] && isLiteralLike(args[0]) && !(args[0].kind === 'literal' && String(args[0].value).length < 8)) emit('hs-hardcoded-secret', file, line, { detail: `${c} given a constant`, confidence: 0.85, evidence: { call: c } });
248
418
  // weak hash
249
419
  if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
250
420
  const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
251
- 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
+ }
252
432
  }
253
433
  // password hashed with a fast hash
254
434
  if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
@@ -264,23 +444,23 @@ export function analyzeHaskellRules(files, opts = {}) {
264
444
  if (IV_CALLS.has(c) && args[0] && isLiteralLike(args[0])) emit('hs-static-iv', file, line, { detail: `${c} of a constant value` });
265
445
  // randomness bound to a security-shaped name
266
446
  if (RANDOM_CALLS.has(c) && node.kind === 'assign' && SECURITY_VAR.test(node.target || '')) {
267
- emit('hs-predictable-random', file, line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
447
+ emitRandom(line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
268
448
  }
269
449
  if (RANDOM_CALLS.has(c) && SECURITY_FN.test(fnBase) && node.kind !== 'assign') {
270
- emit('hs-predictable-random', file, line, { detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
450
+ emitRandom(line, { detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
271
451
  }
272
- if (TIME_SOURCES.has(c) && SECURITY_FN.test(fnBase)) emit('hs-predictable-random', file, line, { vuln: 'Clock value used as a security value', detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
273
- if (INPUT_READS.has(c) && !bounded) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
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 } });
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 });
274
454
  // an allocation whose size is parsed caller text and is not clamped
275
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))) {
276
456
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
277
457
  }
278
458
  // a partial function applied to caller text: one malformed value raises an uncaught exception
279
- if (PARTIALS.has(c) && args.some((a) => mentionsText(a))) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied text`, evidence: { partial: c } });
459
+ if (PARTIALS.has(c) && args.some((a) => mentionsText(a)) && !isTested(args)) emit('hs-partial-function-on-input', file, line, { detail: `${c} on caller-supplied input`, evidence: { partial: c } });
280
460
  // a session/token cookie assembled without HttpOnly or Secure
281
461
  if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
282
462
  // unbounded body
283
- if (BODY_CALLS.has(c)) {
463
+ if (BODY_CALLS.has(c) && !sizeChecked && !(c === 'Network.Wai.Conduit.sourceRequestBody' && bounded)) {
284
464
  if (requestLimit) {
285
465
  controls.push({ kind: 'request-size-limit', file: requestLimit.file, line: requestLimit.line, appliesTo: `${file}:${line}`, verified: false });
286
466
  emit('hs-unbounded-request-body', file, line, { severity: 'low', confidence: 0.4, detail: c, uncertainty: [{ kind: 'unresolved-import', detail: `a request-size-limit middleware exists (${requestLimit.file}:${requestLimit.line}); that it wraps this handler is not verified` }] });
@@ -293,7 +473,7 @@ export function analyzeHaskellRules(files, opts = {}) {
293
473
  if (yes) emit('hs-xml-entity-substitution', file, line, { detail: 'withSubstDTDEntities yes' });
294
474
  }
295
475
  // boundaries
296
- if (UNSAFE_CALLS[c]) {
476
+ if (Object.hasOwn(UNSAFE_CALLS, c)) {
297
477
  const b = UNSAFE_CALLS[c];
298
478
  emit('hs-unsafe-io-boundary', file, line, { vuln: `Unsafe boundary: ${b}`, confidence: 0.5, modelNote: BOUNDARIES[b], detail: BOUNDARIES[b] });
299
479
  }
@@ -326,13 +506,17 @@ export function analyzeHaskellRules(files, opts = {}) {
326
506
  if (e.kind !== 'call' || typeof e.callee !== 'string') continue;
327
507
  const sink = HS_LOG_SINKS.find((s) => `${s.module}.${s.name}` === e.callee);
328
508
  if (!sink) continue;
329
- const arg = (e.args || [])[sink.argIndex];
330
- if (!arg) continue;
509
+ // a variadic log call (printf) interpolates every argument after the format into the message: each is judged on its own, so one
510
+ // redacted value never clears a credential passed beside it
511
+ const argList = sink.argFrom !== undefined ? (e.args || []).slice(sink.argFrom) : [(e.args || [])[sink.argIndex]];
512
+ if (!argList.length || argList.every((a) => !a)) continue;
331
513
  // a file append is a log only when its path says so (a log, audit or trace file)
332
514
  if (sink.channel === 'file') { const p = (e.args || [])[0]; if (!(p && p.kind === 'literal' && /(?:log|audit|trace|journal)/i.test(String(p.value)))) continue; }
333
- let hit = null;
334
- // a labelled value: a literal that names a credential ("token=", "password: ") followed by a non-literal operand
335
- if (!hit) {
515
+ 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; };
516
+ for (const arg of argList) {
517
+ if (!arg) continue;
518
+ let hit = null;
519
+ // a labelled value: a literal that names a credential ("token=", "password: ") followed by a non-literal operand
336
520
  for (const x of walk(arg)) {
337
521
  if (x.kind === 'binary' && x.hs && x.hs.concat && x.left && x.left.kind === 'literal' && typeof x.left.value === 'string' && /^\s*[\w .-]*(?:pass(?:word|wd|phrase)?|pwd|secret|token|api_?key|authorization|bearer|private_?key|session_?id)[\w .-]*\s*[=:]\s*$/i.test(x.left.value) && x.right && x.right.kind !== 'literal') {
338
522
  const lab = x.left.value.replace(/[=:\s]+$/, '').trim();
@@ -340,23 +524,21 @@ export function analyzeHaskellRules(files, opts = {}) {
340
524
  break;
341
525
  }
342
526
  }
343
- }
344
- for (const n of names(arg)) {
345
- if (hit) break;
346
- const bare = n.replace(/^\./, '');
347
- if (sensitive.has(n)) hit = { ...sensitive.get(n), var: n };
348
- 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 };
349
- if (hit) break;
350
- }
351
- // redaction applied inline at the call site
352
- let inlineCleared = false;
353
- for (const x of walk(arg)) if (x.kind === 'call' && typeof x.callee === 'string' && (REDACTOR.test(x.callee) || STRONG_KDF.test(x.callee) || x.callee === 'const')) inlineCleared = true;
354
- if (hit && !inlineCleared) {
355
- emit('hs-sensitive-logging', file, e.line || node.line, {
356
- severity: hit.hashed ? 'low' : 'high',
357
- detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
358
- evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
359
- });
527
+ for (const n of names(arg)) {
528
+ if (hit) break;
529
+ const bare = n.replace(/^\./, '');
530
+ if (sensitive.has(n)) hit = { ...sensitive.get(n), var: n };
531
+ 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 };
532
+ if (hit) break;
533
+ }
534
+ if (hit && !isRedacted(arg)) {
535
+ emit('hs-sensitive-logging', file, e.line || node.line, {
536
+ severity: hit.hashed ? 'low' : 'high',
537
+ detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
538
+ evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
539
+ });
540
+ break;
541
+ }
360
542
  }
361
543
  }
362
544
  }
@@ -14,6 +14,8 @@
14
14
  export class SyntaxBudgetError extends Error {}
15
15
 
16
16
  const KW = new Set(['case', 'class', 'data', 'default', 'deriving', 'do', 'else', 'foreign', 'if', 'import', 'in', 'infix', 'infixl', 'infixr', 'instance', 'let', 'module', 'newtype', 'of', 'then', 'type', 'where']);
17
+ // An annotation naming a plain number or a boolean, alone or inside Maybe / IO / an action monad: a value of that type cannot carry caller text.
18
+ const NUMERIC_TYPE = /^\(?\s*(?:(?:Maybe|IO|ActionM|Handler|Either\s+String)\s+)?\(?\s*(?:Int|Integer|Int(?:8|16|32|64)|Word|Word(?:8|16|32|64)|Double|Float|Natural|Bool|Scientific)\s*\)?\s*\)?$/;
17
19
  const RESERVED_OPS = new Set(['=', '|', '->', '<-', '::', '=>', '\\', '@', '~', '..']);
18
20
  const SYM = new Set('!#$%&*+./<=>?@\\^|-~:'.split(''));
19
21
  const OPEN = { '(': ')', '[': ']', '{': '}' };
@@ -401,7 +403,14 @@ export function parseSyntax(src, opts = {}) {
401
403
  if (++depth > maxDepth) { depth--; throw new SyntaxBudgetError('maxDepth'); }
402
404
  try {
403
405
  let e = expr(0);
404
- while (isRop(peek(), '::')) skipType();
406
+ while (isRop(peek(), '::')) {
407
+ const from = pos + 1;
408
+ skipType();
409
+ // `read s :: Int`, `readMaybe s :: Maybe Int`: the annotation is kept as a `typed` node when it names a plain number or boolean, which the IR
410
+ // lowers to a constant (no caller text survives such a type). Every other annotation is dropped as before.
411
+ const typeText = T.slice(from, pos).map((x) => x.v).join(' ');
412
+ if (NUMERIC_TYPE.test(typeText)) e = { t: 'typed', e, type: typeText, line: e && e.line };
413
+ }
405
414
  return e;
406
415
  } finally { depth--; }
407
416
  }