@clear-capabilities/agentic-security-scanner 0.186.1 → 0.187.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 (36) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/bin/agentic-security.js +42 -17
  3. package/dist/{2066.index.js → 1602.index.js} +477 -9
  4. package/dist/9560.index.js +62 -5
  5. package/dist/agentic-security.mjs +2 -2
  6. package/dist/agentic-security.mjs.sha256 +1 -1
  7. package/dist/{ca62ee93079be4c24eee.json → deabc47d0e27e21bf99b.json} +5 -5
  8. package/package.json +5 -5
  9. package/src/capabilities/decide.js +7 -1
  10. package/src/dataflow/CLAUDE.md +2 -0
  11. package/src/dataflow/catalog-haskell.js +7 -1
  12. package/src/dataflow/engine.js +16 -0
  13. package/src/dataflow/privacy-catalog-haskell.js +2 -1
  14. package/src/language/CLAUDE.md +19 -0
  15. package/src/language/compliance-map.js +6 -0
  16. package/src/language/context.js +20 -0
  17. package/src/language/haskell-disclosure.js +16 -4
  18. package/src/language/haskell-guards.js +130 -3
  19. package/src/language/haskell-ir.js +182 -10
  20. package/src/language/haskell-models.js +93 -4
  21. package/src/language/haskell-security-rules.js +112 -34
  22. package/src/language/haskell-syntax.js +10 -1
  23. package/src/language/haskell-web.js +44 -8
  24. package/src/language/support-registry.js +3 -2
  25. package/src/mcp/tools.js +54 -3
  26. package/src/posture/attack-taxonomy.js +10 -0
  27. package/src/posture/entrypoint-inventory.js +6 -2
  28. package/src/posture/proof-fabric/cli.js +1 -1
  29. package/src/posture/proof-fabric/container.js +2 -2
  30. package/src/posture/proof-fabric/generate-haskell.js +454 -0
  31. package/src/posture/proof-fabric/generate.js +15 -5
  32. package/src/posture/proof-fabric/oracles.js +21 -0
  33. package/src/posture/proof-fabric/reproducer.js +4 -2
  34. package/src/posture/proof-fabric/runner.js +4 -4
  35. package/src/posture/stable-id.js +2 -1
  36. package/src/report/index.js +3 -0
@@ -71,6 +71,12 @@ export const HS_PACKAGES = Object.freeze({
71
71
  'clientsession': { modules: ['Web.ClientSession'], tested: [] },
72
72
  connection: { modules: ['Network.Connection'], tested: [] },
73
73
  HsOpenSSL: { modules: ['OpenSSL.Session'], tested: [] },
74
+ 'wai-extra': { modules: ['Network.Wai.Parse', 'Network.Wai.Conduit'], tested: [] },
75
+ 'scotty-cookie': { modules: ['Web.Scotty.Cookie'], tested: [] },
76
+ 'http-types': { modules: ['Network.HTTP.Types', 'Network.HTTP.Types.Header'], tested: [] },
77
+ 'conduit-extra': { modules: ['Data.Conduit.Binary', 'Data.Conduit.Combinators'], tested: [] },
78
+ 'uri-parsing': { modules: ['Text.URI'], tested: [] },
79
+ 'snap-fileserve': { modules: ['Snap.Util.FileServe'], tested: [] },
74
80
  });
75
81
 
76
82
  // ── sources ─────────────────────────────────────────────────────────────────
