@clear-capabilities/agentic-security-scanner 0.186.2 → 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.
- package/CHANGELOG.md +11 -0
- package/dist/1602.index.js +275 -19
- package/dist/9560.index.js +61 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{4d06c8944ae121d7085c.json → deabc47d0e27e21bf99b.json} +4 -4
- package/package.json +4 -4
- package/src/capabilities/decide.js +7 -1
- package/src/dataflow/CLAUDE.md +2 -0
- package/src/dataflow/catalog-haskell.js +7 -1
- package/src/dataflow/engine.js +16 -0
- package/src/dataflow/privacy-catalog-haskell.js +2 -1
- package/src/language/CLAUDE.md +19 -0
- package/src/language/compliance-map.js +6 -0
- package/src/language/context.js +20 -0
- package/src/language/haskell-disclosure.js +16 -4
- package/src/language/haskell-guards.js +130 -3
- package/src/language/haskell-ir.js +182 -10
- package/src/language/haskell-models.js +93 -4
- package/src/language/haskell-security-rules.js +112 -34
- package/src/language/haskell-syntax.js +10 -1
- package/src/language/haskell-web.js +44 -8
- package/src/language/support-registry.js +3 -2
- package/src/mcp/tools.js +54 -3
- package/src/posture/attack-taxonomy.js +10 -0
- package/src/posture/entrypoint-inventory.js +6 -2
- package/src/posture/proof-fabric/generate-haskell.js +266 -17
- package/src/posture/proof-fabric/generate.js +10 -3
- package/src/posture/proof-fabric/oracles.js +21 -0
- package/src/posture/stable-id.js +2 -1
- package/src/report/index.js +3 -0
- package/dist/2066.index.js +0 -851
|
@@ -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
|
-
|
|
173
|
-
|
|
174
|
-
sink('Network.HTTP.
|
|
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
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
346
|
+
emitRandom(line, { detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
|
|
271
347
|
}
|
|
272
|
-
if (TIME_SOURCES.has(c) && SECURITY_FN.test(fnBase))
|
|
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
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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(), '::'))
|
|
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
|
}
|