@clear-capabilities/agentic-security-scanner 0.195.7 → 0.195.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/{966315fcdef8a956d535.json → 851efefbfcfa2a9aa691.json} +4 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/language/CLAUDE.md +10 -2
- package/src/language/haskell-guards.js +179 -8
- package/src/language/haskell-ir.js +11 -4
- package/src/language/haskell-models.js +12 -0
- package/src/language/haskell-parser.js +63 -1
- package/src/language/haskell-security-rules.js +213 -28
- package/src/language/haskell-trust.js +23 -7
- package/src/language/haskell-web.js +122 -12
- package/src/language/nix-script-taint.js +13 -0
- package/src/language/nix-secrets.js +14 -0
- package/src/language/nixos-hardening.js +20 -3
- package/src/lineage/source-registry.js +1 -0
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Nothing here executes code, evaluates a type, or reads a path outside the files it is handed.
|
|
15
15
|
|
|
16
16
|
import { buildHaskellIR } from './haskell-ir.js';
|
|
17
|
-
import { HS_LOG_SINKS, HS_MODEL_VERSION } from './haskell-models.js';
|
|
17
|
+
import { HS_LOG_SINKS, HS_MODEL_VERSION, sourceInfo } from './haskell-models.js';
|
|
18
18
|
import { codeOfLine } from './haskell-comments.js';
|
|
19
19
|
|
|
20
20
|
export const HS_RULES_VERSION = 'haskell-security-rules/1';
|
|
@@ -53,7 +53,11 @@ const ANY_HASH_CALLS = new Set([...WEAK_HASH_CALLS, 'Crypto.Hash.hash', 'Crypto.
|
|
|
53
53
|
const WEAK_CIPHER_MODULES = new Set(['Crypto.Cipher.DES', 'Crypto.Cipher.RC4', 'Crypto.Cipher.RC2', 'Crypto.Cipher.Blowfish']);
|
|
54
54
|
const ECB_CALLS = new Set(['Crypto.Cipher.Types.ecbEncrypt', 'Crypto.Cipher.Types.ecbDecrypt']);
|
|
55
55
|
const IV_CALLS = new Set(['Crypto.Cipher.Types.makeIV', 'Crypto.Cipher.ChaChaPoly1305.nonce12', 'Crypto.Cipher.ChaChaPoly1305.nonce8']);
|
|
56
|
-
const RANDOM_CALLS = new Set(['System.Random.randomRIO', 'System.Random.randomIO', 'System.Random.newStdGen', 'System.Random.getStdGen', 'System.Random.mkStdGen', 'System.Random.randomR', 'System.Random.random', 'System.Random.randoms', 'System.Random.randomRs', 'System.Random.getStdRandom', 'System.Random.uniformR', 'System.Random.uniform', 'System.Random.initStdGen'
|
|
56
|
+
const RANDOM_CALLS = new Set(['System.Random.randomRIO', 'System.Random.randomIO', 'System.Random.newStdGen', 'System.Random.getStdGen', 'System.Random.mkStdGen', 'System.Random.randomR', 'System.Random.random', 'System.Random.randoms', 'System.Random.randomRs', 'System.Random.getStdRandom', 'System.Random.uniformR', 'System.Random.uniform', 'System.Random.initStdGen',
|
|
57
|
+
// mwc-random is a statistical generator (multiply-with-carry); `createSystemRandom` only seeds it from the OS, its output is not a CSPRNG
|
|
58
|
+
'System.Random.MWC.uniformR', 'System.Random.MWC.uniform', 'System.Random.MWC.uniformRM', 'System.Random.MWC.uniformM', 'System.Random.MWC.createSystemRandom', 'System.Random.MWC.create', 'System.Random.MWC.initialize', 'System.Random.MWC.withSystemRandom', 'System.Random.MWC.withSystemRandomST',
|
|
59
|
+
// splitmix is the pure generator behind random
|
|
60
|
+
'System.Random.SplitMix.nextWord64', 'System.Random.SplitMix.nextInt', 'System.Random.SplitMix.nextDouble', 'System.Random.SplitMix.mkSMGen', 'System.Random.SplitMix.newSMGen', 'System.Random.SplitMix.initSMGen']);
|
|
57
61
|
const BODY_CALLS = new Set(['Network.Wai.strictRequestBody', 'Network.Wai.lazyRequestBody', 'Web.Scotty.body', 'Web.Scotty.Trans.body', 'Network.Wai.Conduit.sourceRequestBody']);
|
|
58
62
|
const SOCKET_READS = new Set(['Network.Socket.ByteString.recv', 'Network.Socket.ByteString.recvFrom', 'Network.Socket.ByteString.Lazy.recv']);
|
|
59
63
|
const SET_COOKIE_HEADER = new Set(['Network.HTTP.Types.Header.hSetCookie', 'Network.HTTP.Types.hSetCookie']);
|
|
@@ -71,12 +75,18 @@ const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(
|
|
|
71
75
|
const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
|
|
72
76
|
// A salt is deliberately absent: it must be unique, not unpredictable or secret (it is stored beside the hash), so a non-cryptographic generator
|
|
73
77
|
// producing one is not a weakness the way it is for a token, nonce or key.
|
|
74
|
-
|
|
78
|
+
// (`newKey`, `generateApiKey`, `mkKey`: a function that MAKES a key is security-shaped; `key` alone is not, it names map keys too)
|
|
79
|
+
const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|apikey|resetcode|passcode|verification|(?:new|gen|generate|make|mk|create|issue|mint|fresh)_?(?:api_?)?key)/i;
|
|
75
80
|
// Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
|
|
76
81
|
const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
|
|
77
82
|
const SECURITY_FN = { test: (name) => SECURITY_FN_SUBSTR.test(name) || String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).some((w) => SECURITY_WORDS.has(w)) };
|
|
78
83
|
const INPUT_READS = new Set(['Prelude.getContents', 'System.IO.getContents', 'System.IO.hGetContents', 'Data.ByteString.getContents', 'Data.ByteString.hGetContents', 'Data.ByteString.Char8.getContents', 'Data.ByteString.Char8.hGetContents', 'Data.ByteString.Lazy.getContents', 'Data.ByteString.Lazy.hGetContents', 'Data.ByteString.Lazy.Char8.getContents', 'Data.ByteString.Lazy.Char8.hGetContents', 'Data.Text.IO.getContents', 'Data.Text.IO.hGetContents', 'Data.Text.Lazy.IO.getContents', 'Data.Text.Lazy.IO.hGetContents']);
|
|
79
84
|
const BOUNDING = /(?:^|\.)(?:take|takeE|isolate|hGetSome|hGet|splitAt|limit|limitedRead)$/;
|
|
85
|
+
// Source provenances (haskell-models.js) that mean "the HTTP client supplied this text": a read with one of these inside a function makes the
|
|
86
|
+
// value caller-supplied for the partial-function, allocation and bounding rules, whatever the function's own parameters are.
|
|
87
|
+
const REQUEST_PROVENANCE = new Set(['url-param', 'path-param', 'http-body', 'header', 'cookie', 'upload']);
|
|
88
|
+
// wai-extra's `requestSizeCheck (Just n) req` wraps the request so the body read after it fails past n bytes: a bound on every body read of the function.
|
|
89
|
+
const WAI_SIZE_CHECK = /(?:^|\.)requestSizeCheck$/;
|
|
80
90
|
// `replicateM n getLine` reads n lines: the count is an allocation (and an input loop) sized by whoever supplied n.
|
|
81
91
|
const ALLOCATORS = new Set(['replicate', 'Prelude.replicate', 'Data.List.replicate', 'Data.ByteString.replicate', 'Data.ByteString.Char8.replicate', 'Data.ByteString.Lazy.replicate', 'Data.Text.replicate', 'Data.Vector.replicate', 'Control.Monad.replicateM', 'Control.Monad.replicateM_', 'Data.Vector.replicateM', 'Data.List.genericReplicate']);
|
|
82
92
|
const CLAMPS = /^(?:(?:Prelude|Data\.Ord)\.)?(?:min|clamp)$/;
|
|
@@ -89,14 +99,72 @@ const PARTIALS = new Set([...PARTIAL_NAMES, ...PARTIAL_NAMES.flatMap((n) => PART
|
|
|
89
99
|
...['Data.Map', 'Data.Map.Strict'].flatMap((m) => ['findMin', 'findMax', 'deleteFindMin', 'elemAt'].map((n) => `${m}.${n}`)), 'Data.Set.findMin', 'Data.Set.findMax', 'Data.Set.elemAt']);
|
|
90
100
|
// A call that tests whether a container is empty, a key is present or a value is available: the partial call beside it is conditional on that test.
|
|
91
101
|
const EMPTINESS_CHECK = /(?:^|\.)(?:null|notNull|isEmpty|length|size|isJust|isNothing|member|notMember|lookup|elem|notElem|nonEmpty|<match>)$/;
|
|
102
|
+
// A call whose result has a different shape from its argument (pieces of it, or a parse of it): a test of the argument says nothing about the result.
|
|
103
|
+
const SPLITTER = /(?:^|\.)(?:words|lines|splitOn|split|splitWhen|chunksOf|breakOn|decode|decodeStrict|eitherDecode|decodeEither|parseMaybe|parseEither|parse|readMaybe|readEither|reads)$/;
|
|
92
104
|
const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
|
|
93
105
|
|
|
94
106
|
// What makes a bound variable security-shaped, and what makes a value sensitive for logging.
|
|
95
107
|
const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
|
|
96
108
|
const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
|
|
97
109
|
const LOW_ENTROPY = /(?:pass(?:word|wd|phrase)?|pwd|ssn|creditcard|cardnumber|cvv|\bpin\b)/i;
|
|
110
|
+
// a variable whose name says it holds the HASH of a credential (`hashedPassword`, `passwordHash`, `tokenDigest`): the stored form, not the secret
|
|
111
|
+
const HASH_NAMED = /(?:hash|digest)/i;
|
|
112
|
+
// Every use of the identifier `n` inside `arg` is as the object of a member access whose field is not credential-shaped (`attemptUser attempt`).
|
|
113
|
+
function usedOnlyAsNonSensitiveField(arg, n) {
|
|
114
|
+
let uses = 0; let fields = 0;
|
|
115
|
+
const visit = (e, parent, d) => {
|
|
116
|
+
if (!e || typeof e !== 'object' || d > 40) return;
|
|
117
|
+
if (e.kind === 'ident' && e.name === n) {
|
|
118
|
+
uses++;
|
|
119
|
+
if (parent && parent.kind === 'member' && parent.object === e && typeof parent.prop === 'string' && !SENSITIVE.test(parent.prop)) fields++;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const k of Object.keys(e)) {
|
|
123
|
+
if (k === 'hs') continue;
|
|
124
|
+
const v = e[k];
|
|
125
|
+
if (Array.isArray(v)) { for (const x of v) visit(x, e, d + 1); } else if (v && typeof v === 'object') visit(v, e, d + 1);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
visit(arg, null, 0);
|
|
129
|
+
return uses > 0 && uses === fields;
|
|
130
|
+
}
|
|
131
|
+
// a value that is a one-time code only in context: `code` is a security value where the function talks about a reset, a verification, an OTP
|
|
132
|
+
const CONTEXT_CODE_VAR = /^(?:code|pin|passcode|otp)$/i;
|
|
133
|
+
const CODE_CONTEXT = /reset|verif|otp|one.?time|activation|confirm|2fa|mfa|two.?factor/i;
|
|
134
|
+
// A project function of this file whose every return value is a comparison, a conjunction or a boolean predicate (after `pure`/`return`).
|
|
135
|
+
function returnsYesNo(fir, callee) {
|
|
136
|
+
const fn = (fir.functions || []).find((x) => x.name === callee || x.qid === callee || (x.qid || '').endsWith(`::${callee}`));
|
|
137
|
+
if (!fn) return false;
|
|
138
|
+
const rets = Object.values(fn.cfg.nodes).filter((n) => n.kind === 'return' && n.value);
|
|
139
|
+
if (!rets.length) return false;
|
|
140
|
+
return rets.every((n) => {
|
|
141
|
+
let e = n.value;
|
|
142
|
+
for (let i = 0; i < 6 && e && e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:pure|return|liftIO)$/.test(e.callee) && (e.args || []).length === 1; i++) e = e.args[0];
|
|
143
|
+
if (!e) return false;
|
|
144
|
+
if (e.kind === 'binary' && ['==', '/=', '!=', '&&', '||', '<', '>', '<=', '>='].includes(e.op)) return true;
|
|
145
|
+
if (e.kind === 'logical') return true;
|
|
146
|
+
if (e.kind === 'literal' && typeof e.value === 'boolean') return true;
|
|
147
|
+
return e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:not|elem|notElem|null|isJust|isNothing|any|all|and|or)$/.test(e.callee);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fnMentions(fn, re) {
|
|
152
|
+
for (const n of Object.values(fn.cfg.nodes)) {
|
|
153
|
+
for (const k of ['source', 'value', 'cond']) {
|
|
154
|
+
if (!n[k]) continue;
|
|
155
|
+
for (const x of walk(n[k])) {
|
|
156
|
+
if (x.kind === 'literal' && typeof x.value === 'string' && re.test(x.value)) return true;
|
|
157
|
+
if (x.kind === 'call' && typeof x.callee === 'string' && re.test(x.callee)) return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (n.kind === 'call' && typeof n.callee === 'string' && re.test(n.callee)) return true;
|
|
161
|
+
if (Array.isArray(n.args)) for (const a of n.args) for (const x of walk(a)) if (x.kind === 'literal' && typeof x.value === 'string' && re.test(x.value)) return true;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
98
165
|
export const SENSITIVE = /(?:pass(?:word|wd|phrase)?|pwd|(?:^|[^a-z])pw(?:[^a-z]|$)|secret|token|api_?key|ssn|credit_?card|card_?number|cvv|authorization|bearer|private_?key|session_?id)/i;
|
|
99
|
-
|
|
166
|
+
// (a helper that keeps only a few characters of the value, `lastFour`, `last4`, `fingerprint`, `takeEnd`, is a masking helper by its name too)
|
|
167
|
+
const REDACTOR = /(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|length|null|takeEnd|fingerprint|abbreviate|truncate)$|(?:^|\.)(?:redact|mask|scrub|obfuscate|anonymi[sz]e|censor|last(?:four|4|digits|chars)|fingerprint)/i;
|
|
100
168
|
|
|
101
169
|
const kids = (e) => {
|
|
102
170
|
const out = [];
|
|
@@ -108,6 +176,19 @@ const kids = (e) => {
|
|
|
108
176
|
return out;
|
|
109
177
|
};
|
|
110
178
|
|
|
179
|
+
// Marks every node under an inlined helper's root with the helper's qid (`hs.inlineCtx`); idempotent, so a cached IR is marked once.
|
|
180
|
+
function markInlined(e, ctx, depth) {
|
|
181
|
+
if (!e || typeof e !== 'object' || depth > 60) return;
|
|
182
|
+
const here = (e.hs && e.hs.inlinedFrom) || ctx;
|
|
183
|
+
if (here && !(e.hs && e.hs.inlineCtx)) e.hs = { ...(e.hs || {}), inlineCtx: here };
|
|
184
|
+
for (const k of Object.keys(e)) {
|
|
185
|
+
if (k === 'hs') continue;
|
|
186
|
+
const v = e[k];
|
|
187
|
+
if (Array.isArray(v)) { for (const x of v) markInlined(x, here, depth + 1); }
|
|
188
|
+
else if (v && typeof v === 'object') markInlined(v, here, depth + 1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
111
192
|
function* walk(e, depth = 0) {
|
|
112
193
|
if (!e || typeof e !== 'object' || depth > 80) return;
|
|
113
194
|
yield e;
|
|
@@ -272,11 +353,13 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
272
353
|
if (/import\s+(?:qualified\s+)?Network\.Wai\.Middleware\.RequestSizeLimit\b/.test(text) || /\brequestSizeLimitMiddleware\b/.test(text)) { requestLimit = { file, line: (text.split('\n').findIndex((l) => /RequestSizeLimit|requestSizeLimitMiddleware/.test(l)) + 1) || 1 }; break; }
|
|
273
354
|
}
|
|
274
355
|
|
|
356
|
+
let currentFn = null;
|
|
275
357
|
const emit = (rule, file, line, extra = {}) => {
|
|
276
358
|
const r = RULES[rule];
|
|
277
359
|
const sev = extra.severity || r.severity;
|
|
278
360
|
const f = {
|
|
279
361
|
id: `hs-rule:${rule}:${file}:${line}`, severity: sev, file, line, vuln: extra.vuln || r.vuln, cwe: r.cwe,
|
|
362
|
+
_fnQid: currentFn ? currentFn.qid : null, ...(extra.inlineCtx ? { inlineCtx: extra.inlineCtx } : {}),
|
|
280
363
|
description: `${extra.vuln || r.vuln}${extra.detail ? `: ${extra.detail}` : ''}`,
|
|
281
364
|
remediation: r.remediation, parser: 'HS-RULES', family: r.family,
|
|
282
365
|
language: 'haskell', capability: 'sast', analysisKind: 'application', evidenceKind: 'source',
|
|
@@ -299,6 +382,14 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
299
382
|
}
|
|
300
383
|
}
|
|
301
384
|
for (const fn of fir.functions) {
|
|
385
|
+
currentFn = fn;
|
|
386
|
+
// A project helper the IR inlined at its call site (`digestFor (credPassword creds)` becomes the helper's body, rooted at a node with
|
|
387
|
+
// `hs.inlinedFrom`) is walked here as well as in the helper itself. Every node under such a root is marked with the helper's qid, so
|
|
388
|
+
// a rule that reports the same thing inside the helper can drop the call-site copy (the filter before the return).
|
|
389
|
+
for (const n of Object.values(fn.cfg.nodes)) {
|
|
390
|
+
for (const k of ['source', 'value', 'cond']) if (n[k]) markInlined(n[k], null, 0);
|
|
391
|
+
if (Array.isArray(n.args)) for (const a of n.args) markInlined(a, null, 0);
|
|
392
|
+
}
|
|
302
393
|
if (fn.name.endsWith('.<module>')) continue;
|
|
303
394
|
const bound = new Map(); // target var -> {callee, line}
|
|
304
395
|
const sensitive = new Map(); // var -> {class:'low'|'high', hashed:boolean, line}
|
|
@@ -312,45 +403,89 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
312
403
|
const fnHs = fn.hs || {};
|
|
313
404
|
const strongParam = new Set((fnHs.paramSources || []).filter((p) => /^hs:handler-param:/.test(p.decorator || '')).map((p) => p.name));
|
|
314
405
|
const entryLike = callersUnknown(fn);
|
|
406
|
+
const callsIn = (e) => { const out = []; for (const x of walk(e)) if (x.kind === 'call' && typeof x.callee === 'string') out.push(x); return out; };
|
|
407
|
+
// A value READ from the request inside the function (`raw <- param "page"`, `body <- strictRequestBody req`, `lookup h (requestHeaders req)`)
|
|
408
|
+
// is caller-supplied text wherever the function sits: the framework's own read says who supplied it, so no parameter label is needed.
|
|
409
|
+
const requestRead = (c) => { const s = sourceInfo(c.callee); return !!s && REQUEST_PROVENANCE.has(s.provenance); };
|
|
315
410
|
const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (!
|
|
411
|
+
const fromRequest = new Set();
|
|
412
|
+
for (let round = 0; round < 8; round++) {
|
|
413
|
+
let grew = false;
|
|
414
|
+
for (const n of fnNodes(fn)) {
|
|
415
|
+
if (n.kind !== 'assign' || !n.target || !n.source) continue;
|
|
416
|
+
const nm = [...names(n.source)];
|
|
417
|
+
const reads = callsIn(n.source).some(requestRead);
|
|
418
|
+
if (!derivedText.has(n.target) && (reads || nm.some((x) => derivedText.has(x)))) { derivedText.add(n.target); grew = true; }
|
|
419
|
+
if (!fromRequest.has(n.target) && (reads || nm.some((x) => fromRequest.has(x)))) { fromRequest.add(n.target); grew = true; }
|
|
321
420
|
}
|
|
421
|
+
if (!grew) break;
|
|
322
422
|
}
|
|
323
|
-
const mentionsText = (e) => [...names(e)].some((x) => derivedText.has(x));
|
|
324
|
-
const
|
|
423
|
+
const mentionsText = (e) => [...names(e)].some((x) => derivedText.has(x)) || callsIn(e).some(requestRead);
|
|
424
|
+
const mentionsRequest = (e) => [...names(e)].some((x) => fromRequest.has(x)) || callsIn(e).some(requestRead);
|
|
325
425
|
const fnReturns = fnNodes(fn).filter((n) => n.kind === 'return' && n.value);
|
|
326
426
|
const bounded = fnNodes(fn).some((n) => [n.value, n.source, ...(n.args || [])].some((e) => e && callsIn(e).some((c) => BOUNDING.test(c.callee))));
|
|
327
427
|
|
|
328
428
|
// Values a condition in this function tests for emptiness or presence (`null ws`, `M.member k t`, `xs /= []`, a pattern match on it) and,
|
|
329
429
|
// transitively, the values they were built from: a partial call on one of them is the other branch of that test.
|
|
430
|
+
// A test covers a partial call only on the value it tested, not on a value SPLIT or PARSED from it afterwards: `maybe "" id (lookup h hs)`
|
|
431
|
+
// proves the header is present, not that `words auth !! 1` has a second word; `isJust (decode body)` does cover `fromJust (decode body)`
|
|
432
|
+
// (the same split, of the same value, is what was tested). Names are therefore followed through assignments but not into a splitting call.
|
|
433
|
+
const directNames = (e) => { const out = new Set(); const visit = (x, d) => { if (!x || typeof x !== 'object' || d > 60) return; if (x.kind === 'call' && typeof x.callee === 'string' && SPLITTER.test(x.callee)) return; if (x.kind === 'ident' && x.name && !(x.hs && x.hs.functionRef)) out.add(x.name); for (const c of kids(x)) visit(c, d + 1); }; visit(e, 0); return out; };
|
|
434
|
+
const splitsIn = (e) => callsIn(e).filter((x) => SPLITTER.test(x.callee)).map((x) => `${x.callee.replace(/^.*\./, '')}|${[...names(x)].sort().join(',')}`);
|
|
330
435
|
const assignSources = new Map();
|
|
331
|
-
for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source) assignSources.set(n.target, [...(assignSources.get(n.target) || []), ...
|
|
436
|
+
for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source) assignSources.set(n.target, [...(assignSources.get(n.target) || []), ...directNames(n.source)]);
|
|
332
437
|
const withOrigins = (seed) => { const out = new Set(seed); const queue = [...seed]; while (queue.length) { const v = queue.pop(); for (const o of assignSources.get(v) || []) if (!out.has(o)) { out.add(o); queue.push(o); } } return out; };
|
|
333
438
|
const testedVars = new Set();
|
|
439
|
+
const testedSplits = new Set();
|
|
334
440
|
for (const n of fnNodes(fn)) {
|
|
335
441
|
if (n.kind !== 'if' || !n.cond) continue;
|
|
336
442
|
for (const x of walk(n.cond)) {
|
|
337
|
-
if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) for (const nm of
|
|
338
|
-
if (x.kind === 'binary' && ['==', '/=', '!='].includes(x.op) && [x.left, x.right].some((side) => side && side.kind === 'array' && side.elements.length === 0)) for (const nm of
|
|
443
|
+
if (x.kind === 'call' && typeof x.callee === 'string' && EMPTINESS_CHECK.test(x.callee)) { for (const nm of directNames(x)) testedVars.add(nm); for (const s of splitsIn(x)) testedSplits.add(s); }
|
|
444
|
+
if (x.kind === 'binary' && ['==', '/=', '!='].includes(x.op) && [x.left, x.right].some((side) => side && side.kind === 'array' && side.elements.length === 0)) for (const nm of directNames(x)) testedVars.add(nm);
|
|
339
445
|
}
|
|
340
446
|
}
|
|
341
447
|
const testedAll = withOrigins(testedVars);
|
|
342
|
-
const isTested = (args) => {
|
|
448
|
+
const isTested = (args) => {
|
|
449
|
+
if (args.some((a) => splitsIn(a).some((s) => testedSplits.has(s)))) return true;
|
|
450
|
+
const mine = withOrigins([...args.flatMap((a) => [...directNames(a)])]);
|
|
451
|
+
for (const v of mine) if (testedAll.has(v) && derivedText.has(v)) return true;
|
|
452
|
+
return false;
|
|
453
|
+
};
|
|
343
454
|
|
|
344
455
|
// A relational test against a number or a length (`total >= limit`, `size <= 65536`) in this function: the evidence that a read or a body is capped.
|
|
345
456
|
const hasSizeTest = fnNodes(fn).some((n) => n.kind === 'if' && n.cond && [...walk(n.cond)].some((x) => x.kind === 'binary' && RELATIONAL.has(x.op)));
|
|
457
|
+
// The values a relational test in this function compares with something (`n > maxBatch`, `count <= 100`): a count one of them sizes is bounded
|
|
458
|
+
// by that test (the direction is not judged; a test that only rejects negatives still counts, which errs toward silence).
|
|
459
|
+
const sizeTestedVars = new Set();
|
|
460
|
+
for (const n of fnNodes(fn)) if (n.kind === 'if' && n.cond) for (const x of walk(n.cond)) if (x.kind === 'binary' && RELATIONAL.has(x.op)) for (const nm of names(x)) sizeTestedVars.add(nm);
|
|
346
461
|
const readsRequestLength = fnNodes(fn).some((n) => [n.value, n.source, n.cond, ...(n.args || [])].some((ex) => ex && [...walk(ex)].some((x) => (x.kind === 'ident' && REQUEST_LENGTH.has(x.name)) || (x.kind === 'call' && REQUEST_LENGTH.has(x.callee)))));
|
|
347
|
-
const
|
|
462
|
+
const waiSizeChecked = fnNodes(fn).some((n) => [n.value, n.source, n.cond, ...(n.args || [])].some((ex) => ex && callsIn(ex).some((x) => WAI_SIZE_CHECK.test(x.callee))));
|
|
463
|
+
const sizeChecked = (hasSizeTest && readsRequestLength) || waiSizeChecked;
|
|
348
464
|
// One predictable-random finding per function: a clock reading that seeds a generator in a token-shaped function is a single flaw,
|
|
349
465
|
// however many of its steps (the clock, the seed, the generator) the rule can see.
|
|
350
466
|
let randomEmitted = false;
|
|
351
467
|
const emitRandom = (line, extra) => { if (randomEmitted) return; randomEmitted = true; emit('hs-predictable-random', file, line, extra); };
|
|
352
468
|
|
|
353
469
|
const seeds = (e) => { for (const n of names(e)) if (SENSITIVE.test(n.replace(/^\./, ''))) return n.replace(/^\./, ''); return null; };
|
|
470
|
+
// A parameter whose declared type is a record of this project with a credential or card field (`Card { cardNumber, cardCvv }`), written
|
|
471
|
+
// to a log WHOLE: `show card`, or the value spliced into the message. Handed to any other function (`masked card`) it is that function's
|
|
472
|
+
// result that is logged, which is judged by the names it carries, not here.
|
|
473
|
+
const sensitiveRecords = new Map((fnHs.recordParams || []).filter((r) => (r.fields || []).some((x) => SENSITIVE.test(x))).map((r) => [r.name, r]));
|
|
474
|
+
const FORMAT_WHOLE = /(?:^|\.)(?:show|tshow|showt|pack|unpack|encode|toJSON|encodeUtf8|decodeUtf8|fromStrict|toStrict|toLazyText|fromString|toString|displayShow|pShow|ppShow|textDisplay|toLogStr|logStr)$/;
|
|
475
|
+
const wholeRecord = (arg) => {
|
|
476
|
+
if (!sensitiveRecords.size) return null;
|
|
477
|
+
let found = null;
|
|
478
|
+
const visit = (e, d) => {
|
|
479
|
+
if (!e || typeof e !== 'object' || d > 40 || found) return;
|
|
480
|
+
if (e.kind === 'ident' && sensitiveRecords.has(e.name)) { const r = sensitiveRecords.get(e.name); found = { name: e.name, type: r.type, field: (r.fields || []).find((x) => SENSITIVE.test(x)) }; return; }
|
|
481
|
+
if (e.kind === 'binary') { visit(e.left, d + 1); visit(e.right, d + 1); return; }
|
|
482
|
+
if (e.kind === 'call' && typeof e.callee === 'string') { if (FORMAT_WHOLE.test(e.callee)) for (const a of e.args || []) visit(a, d + 1); return; }
|
|
483
|
+
if (e.kind === 'array') { for (const a of e.elements || []) visit(a, d + 1); return; }
|
|
484
|
+
if (e.hs && e.hs.inner) visit(e.hs.inner, d + 1);
|
|
485
|
+
};
|
|
486
|
+
visit(arg, 0);
|
|
487
|
+
return found;
|
|
488
|
+
};
|
|
354
489
|
|
|
355
490
|
// pass over nodes in program order
|
|
356
491
|
for (const node of fnNodes(fn)) {
|
|
@@ -371,9 +506,13 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
371
506
|
if (INPUT_READS.has(e.name) && !bounded && readsOutsideInput(fn, e.name, [], importsNetwork)) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
|
|
372
507
|
continue;
|
|
373
508
|
}
|
|
374
|
-
// a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")
|
|
375
|
-
|
|
376
|
-
|
|
509
|
+
// a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`, or handed to a framework's header
|
|
510
|
+
// setter: Scotty `setHeader "Set-Cookie" ("session=" <> tok <> "; Path=/")`, Spock `setHeader`, Yesod `addHeader`
|
|
511
|
+
let cookiePair = null;
|
|
512
|
+
if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) cookiePair = e.elements;
|
|
513
|
+
else if (e.kind === 'call' && typeof e.callee === 'string' && /(?:^|\.)(?:setHeader|addHeader|setResponseHeader|modifyHeader)$/.test(e.callee) && (e.args || []).length >= 2) cookiePair = [e.args[0], e.args[1]];
|
|
514
|
+
if (cookiePair) {
|
|
515
|
+
const [k, v] = cookiePair;
|
|
377
516
|
const isSetCookie = k && ((k.kind === 'ident' && SET_COOKIE_HEADER.has(k.name)) || (k.kind === 'literal' && /^set-cookie$/i.test(String(k.value))) || (k.kind === 'call' && /(?:^|\.)(?:pack|fromString)$/.test(k.callee || '') && k.args && k.args[0] && k.args[0].kind === 'literal' && /^set-cookie$/i.test(String(k.args[0].value))));
|
|
378
517
|
if (isSetCookie && v) {
|
|
379
518
|
const text = literalTextOf(v);
|
|
@@ -426,7 +565,10 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
426
565
|
// scotty-cookie's helpers build a cookie that carries neither Secure nor HttpOnly
|
|
427
566
|
if ((c === 'Web.Scotty.Cookie.setSimpleCookie' || c === 'Web.Scotty.Cookie.makeSimpleCookie') && args[0]) {
|
|
428
567
|
const nm = args[0];
|
|
429
|
-
|
|
568
|
+
// A name that is a PARAMETER of this function is decided by the caller: the function is itself a cookie helper
|
|
569
|
+
// (scotty's own `setSimpleCookie n v`, `deleteCookie c`), and the call that passes a session name is the site to report.
|
|
570
|
+
const ownParam = nm.kind === 'ident' && Array.isArray(fn.params) && fn.params.includes(nm.name);
|
|
571
|
+
if (!ownParam && (nm.kind !== 'literal' || SESSION_COOKIE.test(String(nm.value)))) emit('hs-insecure-cookie', file, line, { detail: 'HttpOnly missing, Secure missing', evidence: { httpOnly: false, secure: false, helper: c } });
|
|
430
572
|
}
|
|
431
573
|
// a socket read repeated by a recursive loop, with no size test anywhere in it
|
|
432
574
|
if (SOCKET_READS.has(c) && !hasSizeTest && !bounded && fn.calls.some((x) => x.callee === fn.name && x.hs && x.hs.target === fn.qid)) {
|
|
@@ -451,8 +593,9 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
451
593
|
}
|
|
452
594
|
}
|
|
453
595
|
// password hashed with a fast hash
|
|
454
|
-
|
|
455
|
-
|
|
596
|
+
// (a hash call the IR inlined from a project helper, `digestFor (credPassword creds)` at the comparison, is reported once, in the helper)
|
|
597
|
+
if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && !(e.hs && e.hs.inlinedFrom) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
|
|
598
|
+
emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value`, inlineCtx: e.hs && e.hs.inlineCtx });
|
|
456
599
|
}
|
|
457
600
|
if (PBKDF2_CALLS.has(c)) {
|
|
458
601
|
for (const a of args) for (const x of walk(a)) if (x.kind === 'object') for (const p of x.props || []) if (p.key === 'iterCounts' && p.value && p.value.kind === 'literal' && typeof p.value.value === 'number' && p.value.value < 10000) {
|
|
@@ -463,7 +606,7 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
463
606
|
if (ECB_CALLS.has(c)) emit('hs-ecb-mode', file, line, { detail: c });
|
|
464
607
|
if (IV_CALLS.has(c) && args[0] && isLiteralLike(args[0])) emit('hs-static-iv', file, line, { detail: `${c} of a constant value` });
|
|
465
608
|
// randomness bound to a security-shaped name
|
|
466
|
-
if (RANDOM_CALLS.has(c) && node.kind === 'assign' && SECURITY_VAR.test(node.target || '')) {
|
|
609
|
+
if (RANDOM_CALLS.has(c) && node.kind === 'assign' && (SECURITY_VAR.test(node.target || '') || (CONTEXT_CODE_VAR.test(node.target || '') && fnMentions(fn, CODE_CONTEXT)))) {
|
|
467
610
|
emitRandom(line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
|
|
468
611
|
}
|
|
469
612
|
if (RANDOM_CALLS.has(c) && SECURITY_FN.test(fnBase) && node.kind !== 'assign') {
|
|
@@ -472,7 +615,8 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
472
615
|
if (TIME_SOURCES.has(c) && SECURITY_FN.test(fnBase)) emitRandom(line, { vuln: 'Clock value used as a security value', detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
|
|
473
616
|
if (INPUT_READS.has(c) && !bounded && readsOutsideInput(fn, c, args, importsNetwork)) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
|
|
474
617
|
// an allocation whose size is parsed caller text and is not clamped
|
|
475
|
-
|
|
618
|
+
// (a number the framework parsed from the request, `n <- param "count" :: ActionM Int`, is parsed caller text too)
|
|
619
|
+
if (ALLOCATORS.has(c) && args[0] && mentionsText(args[0]) && (mentionsRequest(args[0]) || callsIn(args[0]).some((x) => /(?:^|\.)(?:read|readMaybe|readEither)$/.test(x.callee))) && !callsIn(args[0]).some((x) => CLAMPS.test(x.callee)) && ![...names(args[0])].some((v) => sizeTestedVars.has(v))) {
|
|
476
620
|
emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
|
|
477
621
|
}
|
|
478
622
|
// a partial function applied to caller text: one malformed value raises an uncaught exception
|
|
@@ -510,6 +654,14 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
510
654
|
for (const n of srcNames) if (sensitive.has(n)) { const s = sensitive.get(n); cls = { name: s.name, low: s.low }; hashed = hashed || s.hashed; }
|
|
511
655
|
if (cls) {
|
|
512
656
|
let cleared = false;
|
|
657
|
+
// a yes/no answer about the credential (`accepted <- checkCredentials attempt`, a comparison, a conjunction) carries no credential text
|
|
658
|
+
let root = src; for (let i = 0; i < 6 && root && root.kind === 'call' && typeof root.callee === 'string' && /(?:^|\.)(?:liftIO|pure|return|lift)$/.test(root.callee) && (root.args || []).length === 1; i++) root = root.args[0];
|
|
659
|
+
if (root && ((root.kind === 'binary' && ['==', '/=', '!=', '&&', '||', '<', '>', '<=', '>='].includes(root.op)) || root.kind === 'logical')) cleared = true;
|
|
660
|
+
// the binding says it holds a hash or digest (`hashedPassword <- hashPassword plain`): the stored form, whatever made it
|
|
661
|
+
if (HASH_NAMED.test(node.target || '')) cleared = true;
|
|
662
|
+
if (root && root.kind === 'call' && typeof root.callee === 'string' && /(?:^|\.)(?:not|elem|notElem|null|isJust|isNothing|any|all|and|or)$/.test(root.callee)) cleared = true;
|
|
663
|
+
// a project helper whose every return is such an answer (`checkCredentials a = pure (user a == "x" && password a == "y")`)
|
|
664
|
+
if (root && root.kind === 'call' && typeof root.callee === 'string' && returnsYesNo(fir, root.callee)) cleared = true;
|
|
513
665
|
for (const x of walk(src)) {
|
|
514
666
|
if (x.kind !== 'call' || typeof x.callee !== 'string') continue;
|
|
515
667
|
if (REDACTOR.test(x.callee)) cleared = true;
|
|
@@ -532,7 +684,8 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
532
684
|
if (!argList.length || argList.every((a) => !a)) continue;
|
|
533
685
|
// a file append is a log only when its path says so (a log, audit or trace file)
|
|
534
686
|
if (sink.channel === 'file') { const p = (e.args || [])[0]; if (!(p && p.kind === 'literal' && /(?:log|audit|trace|journal)/i.test(String(p.value)))) continue; }
|
|
535
|
-
|
|
687
|
+
// (a project function the IR inlined into the argument, `masked card`, keeps its origin in `hs.inlinedFrom`: a redactor by name earns the same credit inlined as called)
|
|
688
|
+
const isRedacted = (a) => { for (const x of walk(a)) { if (x.kind === 'call' && typeof x.callee === 'string' && (REDACTOR.test(x.callee) || STRONG_KDF.test(x.callee) || x.callee === 'const')) return true; if (x.hs && typeof x.hs.inlinedFrom === 'string' && REDACTOR.test(x.hs.inlinedFrom.replace(/^.*::/, ''))) return true; } return false; };
|
|
536
689
|
for (const arg of argList) {
|
|
537
690
|
if (!arg) continue;
|
|
538
691
|
let hit = null;
|
|
@@ -547,12 +700,19 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
547
700
|
for (const n of names(arg)) {
|
|
548
701
|
if (hit) break;
|
|
549
702
|
const bare = n.replace(/^\./, '');
|
|
550
|
-
|
|
551
|
-
|
|
703
|
+
// a value built from a credential (`attempt = Attempt user password`) logged through a field that is not the credential
|
|
704
|
+
// (`attemptUser attempt`, lowered to a member of `attempt`) is that field, not the secret
|
|
705
|
+
if (sensitive.has(n) && !usedOnlyAsNonSensitiveField(arg, n)) hit = { ...sensitive.get(n), var: n };
|
|
706
|
+
// a name that says it holds a hash or digest of the credential (`hashedPassword`, `tokenDigest`) is the stored form, not the secret
|
|
707
|
+
else if (SENSITIVE.test(bare) && !/^[A-Z][A-Za-z0-9_.]*\./.test(n) && !HASH_NAMED.test(bare)) hit = { name: bare, low: LOW_ENTROPY.test(bare), hashed: false, var: n };
|
|
708
|
+
// the record itself, logged whole (`show attempt`), when one of its fields was built from a credential (`attempt.attemptPassword`)
|
|
709
|
+
else if (!sensitive.has(n)) { const field = [...sensitive.keys()].find((k) => k.startsWith(`${n}.`)); if (field && !usedOnlyAsNonSensitiveField(arg, n)) hit = { ...sensitive.get(field), name: field, var: n }; }
|
|
552
710
|
if (hit) break;
|
|
553
711
|
}
|
|
712
|
+
if (!hit) { const rec = wholeRecord(arg); if (rec) hit = { name: `${rec.type}.${rec.field}`, low: LOW_ENTROPY.test(rec.field), hashed: false, var: rec.name }; }
|
|
554
713
|
if (hit && !isRedacted(arg)) {
|
|
555
714
|
emit('hs-sensitive-logging', file, e.line || node.line, {
|
|
715
|
+
inlineCtx: e.hs && e.hs.inlineCtx,
|
|
556
716
|
severity: hit.hashed ? 'low' : 'high',
|
|
557
717
|
detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
|
|
558
718
|
evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
|
|
@@ -563,6 +723,20 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
563
723
|
}
|
|
564
724
|
}
|
|
565
725
|
}
|
|
726
|
+
// A security-shaped function (`generateApiKey`, `newResetCode`) whose RESULT is built from a System.Random value: the generator was bound
|
|
727
|
+
// to an ordinary name (`g <- newStdGen`, `let bytes = take 16 (randoms g)`), so neither the binding rule nor the "feeds the function" rule
|
|
728
|
+
// above saw it; the value reaching the function's return is what makes it the token.
|
|
729
|
+
if (!randomEmitted && SECURITY_FN.test(fnBase)) {
|
|
730
|
+
const hasRandom = (e) => { for (const x of walk(e)) if ((x.kind === 'call' && RANDOM_CALLS.has(x.callee)) || (x.kind === 'ident' && RANDOM_CALLS.has(x.name) && !(x.hs && x.hs.functionRef))) return true; return false; };
|
|
731
|
+
const carriers = new Set(); let seedLine = null;
|
|
732
|
+
for (let round = 0; round < 8; round++) {
|
|
733
|
+
let grew = false;
|
|
734
|
+
for (const n of fnNodes(fn)) if (n.kind === 'assign' && n.target && n.source && !carriers.has(n.target) && (hasRandom(n.source) || [...names(n.source)].some((x) => carriers.has(x)))) { carriers.add(n.target); if (seedLine === null || (Number.isFinite(n.line) && n.line < seedLine)) seedLine = n.line; grew = true; }
|
|
735
|
+
if (!grew) break;
|
|
736
|
+
}
|
|
737
|
+
const ret = fnReturns.find((n) => hasRandom(n.value) || [...names(n.value)].some((x) => carriers.has(x)));
|
|
738
|
+
if (ret) emitRandom(ret.line || seedLine || fn.line, { detail: `a System.Random value reaches the result of \`${fnBase}\``, evidence: { function: fnBase, via: [...carriers].slice(0, 4) } });
|
|
739
|
+
}
|
|
566
740
|
}
|
|
567
741
|
}
|
|
568
742
|
|
|
@@ -571,5 +745,16 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
571
745
|
const out = [];
|
|
572
746
|
for (const f of findings) { if (seen.has(f.id)) continue; seen.add(f.id); out.push(f); }
|
|
573
747
|
void notes; void opts;
|
|
574
|
-
|
|
748
|
+
// A call-site copy of a finding the helper itself reports (the helper's body was inlined at the call) is dropped; a call-site finding
|
|
749
|
+
// whose helper reports nothing (the password shape is visible only at the call) is kept.
|
|
750
|
+
// The kept finding lists the call sites it also covers (`alsoAt`), so a reviewer still sees every line of the one weakness.
|
|
751
|
+
const byKey = new Map(out.map((f) => [`${f.rule}|${f.file}|${f._fnQid}`, f]));
|
|
752
|
+
const deduped = out.filter((f) => {
|
|
753
|
+
const helper = f.inlineCtx && f._fnQid !== f.inlineCtx ? byKey.get(`${f.rule}|${f.file}|${f.inlineCtx}`) : null;
|
|
754
|
+
if (!helper) return true;
|
|
755
|
+
helper.alsoAt = [...(helper.alsoAt || []), { line: f.line, reason: 'the same helper inlined at this call' }];
|
|
756
|
+
return false;
|
|
757
|
+
});
|
|
758
|
+
for (const f of deduped) delete f._fnQid;
|
|
759
|
+
return { findings: deduped, controls, notes };
|
|
575
760
|
}
|
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
// is lowered to `info`, never dropped
|
|
11
11
|
// api-contract the value is a parameter of an exported function that nothing in the analysed code calls, and the function
|
|
12
12
|
// hands it WHOLE (or as the root it appends fixed text to) to a path or command operation: a thin wrapper.
|
|
13
|
-
// The caller chose what to read or run, and the wrapper has no fixed part to escape from. Lowered to `info
|
|
13
|
+
// The caller chose what to read or run, and the wrapper has no fixed part to escape from. Lowered to `info`.
|
|
14
|
+
// Path operations joined this way since 0.195.9: `renderFile path = responseFile .. path` and
|
|
15
|
+
// `store path = readFile (cs path)` were 2 of the 29 flagged path sites on the real-code sets and no true
|
|
16
|
+
// positive had that shape; a parameter spliced UNDER a fixed directory (`"/srv/" ++ name`) is still kept,
|
|
17
|
+
// because the function chose the directory and lets the caller leave it
|
|
14
18
|
// entry-parameter a parameter of such a function used inside a larger value the function builds: its own construction, kept
|
|
15
19
|
// data-input standard input or a file's content in a service or a library: data the program processes, kept
|
|
16
20
|
// unattributed the engine recorded no origin: kept
|
|
@@ -26,11 +30,17 @@ import { findSites, identNames } from './haskell-guards.js';
|
|
|
26
30
|
export const TRUST_VERDICTS = Object.freeze(['attacker-reachable', 'operator-controlled', 'api-contract', 'entry-parameter', 'data-input', 'unattributed']);
|
|
27
31
|
|
|
28
32
|
const OPERATOR = new Set(['cli', 'env']);
|
|
29
|
-
const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network']);
|
|
33
|
+
const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'cookie', 'network', 'upload']);
|
|
30
34
|
const DATA = new Set(['stdin', 'file-read']);
|
|
31
35
|
const WEAK_PARAM_LABEL = /\(caller-controlled\)$/;
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
// CWE-918 is deliberately NOT a wrapper family: `fetch url = parseRequest url >>= httpNoBody` is pinned as a finding in
|
|
37
|
+
// test/haskell/haskell-generalization.test.js (a URL the caller chooses is the SSRF shape of the development sets). unseen-v4 labels
|
|
38
|
+
// that shape safe when the URL is configuration; that disagreement is recorded, not decided here. CWE-22 joined the set in 0.195.9
|
|
39
|
+
// (see the header): the synthetic development sets label `load name = readFile name` vulnerable, the real-code sets label every such
|
|
40
|
+
// site a false positive, and the real code won; a parameter under a fixed directory is still `entry-parameter`.
|
|
41
|
+
const WRAPPER_FAMILIES = new Set(['CWE-78', 'CWE-88', 'CWE-22']);
|
|
42
|
+
// `cs`/`convertString` (string-conversions) and `toS` (protolude) change the type of a text value and nothing else
|
|
43
|
+
const CONVERSIONS = /(?:^|\.)(?:pack|unpack|fromString|toString|show|encodeUtf8|decodeUtf8|toStrict|fromStrict|toText|unpackFS|fromText|cs|convertString|toS|toSL|toST)$/;
|
|
34
44
|
const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
|
|
35
45
|
// list plumbing that only lays the parameters out as text (`unwords args`, `concatMap (' ':) args`): no fixed words are added
|
|
36
46
|
const LIST_LAYOUT = /(?:^|\.)(?:unwords|unlines|intercalate|concatMap|concat|map|fmap)$/;
|
|
@@ -176,7 +186,8 @@ function passThroughVerdict(perFile, f) {
|
|
|
176
186
|
if (!weak.size) return null;
|
|
177
187
|
const own = new Set(Array.isArray(analysed.params) ? analysed.params : []);
|
|
178
188
|
const res = localResolver(analysed);
|
|
179
|
-
|
|
189
|
+
// (a name bound once in the function stands for its definition here too, so `req <- parseRequest url; httpLbs req m` reaches `url`; a record update is a union of base and update)
|
|
190
|
+
return args.every((a) => isPassThrough(a, own, family, res)) && args.some((a) => { const seen = new Set(); const walkIds = (e, d = 0) => { if (!e || d > 12) return; if (e.kind === 'ident') { seen.add(e.name); const r = res(e); if (r && r !== e) walkIds(r, d + 1); } for (const k of ['left', 'right', 'object', 'value']) if (e[k] && typeof e[k] === 'object') walkIds(e[k], d + 1); for (const k of ['args', 'elements', 'branches']) if (Array.isArray(e[k])) e[k].forEach((x) => walkIds(x, d + 1)); }; let x = a; for (let i = 0; i < 4; i++) { const r = res(x); if (r === x) break; x = r; } walkIds(x); return [...seen].some((n) => weak.has(n)); }) ? 'direct' : null;
|
|
180
191
|
}
|
|
181
192
|
// the sink is in a callee
|
|
182
193
|
const own = new Set(Array.isArray(sink.fn.params) ? sink.fn.params : []);
|
|
@@ -257,9 +268,14 @@ export function applyTrustBoundary(findings, perFile) {
|
|
|
257
268
|
else if (roots.length && [...classes].every((c) => c === 'operator')) {
|
|
258
269
|
verdict = 'operator-controlled';
|
|
259
270
|
reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
|
|
260
|
-
} else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance))) {
|
|
271
|
+
} else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance) || (f.cwe === 'CWE-22' && r.provenance === 'stdin'))) {
|
|
272
|
+
// Standard input counts here for a PATH operation only: a command-line program reading a file name from its own standard input
|
|
273
|
+
// is the person at the keyboard naming a file (14 of the 29 flagged path sites on the real-code sets, every one a false positive).
|
|
274
|
+
// A command built from standard input stays reported (`getLine >>= callCommand`, pinned in test/haskell/haskell-trust-boundary.test.js).
|
|
261
275
|
verdict = 'operator-controlled';
|
|
262
|
-
reason =
|
|
276
|
+
reason = f.cwe === 'CWE-22' && roots.some((r) => r.provenance === 'stdin')
|
|
277
|
+
? 'the function belongs to a command-line program (reached from a main that imports no server framework) and the file it works on is named by the person running it: on its standard input, in files it was pointed at, or in its arguments and environment'
|
|
278
|
+
: 'the function belongs to a command-line program (reached from a main that imports no server framework) and the value comes from files the person running it pointed the program at, or from its arguments and environment; standard input is covered by this rule only for a path operation';
|
|
263
279
|
} else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
|
|
264
280
|
else if (classes.has('weak-param')) {
|
|
265
281
|
verdict = 'entry-parameter';
|