@@ -131,6 +137,48 @@ src('Yesod', ['getRequest', 'rawRequestBody', 'runRequestBody', 'lookupFile', 'l
131
137
  // Scotty: the underlying WAI request
132
138
  src('Web.Scotty', ['request'], 'http-body', 'scotty WAI request', { package: 'scotty', framework: 'scotty' });
133
139
  src('Web.Scotty.Trans', ['request'], 'http-body', 'scotty WAI request', { package: 'scotty', framework: 'scotty' });
140
+ // The request accessors a handler really uses, beyond the first few: the Maybe/Either forms of the same parameter reads (scotty 0.20+),
141
+ // the raw body reader, cookies, and the JSON body helpers of yesod. Each reads a value the client controls.
142
+ for (const mod of ['Web.Scotty', 'Web.Scotty.Trans']) {
143
+ src(mod, ['queryParamMaybe', 'pathParamMaybe', 'formParamMaybe', 'queryParamEither', 'formParamEither', 'paramsEither'], 'url-param', 'scotty request parameter', { package: 'scotty', framework: 'scotty' });
144
+ src(mod, ['bodyReader', 'bodyRaw'], 'http-body', 'scotty request body', { package: 'scotty', framework: 'scotty' });
145
+ }
146
+ src('Web.Scotty.Cookie', ['getCookie', 'getCookies'], 'cookie', 'scotty request cookie', { package: 'scotty-cookie', framework: 'scotty' });
147
+ for (const mod of ['Yesod.Core', 'Yesod']) {
148
+ src(mod, ['parseJsonBody', 'requireJsonBody', 'parseCheckJsonBody', 'requireCheckJsonBody', 'lookupCookies', 'lookupHeaders'], 'http-body', 'yesod request body', { package: 'yesod', framework: 'yesod' });
149
+ src(mod, ['lookupBasicAuth'], 'header', 'yesod request header', { package: 'yesod', framework: 'yesod' });
150
+ }
151
+ src('Yesod.Core.Json', ['parseJsonBody', 'requireJsonBody', 'parseCheckJsonBody', 'requireCheckJsonBody'], 'http-body', 'yesod request body', { package: 'yesod', framework: 'yesod' });
152
+ // WAI: the multipart/form body parsers and the conduit request body, and the request's host and cookie headers
153
+ src('Network.Wai.Parse', ['parseRequestBody', 'parseRequestBodyEx'], 'http-body', 'WAI parsed request body', { package: 'wai-extra', framework: 'wai' });
154
+ src('Network.Wai.Conduit', ['sourceRequestBody'], 'http-body', 'WAI request body stream', { package: 'wai-extra', framework: 'wai' });
155
+
156
+ // A text parameter of an exported function that returns a web-framework HANDLER value (a Scotty action, a Yesod handler or widget, a Servant handler,
157
+ // a WAI application) receives what the framework took from the request: a route piece, a query value, a body field. That is a request source, so it
158
+ // is labelled as one. Every other exported function's text parameter stays the weaker `hs:caller-controlled` source (the caller of a library
159
+ // function is not known to be a client). The module must import the framework AND the function's declared result must be its handler type; a
160
+ // helper in a framework module that returns plain text is not a handler.
161
+ export const HS_HANDLER_PARAM_SOURCES = Object.freeze({
162
+ scotty: { decorator: 'hs:handler-param:scotty', framework: 'scotty', modules: ['Web.Scotty', 'Web.Scotty.Trans'], result: /\b(?:ActionM|ActionT)\b/, provenance: 'url-param', label: 'route, query or body value handed to a Scotty action (request input)' },
163
+ yesod: { decorator: 'hs:handler-param:yesod', framework: 'yesod', modules: ['Yesod', 'Yesod.Core', 'Yesod.Auth'], result: /\b(?:Handler|HandlerFor|HandlerT|Widget|WidgetFor|WidgetT)\b/, provenance: 'url-param', label: 'route piece or request value handed to a Yesod handler (request input)' },
164
+ servant: { decorator: 'hs:handler-param:servant', framework: 'servant', modules: ['Servant', 'Servant.API', 'Servant.Server', 'Servant.Auth', 'Servant.Auth.Server'], result: /\b(?:Handler|ServerT|AppM)\b/, provenance: 'url-param', label: 'capture, query or body value handed to a Servant handler (request input)' },
165
+ wai: { decorator: 'hs:handler-param:wai', framework: 'wai', modules: ['Network.Wai', 'Network.Wai.Handler.Warp'], result: /\b(?:ResponseReceived|Application|Middleware)\b/, provenance: 'url-param', label: 'request value handed to a WAI application (request input)' },
166
+ });
167
+ /** What a parameter-annotation source says about itself: the label an evidence chain shows and, for a request value, its provenance. */
168
+ export function paramSourceInfo(decorator) {
169
+ for (const d of Object.values(HS_HANDLER_PARAM_SOURCES)) if (d.decorator === decorator) return { label: d.label, provenance: d.provenance };
170
+ if (decorator === 'hs:caller-controlled') return { label: 'text parameter of an exported function (caller-controlled)', provenance: null };
171
+ return null;
172
+ }
173
+ /** The framework whose handler type a function with this signature returns, among the frameworks the module imports; null for a plain library function. */
174
+ export function handlerFrameworkOf(importedModules, resultText) {
175
+ if (!resultText) return null;
176
+ const mods = new Set(importedModules || []);
177
+ for (const def of Object.values(HS_HANDLER_PARAM_SOURCES)) {
178
+ if (def.modules.some((m) => mods.has(m)) && def.result.test(resultText)) return def;
179
+ }
180
+ return null;
181
+ }
134
182
 
135
183
  // ── sinks ───────────────────────────────────────────────────────────────────
136
184
  // family: the taint family the existing engine already uses (cmd | sql | path | url | xss)
@@ -169,9 +217,12 @@ sink('Database.Persist.Sql', ['rawSql', 'rawExecute', 'rawQuery'], { ...SQL, arg
169
217
 
170
218
  // CWE-918. The URL (or request spec built from it) is the sink.
171
219
  const SSRF = { family: 'url', cwe: 'CWE-918', severity: 'high', argIndex: 0, vuln: 'Server-Side Request Forgery', remediation: 'Parse the URL, require an allow-listed scheme and host, and reject private or link-local addresses after DNS resolution.' };
172
- sink('Network.HTTP.Simple', ['httpBS', 'httpLBS', 'httpJSON', 'httpNoBody', 'httpSink', 'parseRequest', 'parseRequest_'], { ...SSRF, package: 'http-conduit' });
173
- sink('Network.HTTP.Conduit', ['simpleHttp', 'parseRequest', 'parseUrlThrow', 'parseRequest_', 'httpLbs'], { ...SSRF, package: 'http-conduit' });
174
- sink('Network.HTTP.Client', ['parseRequest', 'parseUrlThrow', 'parseRequest_', 'requestFromURI'], { ...SSRF, package: 'http-client' });
220
+ // The SEND is the sink, not the parse: `parseRequest url` only builds a Request value, and the host check a defence needs (`host req \`elem\` allowed`)
221
+ // can only be written AFTER that parse. A sink on the parse would sit before every such check and report every guarded flow.
222
+ sink('Network.HTTP.Simple', ['httpBS', 'httpLBS', 'httpJSON', 'httpNoBody', 'httpSink'], { ...SSRF, package: 'http-conduit' });
223
+ sink('Network.HTTP.Conduit', ['simpleHttp'], { ...SSRF, package: 'http-conduit' });
224
+ // http-conduit re-exports the http-client senders under its own module names, and conduit-flavoured modules add their own
225
+ for (const mod of ['Network.HTTP.Conduit', 'Network.HTTP.Client.Conduit']) sink(mod, ['httpLbs', 'httpNoBody', 'http', 'withResponse', 'responseOpen'], { ...SSRF, argIndex: 0, package: 'http-conduit' });
175
226
  sink('Network.HTTP.Client', ['httpLbs', 'httpNoBody', 'withResponse', 'responseOpen'], { ...SSRF, argIndex: 0, package: 'http-client' });
176
227
  // wreq: where the URL sits depends on the function. `get url`, but `getWith opts url` (the first argument is the Options, not the target),
177
228
  // `customMethod method url`, `customMethodWith method opts url`; the Session forms take the session before the URL.
@@ -197,6 +248,10 @@ sink('Text.Blaze.Html5', ['preEscapedToHtml'], { ...XSS, package: 'blaze-html' }
197
248
  sink('Lucid', ['toHtmlRaw'], { ...XSS, package: 'lucid' });
198
249
  sink('Lucid.Base', ['toHtmlRaw'], { ...XSS, package: 'lucid' });
199
250
  sink('Text.Hamlet', ['preEscapedToMarkup'], { ...XSS, package: 'shakespeare' });
251
+ // `Yesod` and `Yesod.Core` re-export the blaze pre-escaped constructors, so the same call resolves to those modules
252
+ for (const mod of ['Yesod', 'Yesod.Core', 'Yesod.Core.Widget', 'Text.Blaze', 'Text.Blaze.Html', 'Text.Blaze.Html5', 'Text.Blaze.Internal']) {
253
+ sink(mod, ['preEscapedToMarkup', 'preEscapedToHtml'], { ...XSS, package: mod.startsWith('Yesod') ? 'yesod' : 'blaze-html', ...(mod.startsWith('Yesod') ? { framework: 'yesod' } : {}) });
254
+ }
200
255
  sink('Web.Scotty', ['html'], { ...XSS, package: 'scotty', framework: 'scotty', vuln: 'Cross-Site Scripting (HTML response from text)' });
201
256
  sink('Web.Scotty.Trans', ['html'], { ...XSS, package: 'scotty', framework: 'scotty', vuln: 'Cross-Site Scripting (HTML response from text)' });
202
257
 
@@ -232,6 +287,10 @@ for (const mod of ['Database.Persist.Postgresql', 'Database.Persist.Sqlite', 'Da
232
287
 
233
288
  // CWE-918. Req builds a URL from typed pieces, so the HOST is the attacker-steered part; HTTP's request constructors take the URL text.
234
289
  sink('Network.HTTP.Req', ['https', 'http'], { ...SSRF, argIndex: 0, package: 'req', vuln: 'Server-Side Request Forgery (request host)' });
290
+ // `useURI` turns a parsed URI (often one the caller supplied) into the host-and-path pair `req` sends to. The tainted value is the whole URI, which
291
+ // names the host: that is the point the request target is chosen. (`req` itself is not a sink: its URL argument is usually built from a fixed host
292
+ // and tainted path segments, which `https "host" /: segment` keeps on that host.)
293
+ sink('Network.HTTP.Req', ['useURI'], { ...SSRF, argIndex: 0, package: 'req', vuln: 'Server-Side Request Forgery (request target from a parsed URI)' });
235
294
  sink('Network.HTTP', ['getRequest', 'postRequest', 'headRequest', 'simpleHTTP'], { ...SSRF, argIndex: 0, package: 'HTTP' });
236
295
 
237
296
  // CWE-601. Redirecting to a location taken from the request sends the user to a site the attacker names.
@@ -252,6 +311,17 @@ sink('Data.ByteString.Lazy.Char8', ['readFile', 'writeFile', 'appendFile'], { fa
252
311
  sink('Data.Text.Lazy.IO', ['readFile', 'writeFile', 'appendFile'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'text', vuln: 'Path Traversal', remediation: 'Confirm the canonical path stays under an allowed directory before opening it.' });
253
312
  sink('System.Posix.Files', ['removeLink', 'createSymbolicLink', 'setFileMode', 'setOwnerAndGroup', 'readSymbolicLink'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, package: 'unix', vuln: 'Path Traversal (file operation)', remediation: 'Confirm the canonical path stays under an allowed directory before the operation.' });
254
313
  sink('System.Posix.Files', ['rename'], { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 'all', package: 'unix', vuln: 'Path Traversal (rename)', remediation: 'Confirm both canonical paths stay under an allowed directory.' });
314
+ // Serving a file named by the request: the web frameworks' own file responses take a FilePath. (WAI `responseFile status headers path part`
315
+ // has the path third; Yesod's `sendFile contentType path` second.)
316
+ const PATH_RESPONSE = { family: 'path', cwe: 'CWE-22', severity: 'high', argIndex: 0, vuln: 'Path Traversal (file served from a request-chosen path)', remediation: 'Serve files only from a fixed directory: reject any name containing a path separator or `..` (`takeFileName p == p`), or look the file up in an allow-list.' };
317
+ sink('Web.Scotty', ['file'], { ...PATH_RESPONSE, package: 'scotty', framework: 'scotty' });
318
+ sink('Web.Scotty.Trans', ['file'], { ...PATH_RESPONSE, package: 'scotty', framework: 'scotty' });
319
+ sink('Yesod.Core', ['sendFile', 'sendFilePart'], { ...PATH_RESPONSE, argIndex: 1, package: 'yesod', framework: 'yesod' });
320
+ sink('Yesod', ['sendFile', 'sendFilePart'], { ...PATH_RESPONSE, argIndex: 1, package: 'yesod', framework: 'yesod' });
321
+ sink('Network.Wai', ['responseFile'], { ...PATH_RESPONSE, argIndex: 2, package: 'wai', framework: 'wai' });
322
+ sink('Snap.Core', ['sendFile'], { ...PATH_RESPONSE, package: 'snap', framework: 'snap' });
323
+ sink('Snap.Util.FileServe', ['serveFile'], { ...PATH_RESPONSE, package: 'snap-fileserve', framework: 'snap' });
324
+ sink('Snap.Util.FileServe', ['serveFileAs'], { ...PATH_RESPONSE, argIndex: 1, package: 'snap-fileserve', framework: 'snap' });
255
325
 
256
326
  // ── sanitizers (context-specific; `appliesTo` is the family each one actually neutralises) ──
257
327
  const SAN = [];
@@ -287,6 +357,9 @@ export const HS_LOG_SINKS = Object.freeze([
287
357
  ...E('System.IO', ['appendFile']).map((e) => ({ ...e, argIndex: 1, channel: 'file' })),
288
358
  ...E('Debug.Trace', ['trace', 'traceShow', 'traceM', 'traceShowM', 'traceShowId', 'traceId']).map((e) => ({ ...e, argIndex: 0, channel: 'trace' })),
289
359
  ...E('Control.Monad.Logger', ['logDebugN', 'logInfoN', 'logWarnN', 'logErrorN', 'logOtherN']).map((e) => ({ ...e, argIndex: 0, channel: 'logger' })),
360
+ // printf is variadic: the format string is argument 0 and every later argument is a value interpolated into the message
361
+ ...E('Text.Printf', ['printf']).map((e) => ({ ...e, argIndex: 0, argFrom: 0, channel: 'stdout' })),
362
+ ...E('Text.Printf', ['hPrintf']).map((e) => ({ ...e, argIndex: 1, argFrom: 1, channel: 'handle' })),
290
363
  ...E('Katip', ['logFM', 'logTM', 'logItem']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
291
364
  ...E('System.Log.Logger', ['debugM', 'infoM', 'noticeM', 'warningM', 'errorM', 'criticalM']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
292
365
  ]);
@@ -301,7 +374,7 @@ export const HS_RULE_APIS = Object.freeze([
301
374
  ...E('Crypto.Cipher.Types', ['makeIV', 'ecbEncrypt', 'ecbDecrypt', 'nullIV', 'cbcEncrypt', 'cbcDecrypt']),
302
375
  ...E('Crypto.Cipher.ChaChaPoly1305', ['nonce12', 'nonce8']),
303
376
  ...E('Crypto.KDF.PBKDF2', ['generate', 'fastPBKDF2_SHA1', 'fastPBKDF2_SHA256', 'fastPBKDF2_SHA512']),
304
- ...E('System.Random', ['randomRIO', 'randomIO', 'newStdGen', 'getStdGen', 'mkStdGen', 'randomR', 'random', 'randoms', 'randomRs']),
377
+ ...E('System.Random', ['randomRIO', 'randomIO', 'newStdGen', 'getStdGen', 'mkStdGen', 'randomR', 'random', 'randoms', 'randomRs', 'getStdRandom', 'uniformR', 'uniform', 'initStdGen']),
305
378
  ...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
306
379
  ...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
307
380
  ...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
@@ -311,6 +384,22 @@ export const HS_RULE_APIS = Object.freeze([
311
384
  ...E('Data.Text.IO', ['getContents', 'hGetContents']), ...E('Data.Text.Lazy.IO', ['getContents', 'hGetContents']),
312
385
  ...E('Data.Text', ['replicate', 'take']), ...E('Data.Vector', ['replicate']),
313
386
  ...E('Web.Cookie', ['defaultSetCookie']),
387
+ // session cookies set without the flags: scotty-cookie's helper builds a cookie with no Secure and no HttpOnly; a Set-Cookie header written by hand
388
+ ...E('Web.Scotty.Cookie', ['setSimpleCookie', 'makeSimpleCookie']),
389
+ ...E('Network.HTTP.Types.Header', ['hSetCookie']), ...E('Network.HTTP.Types', ['hSetCookie']),
390
+ ...E('Servant.Auth.Server', ['defaultCookieSettings']),
391
+ // unbounded reads: a request body drained into memory, a socket read in a loop
392
+ ...E('Network.Wai.Conduit', ['sourceRequestBody']), ...E('Network.Wai', ['getRequestBodyChunk', 'requestBody', 'requestBodyLength']),
393
+ ...E('Data.Conduit.Binary', ['sinkLbs', 'take', 'isolate']), ...E('Data.Conduit.Combinators', ['sinkLazy', 'sinkLazyBuilder', 'take', 'takeE']),
394
+ ...E('Network.Socket.ByteString', ['recv', 'recvFrom']), ...E('Network.Socket.ByteString.Lazy', ['recv', 'getContents']), ...E('System.IO', ['hGetSome', 'hGetLine']),
395
+ // partial functions that raise on an empty or short input, and the Map lookup that raises on a missing key
396
+ ...E('Prelude', ['head', 'tail', 'last', 'init', 'maximum', 'minimum', 'foldr1', 'foldl1', 'read', 'toEnum', 'succ', 'pred', 'cycle']),
397
+ ...E('Data.List', ['head', 'tail', 'last', 'init', 'maximum', 'minimum', 'foldr1', 'foldl1', 'maximumBy', 'minimumBy', '!!', 'genericIndex']),
398
+ ...E('Data.Foldable', ['maximum', 'minimum', 'foldr1', 'foldl1', 'maximumBy', 'minimumBy']),
399
+ ...E('Data.Map', ['!', 'findMin', 'findMax', 'deleteFindMin', 'elemAt']), ...E('Data.Map.Strict', ['!', 'findMin', 'findMax', 'deleteFindMin', 'elemAt']),
400
+ ...E('Data.HashMap.Strict', ['!']), ...E('Data.IntMap', ['!']), ...E('Data.IntMap.Strict', ['!']), ...E('Data.Set', ['findMin', 'findMax', 'elemAt']),
401
+ ...E('Data.Text', ['head', 'last', 'tail', 'init', 'maximum', 'minimum', 'index']),
402
+ ...E('Data.Maybe', ['fromJust']),
314
403
  ...E('Web.JWT', ['hmacSecret', 'secret']), ...E('Servant.Auth.Server', ['fromSecret']), ...E('Web.ClientSession', ['initKey']),
315
404
  ...E('Network.HTTP.Client.TLS', ['mkManagerSettings']), ...E('OpenSSL.Session', ['contextSetVerificationMode']),
316
405
  ...E('Text.XML.HXT.Core', ['withSubstDTDEntities']),
@@ -52,8 +52,12 @@ const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.
52
52
  const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
53
53
  const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
54
54
  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']);
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', 'System.Random.getStdRandom', 'System.Random.uniformR', 'System.Random.uniform', 'System.Random.initStdGen']);
56
+ const BODY_CALLS = new Set(['Network.Wai.strictRequestBody', 'Network.Wai.lazyRequestBody', 'Web.Scotty.body', 'Web.Scotty.Trans.body', 'Network.Wai.Conduit.sourceRequestBody']);
57
+ const SOCKET_READS = new Set(['Network.Socket.ByteString.recv', 'Network.Socket.ByteString.recvFrom', 'Network.Socket.ByteString.Lazy.recv']);
58
+ const SET_COOKIE_HEADER = new Set(['Network.HTTP.Types.Header.hSetCookie', 'Network.HTTP.Types.hSetCookie']);
59
+ const REQUEST_LENGTH = new Set(['Network.Wai.requestBodyLength']);
60
+ const RELATIONAL = new Set(['<', '<=', '>', '>=']);
57
61
  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
62
  const STRONG_KDF = /^(?:Crypto\.KDF\.BCrypt\.|Crypto\.Argon2\.|Crypto\.Scrypt\.|Crypto\.KDF\.Scrypt\.|Crypto\.PasswordStore\.|Crypto\.BCrypt\.)/;
59
63
  const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'System.IO.Unsafe.unsafeInterleaveIO': 'unsafeInterleaveIO', 'System.IO.Unsafe.unsafeDupablePerformIO': 'unsafeDupablePerformIO', 'Unsafe.Coerce.unsafeCoerce': 'unsafeCoerce' };
@@ -69,11 +73,19 @@ const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|salt|apikey|r
69
73
  const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
70
74
  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
75
  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)$/;
76
+ const BOUNDING = /(?:^|\.)(?:take|takeE|isolate|hGetSome|hGet|splitAt|limit|limitedRead)$/;
73
77
  // `replicateM n getLine` reads n lines: the count is an allocation (and an input loop) sized by whoever supplied n.
74
78
  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
79
  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']);
80
+ const PARTIAL_NAMES = ['read', 'head', 'tail', 'last', 'init', 'maximum', 'minimum', 'foldr1', 'foldl1', 'maximumBy', 'minimumBy', 'toEnum', 'succ', 'pred', 'fromJust', 'cycle'];
81
+ const PARTIAL_MODULES = ['Prelude', 'Data.List', 'Data.Foldable', 'Data.Maybe'];
82
+ const PARTIALS = new Set([...PARTIAL_NAMES, ...PARTIAL_NAMES.flatMap((n) => PARTIAL_MODULES.map((m) => `${m}.${n}`)),
83
+ ...['head', 'last', 'tail', 'init', 'maximum', 'minimum', 'index'].map((n) => `Data.Text.${n}`),
84
+ // the Map/Set lookups and extremum selectors that raise on a missing key or an empty container
85
+ ...['Data.Map', 'Data.Map.Strict', 'Data.HashMap.Strict', 'Data.IntMap', 'Data.IntMap.Strict'].map((m) => `${m}.!`),
86
+ ...['Data.Map', 'Data.Map.Strict'].flatMap((m) => ['findMin', 'findMax', 'deleteFindMin', 'elemAt'].map((n) => `${m}.${n}`)), 'Data.Set.findMin', 'Data.Set.findMax', 'Data.Set.elemAt']);
87
+ // 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.
88
+ const EMPTINESS_CHECK = /(?:^|\.)(?:null|notNull|isEmpty|length|size|isJust|isNothing|member|notMember|lookup|elem|notElem|nonEmpty|<match>)$/;
77
89
  const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
78
90
 
79
91
  // What makes a bound variable security-shaped, and what makes a value sensitive for logging.
@@ -89,6 +101,7 @@ const kids = (e) => {
89
101
  for (const k of ['args', 'elements', 'branches', 'parts']) if (Array.isArray(e[k])) out.push(...e[k]);
90
102
  if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) out.push(p.value);
91
103
  if (e.hs && e.hs.selector && typeof e.hs.selector === 'object') out.push(e.hs.selector); // `lookup key t`: the key decides the outcome
104
+ 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
105
  return out;
93
106
  };
94
107
 
@@ -117,6 +130,15 @@ const isLiteralLike = (e, depth = 0) => {
117
130
 
118
131
  function fnNodes(fn) { return Object.values(fn.cfg.nodes); }
119
132
 
133
+ // The text a concatenation spells out, in order, skipping the parts that are not literals (`"session_id=" <> sid <> "; Path=/"` -> `session_id=; Path=/`).
134
+ function literalTextOf(e, depth = 0) {
135
+ if (!e || depth > 12) return '';
136
+ if (e.kind === 'literal' && typeof e.value === 'string') return e.value;
137
+ if (e.kind === 'binary' && e.hs && e.hs.concat) return literalTextOf(e.left, depth + 1) + literalTextOf(e.right, depth + 1);
138
+ 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);
139
+ return '';
140
+ }
141
+
120
142
  function snippetOf(text, line) {
121
143
  if (!text || !Number.isInteger(line) || line < 1) return '';
122
144
  const l = text.split('\n')[line - 1];
@@ -177,7 +199,7 @@ export function analyzeHaskellRules(files, opts = {}) {
177
199
 
178
200
  const fnBase = fn.name.replace(/^.*\./, '');
179
201
  // text-typed parameters of an exported function, and every local value derived from them
180
- const derivedText = new Set((fn.hs && fn.hs.textParams) || []);
202
+ const derivedText = new Set([...((fn.hs && fn.hs.textParams) || []), ...((fn.hs && fn.hs.inputParams) || [])]);
181
203
  if (derivedText.size) {
182
204
  for (let round = 0; round < 8; round++) {
183
205
  let grew = false;
@@ -190,6 +212,31 @@ export function analyzeHaskellRules(files, opts = {}) {
190
212
  const fnReturns = fnNodes(fn).filter((n) => n.kind === 'return' && n.value);
191
213
  const bounded = fnNodes(fn).some((n) => [n.value, n.source, ...(n.args || [])].some((e) => e && callsIn(e).some((c) => BOUNDING.test(c.callee))));
192
214
 
215
+ // Values a condition in this function tests for emptiness or presence (`null ws`, `M.member k t`, `xs /= []`, a pattern match on it) and,
216
+ // transitively, the values they were built from: a partial call on one of them is the other branch of that test.
217
+ const assignSources = new Map();
218
+ 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)]);
219
+ 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; };
220
+ const testedVars = new Set();
221
+ for (const n of fnNodes(fn)) {
222
+ if (n.kind !== 'if' || !n.cond) continue;
223
+ for (const x of walk(n.cond)) {
224
+ if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) for (const nm of names(x)) testedVars.add(nm);
225
+ 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);
226
+ }
227
+ }
228
+ const testedAll = withOrigins(testedVars);
229
+ 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; };
230
+
231
+ // 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.
232
+ const hasSizeTest = fnNodes(fn).some((n) => n.kind === 'if' && n.cond && [...walk(n.cond)].some((x) => x.kind === 'binary' && RELATIONAL.has(x.op)));
233
+ 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)))));
234
+ const sizeChecked = hasSizeTest && readsRequestLength;
235
+ // One predictable-random finding per function: a clock reading that seeds a generator in a token-shaped function is a single flaw,
236
+ // however many of its steps (the clock, the seed, the generator) the rule can see.
237
+ let randomEmitted = false;
238
+ const emitRandom = (line, extra) => { if (randomEmitted) return; randomEmitted = true; emit('hs-predictable-random', file, line, extra); };
239
+
193
240
  const seeds = (e) => { for (const n of names(e)) if (SENSITIVE.test(n.replace(/^\./, ''))) return n.replace(/^\./, ''); return null; };
194
241
 
195
242
  // pass over nodes in program order
@@ -205,12 +252,32 @@ export function analyzeHaskellRules(files, opts = {}) {
205
252
  if (e.kind === 'ident') {
206
253
  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
254
  // 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 } });
255
+ 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
+ // a generator named as the whole value of a security-shaped action (`newCsrfToken = randomIO`): no call is written, the name is the call
257
+ 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 } });
209
258
  if (INPUT_READS.has(e.name) && !bounded) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
210
259
  continue;
211
260
  }
261
+ // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`
262
+ if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) {
263
+ const [k, v] = e.elements;
264
+ 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))));
265
+ if (isSetCookie && v) {
266
+ const text = literalTextOf(v);
267
+ const nm = /^\s*([^=;\s]+)\s*=/.exec(text);
268
+ const sessionLike = !nm || SESSION_COOKIE.test(nm[1]);
269
+ const secure = /;\s*Secure\b/i.test(text); const httpOnly = /;\s*HttpOnly\b/i.test(text);
270
+ // 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 } });
272
+ }
273
+ }
212
274
  if (e.kind === 'object' && e.hs && e.hs.update && Array.isArray(e.props)) {
213
275
  const prop = (k) => e.props.find((p) => p && p.key === k);
276
+ // servant-auth: `cookieIsSecure = NotSecure` sends the session cookie over plain HTTP too
277
+ const secureSetting = prop('cookieIsSecure');
278
+ if (secureSetting && secureSetting.value && /(?:^|\.)NotSecure$/.test(String(secureSetting.value.value || secureSetting.value.name || '')) && (fir.imports || []).some((im) => im.module === 'Servant.Auth.Server')) {
279
+ 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' } });
280
+ }
214
281
  if (prop('setCookieName')) {
215
282
  const nm = prop('setCookieName').value;
216
283
  const sessionLike = !nm || nm.kind !== 'literal' || SESSION_COOKIE.test(String(nm.value));
@@ -233,7 +300,7 @@ export function analyzeHaskellRules(files, opts = {}) {
233
300
  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
301
  }
235
302
  // `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)) {
303
+ if (e.kind === 'binary' && e.op === '!!' && e.left && mentionsText(e.left) && !isTested([e.left])) {
237
304
  emit('hs-partial-function-on-input', file, e.line || node.line, { detail: '!! on a caller-supplied list', evidence: { partial: '!!' } });
238
305
  continue;
239
306
  }
@@ -242,6 +309,15 @@ export function analyzeHaskellRules(files, opts = {}) {
242
309
  const line = e.line || node.line;
243
310
  const args = e.args || [];
244
311
 
312
+ // scotty-cookie's helpers build a cookie that carries neither Secure nor HttpOnly
313
+ if ((c === 'Web.Scotty.Cookie.setSimpleCookie' || c === 'Web.Scotty.Cookie.makeSimpleCookie') && args[0]) {
314
+ const nm = args[0];
315
+ 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 } });
316
+ }
317
+ // a socket read repeated by a recursive loop, with no size test anywhere in it
318
+ if (SOCKET_READS.has(c) && !hasSizeTest && !bounded && fn.calls.some((x) => x.callee === fn.name && x.hs && x.hs.target === fn.qid)) {
319
+ 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 } });
320
+ }
245
321
  // TLS certificate validation switched off
246
322
  // a signing/encryption secret that is a constant in the source
247
323
  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 } });
@@ -264,23 +340,23 @@ export function analyzeHaskellRules(files, opts = {}) {
264
340
  if (IV_CALLS.has(c) && args[0] && isLiteralLike(args[0])) emit('hs-static-iv', file, line, { detail: `${c} of a constant value` });
265
341
  // randomness bound to a security-shaped name
266
342
  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 } });
343
+ emitRandom(line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
268
344
  }
269
345
  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 } });
346
+ emitRandom(line, { detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
271
347
  }
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 } });
348
+ 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 } });
273
349
  if (INPUT_READS.has(c) && !bounded) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
274
350
  // an allocation whose size is parsed caller text and is not clamped
275
351
  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
352
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
277
353
  }
278
354
  // 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 } });
355
+ 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
356
  // a session/token cookie assembled without HttpOnly or Secure
281
357
  if (c === 'Web.Cookie.defaultSetCookie') { /* handled on the record update below */ }
282
358
  // unbounded body
283
- if (BODY_CALLS.has(c)) {
359
+ if (BODY_CALLS.has(c) && !sizeChecked && !(c === 'Network.Wai.Conduit.sourceRequestBody' && bounded)) {
284
360
  if (requestLimit) {
285
361
  controls.push({ kind: 'request-size-limit', file: requestLimit.file, line: requestLimit.line, appliesTo: `${file}:${line}`, verified: false });
286
362
  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` }] });
@@ -326,13 +402,17 @@ export function analyzeHaskellRules(files, opts = {}) {
326
402
  if (e.kind !== 'call' || typeof e.callee !== 'string') continue;
327
403
  const sink = HS_LOG_SINKS.find((s) => `${s.module}.${s.name}` === e.callee);
328
404
  if (!sink) continue;
329
- const arg = (e.args || [])[sink.argIndex];
330
- if (!arg) continue;
405
+ // a variadic log call (printf) interpolates every argument after the format into the message: each is judged on its own, so one
406
+ // redacted value never clears a credential passed beside it
407
+ const argList = sink.argFrom !== undefined ? (e.args || []).slice(sink.argFrom) : [(e.args || [])[sink.argIndex]];
408
+ if (!argList.length || argList.every((a) => !a)) continue;
331
409
  // a file append is a log only when its path says so (a log, audit or trace file)
332
410
  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) {
411
+ 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; };
412
+ for (const arg of argList) {
413
+ if (!arg) continue;
414
+ let hit = null;
415
+ // a labelled value: a literal that names a credential ("token=", "password: ") followed by a non-literal operand
336
416
  for (const x of walk(arg)) {
337
417
  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
418
  const lab = x.left.value.replace(/[=:\s]+$/, '').trim();
@@ -340,23 +420,21 @@ export function analyzeHaskellRules(files, opts = {}) {
340
420
  break;
341
421
  }
342
422
  }
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
- });
423
+ for (const n of names(arg)) {
424
+ if (hit) break;
425
+ const bare = n.replace(/^\./, '');
426
+ if (sensitive.has(n)) hit = { ...sensitive.get(n), var: n };
427
+ 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 };
428
+ if (hit) break;
429
+ }
430
+ if (hit && !isRedacted(arg)) {
431
+ emit('hs-sensitive-logging', file, e.line || node.line, {
432
+ severity: hit.hashed ? 'low' : 'high',
433
+ detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
434
+ evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
435
+ });
436
+ break;
437
+ }
360
438
  }
361
439
  }
362
440
  }
@@ -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
  }