@clear-capabilities/agentic-security-scanner 0.195.8 → 0.195.10
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 +22 -0
- package/dist/{06198b6f3fac9b1cb25f.json → 312ef9976a988b322ade.json} +3 -3
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/language/CLAUDE.md +14 -3
- package/src/language/haskell-fix.js +251 -5
- package/src/language/haskell-guards.js +131 -5
- package/src/language/haskell-ir.js +9 -3
- package/src/language/haskell-models.js +5 -0
- package/src/language/haskell-security-rules.js +173 -13
- package/src/language/haskell-trust.js +19 -7
- package/src/language/haskell-web.js +9 -2
- 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/language/support-registry.js +2 -0
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// has the prefix. A weak guard is reported as a note on the finding and does not refute it.
|
|
18
18
|
|
|
19
19
|
const CANON_CALLS = new Set(['System.Directory.canonicalizePath', 'System.Directory.makeAbsolute', 'System.FilePath.normalise']);
|
|
20
|
+
import { HS_SANITIZERS } from './haskell-models.js';
|
|
21
|
+
// the catalog's HTML escapers (blaze, lucid): the same list the taint catalog is generated from
|
|
22
|
+
const CATALOG_HTML_ESCAPERS = new Set(HS_SANITIZERS.filter((s) => (s.appliesTo || []).includes('xss')).map((s) => `${s.module}.${s.name}`));
|
|
20
23
|
const EXIT_CALLS = new Set(['Control.Exception.throwIO', 'Control.Exception.throw', 'System.Exit.die', 'System.Exit.exitFailure', 'System.Exit.exitWith', 'throwIO', 'throw', 'die', 'exitFailure', 'exitWith', 'error', 'fail', 'ioError', 'Control.Monad.Fail.fail']);
|
|
21
24
|
const WHEN = new Set(['Control.Monad.when', 'when']);
|
|
22
25
|
const UNLESS = new Set(['Control.Monad.unless', 'unless']);
|
|
@@ -336,10 +339,15 @@ export function judgeFinding(f, perFile) {
|
|
|
336
339
|
const fam = FAMILY_BY_CWE[f.cwe];
|
|
337
340
|
const ir = perFile && perFile[f.file];
|
|
338
341
|
if (!fam || !ir || !f._funcQid || !f.callee) return { status: 'none' };
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
342
|
+
// A finding found while analysing a CALLER (`_funcQid`) is located at the callee's line: the sink function is the one in the
|
|
343
|
+
// finding's file that has the call at that line (the caller first, then the others), the way haskell-trust.js locates it.
|
|
344
|
+
const direct = ir.functions.find((x) => x.qid === f._funcQid);
|
|
345
|
+
let fn = null; let sites = [];
|
|
346
|
+
for (const cand of [direct, ...ir.functions.filter((x) => x !== direct)].filter(Boolean)) {
|
|
347
|
+
const s = findSites(cand, f.callee, f.line);
|
|
348
|
+
if (s.length) { fn = cand; sites = s; break; }
|
|
349
|
+
}
|
|
350
|
+
if (!fn || !sites.length) return { status: 'none' };
|
|
343
351
|
let weak = null;
|
|
344
352
|
let first = null;
|
|
345
353
|
for (const site of sites) {
|
|
@@ -354,7 +362,46 @@ export function judgeFinding(f, perFile) {
|
|
|
354
362
|
const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
|
|
355
363
|
let refuted = null;
|
|
356
364
|
if (fam === 'xss') {
|
|
357
|
-
|
|
365
|
+
// the project's own escapers plus the catalog's HTML escapers (blaze/lucid `toHtml`, `text`, ...): the taint walk records the latter on
|
|
366
|
+
// the path for the proof gate, but a sink argument built by a HELPER (`html (renderHtml (greeting name))`) is judged here, where the
|
|
367
|
+
// helper's body can be read: a helper that uses the parameter receiving the tainted value only under an escaper escapes it
|
|
368
|
+
const escapers = new Set([...htmlEscapers(perFile), ...CATALOG_HTML_ESCAPERS]);
|
|
369
|
+
const helperByName = (callee) => ir.functions.find((x) => x.name === callee || x.qid === callee || (x.qid || '').endsWith(`::${callee}`)) || null;
|
|
370
|
+
const paramEscaped = (helper, param, depth) => {
|
|
371
|
+
if (depth > 2) return false;
|
|
372
|
+
const under = (e, inEsc, d) => {
|
|
373
|
+
if (!e || typeof e !== 'object' || d > 40) return true;
|
|
374
|
+
if (e.kind === 'ident' && e.name === param) return inEsc;
|
|
375
|
+
if (e.kind === 'call' && typeof e.callee === 'string') {
|
|
376
|
+
if (escapers.has(e.callee)) return true;
|
|
377
|
+
const h = helperByName(e.callee);
|
|
378
|
+
if (h && Array.isArray(h.params)) {
|
|
379
|
+
// a nested helper: each argument that mentions the parameter must land on a parameter the nested helper escapes
|
|
380
|
+
return (e.args || []).every((a, i) => !identNames(a).has(param) || (h.params[i] && paramEscaped(h, h.params[i], depth + 1) && under(a, true, d + 1)));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
for (const [, c] of exprChildren(e)) if (!under(c, inEsc, d + 1)) return false;
|
|
384
|
+
return true;
|
|
385
|
+
};
|
|
386
|
+
for (const n of nodeList(helper)) {
|
|
387
|
+
for (const k of ['source', 'value', 'cond']) if (n[k] && !under(n[k], false, 0)) return false;
|
|
388
|
+
if (Array.isArray(n.args)) for (const a of n.args) if (!under(a, false, 0)) return false;
|
|
389
|
+
}
|
|
390
|
+
return true;
|
|
391
|
+
};
|
|
392
|
+
const addHelperEscapers = (e, d) => {
|
|
393
|
+
if (!e || typeof e !== 'object' || d > 40) return;
|
|
394
|
+
if (e.kind === 'call' && typeof e.callee === 'string' && !escapers.has(e.callee)) {
|
|
395
|
+
const h = helperByName(e.callee);
|
|
396
|
+
if (h && Array.isArray(h.params) && (e.args || []).length) {
|
|
397
|
+
const taintedPositions = (e.args || []).map((a, i) => ([...identNames(a)].some((v) => vars.has(v) || derived.has(v)) ? i : -1)).filter((i) => i >= 0);
|
|
398
|
+
if (taintedPositions.length && taintedPositions.every((i) => h.params[i] && paramEscaped(h, h.params[i], 0))) escapers.add(e.callee);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
for (const [, c] of exprChildren(e)) addHelperEscapers(c, d + 1);
|
|
402
|
+
};
|
|
403
|
+
addHelperEscapers(argExpr, 0);
|
|
404
|
+
for (const v of identNames(argExpr)) for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === v && n.source) addHelperEscapers(n.source, 0);
|
|
358
405
|
const calls = [];
|
|
359
406
|
const find = (e, d) => { if (!e || typeof e !== 'object' || d > 40) return; if (e.kind === 'call' && escapers.has(e.callee)) calls.push(e); for (const [, c] of exprChildren(e)) find(c, d + 1); };
|
|
360
407
|
// the escaper may sit behind one or more local bindings (`page = ... path ...`, `path = escape (...)`): follow them, each once
|
|
@@ -507,6 +554,75 @@ export function waiHtmlContext(f, perFile) {
|
|
|
507
554
|
return 'unknown';
|
|
508
555
|
}
|
|
509
556
|
|
|
557
|
+
// ── a request whose host is fixed ────────────────────────────────────────────
|
|
558
|
+
// SSRF is the client choosing the HOST. A URL built as `fixedBase <> clientText` where the fixed part is a literal (or a constant bound to
|
|
559
|
+
// one) that already names a scheme, a host and a "/" cannot reach another host: the client text lands in the path, query or fragment.
|
|
560
|
+
// Likewise a request value built by http-conduit's modifiers (`setRequestQueryString q base`, `setRequestPath p base`, ...) over a fixed
|
|
561
|
+
// base request keeps that base's host; the modifiers take the base as their LAST argument. A record update of such a value is judged by
|
|
562
|
+
// its base unless it sets a field that names the host.
|
|
563
|
+
const HOST_ANCHORED = /^[a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/[^\s]*$/i;
|
|
564
|
+
const REQUEST_BUILDERS = /(?:^|\.)(?:parseRequest|parseRequest_|parseUrlThrow|parseUrl|requestFromURI|requestFromURI_)$/;
|
|
565
|
+
const REQUEST_MODIFIERS = /(?:^|\.)(?:setRequestQueryString|setRequestQuery|setQueryString|setRequestPath|setRequestBody|setRequestBodyLBS|setRequestBodyJSON|setRequestBodyURLEncoded|setRequestBodyFile|setRequestBodySource|setRequestHeader|setRequestHeaders|addRequestHeader|setRequestMethod|setRequestBasicAuth|setRequestBearerAuth|setRequestIgnoreStatus|setRequestResponseTimeout|setRequestCheckStatus|setRequestManager|urlEncodedBody|applyBasicAuth|applyBearerAuth)$/;
|
|
566
|
+
const PEEL_WRAPPERS = /(?:^|\.)(?:liftIO|pure|return|unpack|pack|fromString|toStrict|fromStrict|encodeUtf8|decodeUtf8|toText|fromText)$/;
|
|
567
|
+
const HOST_FIELD = /^(?:host|port|secure|url|uri|requestHost)$/i;
|
|
568
|
+
|
|
569
|
+
function fixedHostRequest(f, perFile) {
|
|
570
|
+
if (!f || f.cwe !== 'CWE-918') return null;
|
|
571
|
+
const ir = perFile && perFile[f.file];
|
|
572
|
+
if (!ir || !f.callee) return null;
|
|
573
|
+
const direct = ir.functions.find((x) => x.qid === f._funcQid);
|
|
574
|
+
let fn = null; let sites = [];
|
|
575
|
+
for (const cand of [direct, ...ir.functions.filter((x) => x !== direct)].filter(Boolean)) {
|
|
576
|
+
const s = findSites(cand, f.callee, f.line);
|
|
577
|
+
if (s.length) { fn = cand; sites = s; break; }
|
|
578
|
+
}
|
|
579
|
+
if (!fn || !sites.length) return null;
|
|
580
|
+
const argIdx = Number.isInteger(f.argIndex) ? f.argIndex : 0;
|
|
581
|
+
// a local name bound once stands for its definition; a zero-parameter function of the module whose body is one literal is a constant
|
|
582
|
+
const localDef = (name) => { const defs = []; for (const n of nodeList(fn)) if (n.kind === 'assign' && n.target === name && n.source) defs.push(n.source); return defs.length === 1 ? defs[0] : null; };
|
|
583
|
+
const constDef = (name) => {
|
|
584
|
+
const bare = name.replace(/^.*\./, '');
|
|
585
|
+
const c = ir.functions.find((x) => (x.name === name || x.qid === name || (x.name || '').replace(/^.*\./, '') === bare) && (!Array.isArray(x.params) || x.params.length === 0));
|
|
586
|
+
if (!c) return null;
|
|
587
|
+
const rets = Object.values(c.cfg.nodes).filter((n) => n.kind === 'return' && n.value);
|
|
588
|
+
return rets.length === 1 ? rets[0].value : null;
|
|
589
|
+
};
|
|
590
|
+
let evidence = null;
|
|
591
|
+
const fixed = (e, seen, depth) => {
|
|
592
|
+
if (!e || typeof e !== 'object' || depth > 24) return false;
|
|
593
|
+
if (e.kind === 'literal') { const ok = typeof e.value === 'string' && HOST_ANCHORED.test(e.value); if (ok) evidence = e.value.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/@?#\s]+\/).*$/i, '$1'); return ok; }
|
|
594
|
+
if (e.kind === 'ident') {
|
|
595
|
+
if (seen.has(e.name)) return false;
|
|
596
|
+
seen.add(e.name);
|
|
597
|
+
const d = localDef(e.name) || constDef(e.name);
|
|
598
|
+
return d ? fixed(d, seen, depth + 1) : false;
|
|
599
|
+
}
|
|
600
|
+
if (e.kind === 'union') return Array.isArray(e.branches) && e.branches.length > 0 && e.branches.every((b) => fixed(b, new Set(seen), depth + 1));
|
|
601
|
+
if (e.kind === 'object' && Array.isArray(e.props)) {
|
|
602
|
+
// a record update: `{ method = "POST" }` joined (as a union) with its base; a field that names the host makes it unknown
|
|
603
|
+
if (e.props.some((p) => HOST_FIELD.test(String(p.key || p.name || '')))) return false;
|
|
604
|
+
return e.object ? fixed(e.object, seen, depth + 1) : true;
|
|
605
|
+
}
|
|
606
|
+
if (e.kind === 'binary' && (e.op === '<>' || e.op === '++' || (e.hs && e.hs.concat))) return fixed(e.left, seen, depth + 1);
|
|
607
|
+
if (e.kind === 'call' && typeof e.callee === 'string') {
|
|
608
|
+
const args = e.args || [];
|
|
609
|
+
if (PEEL_WRAPPERS.test(e.callee) && args.length === 1) return fixed(args[0], seen, depth + 1);
|
|
610
|
+
if (REQUEST_BUILDERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
|
|
611
|
+
if (REQUEST_MODIFIERS.test(e.callee) && args.length >= 1) return fixed(args[args.length - 1], seen, depth + 1);
|
|
612
|
+
if (/(?:^|\.)(?:<\/>|combine|joinPath)$/.test(e.callee) && args.length === 2) return fixed(args[0], seen, depth + 1);
|
|
613
|
+
}
|
|
614
|
+
return false;
|
|
615
|
+
};
|
|
616
|
+
for (const site of sites) {
|
|
617
|
+
const arg = (site.call.args || [])[argIdx];
|
|
618
|
+
if (!arg) return null;
|
|
619
|
+
// a record update is a union of the base and the update: a union judged branch by branch above; an `object` branch with no base is the
|
|
620
|
+
// update alone, which is fixed when it names no host field, so the base branch decides
|
|
621
|
+
if (!fixed(arg, new Set(), 0)) return null;
|
|
622
|
+
}
|
|
623
|
+
return { kind: 'fixed-host-request', base: evidence };
|
|
624
|
+
}
|
|
625
|
+
|
|
510
626
|
export function applyHaskellGuards(findings, perFile) {
|
|
511
627
|
const stats = { refuted: 0, weak: 0 };
|
|
512
628
|
for (const f of findings || []) {
|
|
@@ -522,6 +638,16 @@ export function applyHaskellGuards(findings, perFile) {
|
|
|
522
638
|
}
|
|
523
639
|
if (ctx === 'unknown' && !f._htmlContextUnknown) { f._htmlContextUnknown = true; f.uncertainty = [...(f.uncertainty || []), { kind: 'unread-content-type', detail: 'the response headers at this call are not literal text, so whether the body is served as HTML was not read' }]; }
|
|
524
640
|
} catch { /* judged below */ }
|
|
641
|
+
try {
|
|
642
|
+
const fh = fixedHostRequest(f, perFile);
|
|
643
|
+
if (fh && !f._fixedHost) {
|
|
644
|
+
f._fixedHost = true; f.severity = 'info';
|
|
645
|
+
f.controls = [...(f.controls || []), { kind: 'fixed-host-request', evidenceKind: 'source' }];
|
|
646
|
+
f.guardNote = { kind: 'fixed-host-request', reason: `the request's host is fixed by the literal${fh.base ? ` "${fh.base}"` : ''} the URL or base request starts from; the client's text can only choose the path, query or fragment, which is not a server-side request forgery (a path it chooses on that host is still worth a look)` };
|
|
647
|
+
stats.weak++;
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
} catch { /* judged below */ }
|
|
525
651
|
try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
|
|
526
652
|
try {
|
|
527
653
|
const qp = quotedPositionals(f, perFile);
|
|
@@ -148,14 +148,20 @@ function servantEndpointParams(mod, fn, sig, exported) {
|
|
|
148
148
|
};
|
|
149
149
|
const body = slice.slice(eq + 1);
|
|
150
150
|
if (split(body, ':<|>').filter((p) => p.length).length !== 1) return [];
|
|
151
|
+
const parts = split(body, ':>').filter((p) => p.length);
|
|
152
|
+
// The endpoint's values are the LAST parameters of the handler: `orderServer conn ref` for `QueryParam "ref" Text` takes `conn` from the
|
|
153
|
+
// closure. Pair from the end (the same rule haskell-web.js applies); a handler with fewer parameters than the API names is not paired.
|
|
154
|
+
const reads = parts.filter((p) => p[0] && p[0].t === 'con' && (SERVANT_PRINCIPAL.has(p[0].v) || SERVANT_READS.has(p[0].v))).length;
|
|
155
|
+
const offset = fn.params.length - reads;
|
|
156
|
+
if (offset < 0) return [];
|
|
151
157
|
const out = [];
|
|
152
158
|
let idx = 0;
|
|
153
|
-
for (const part of
|
|
159
|
+
for (const part of parts) {
|
|
154
160
|
const head = part[0];
|
|
155
161
|
if (!head || head.t !== 'con') continue;
|
|
156
162
|
const isPrincipal = SERVANT_PRINCIPAL.has(head.v);
|
|
157
163
|
if (!isPrincipal && !SERVANT_READS.has(head.v)) continue;
|
|
158
|
-
const name = fn.params[idx];
|
|
164
|
+
const name = fn.params[offset + idx];
|
|
159
165
|
idx++;
|
|
160
166
|
if (isPrincipal || !name) continue;
|
|
161
167
|
// the carried type is what follows the name string (or the content-type list for a body): its text decides whether it is text
|
|
@@ -163,7 +169,7 @@ function servantEndpointParams(mod, fn, sig, exported) {
|
|
|
163
169
|
let rest = part.slice(lastStr >= 0 ? lastStr + 1 : 1);
|
|
164
170
|
if (/^ReqBody/.test(head.v)) { const close = rest.findIndex((t) => t.t === 'sp' && t.v === ']'); rest = close >= 0 ? rest.slice(close + 1) : rest; }
|
|
165
171
|
const type = rest.map((t) => t.v).join(' ').trim();
|
|
166
|
-
if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: idx - 1, name, decorator: 'hs:handler-param:servant' });
|
|
172
|
+
if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: offset + idx - 1, name, decorator: 'hs:handler-param:servant' });
|
|
167
173
|
}
|
|
168
174
|
return out;
|
|
169
175
|
}
|
|
@@ -380,6 +380,8 @@ export const HS_LOG_SINKS = Object.freeze([
|
|
|
380
380
|
...E('Text.Printf', ['hPrintf']).map((e) => ({ ...e, argIndex: 1, argFrom: 1, channel: 'handle' })),
|
|
381
381
|
...E('Katip', ['logFM', 'logTM', 'logItem']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
|
|
382
382
|
...E('System.Log.Logger', ['debugM', 'infoM', 'noticeM', 'warningM', 'errorM', 'criticalM']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
|
|
383
|
+
// fast-logger: the logger set is argument 0, the message argument 1
|
|
384
|
+
...E('System.Log.FastLogger', ['pushLogStr', 'pushLogStrLn']).map((e) => ({ ...e, argIndex: 1, channel: 'logger' })),
|
|
383
385
|
]);
|
|
384
386
|
|
|
385
387
|
// Library surfaces the structural rules (haskell-security-rules.js) look for; registering them here makes
|
|
@@ -393,6 +395,9 @@ export const HS_RULE_APIS = Object.freeze([
|
|
|
393
395
|
...E('Crypto.Cipher.ChaChaPoly1305', ['nonce12', 'nonce8']),
|
|
394
396
|
...E('Crypto.KDF.PBKDF2', ['generate', 'fastPBKDF2_SHA1', 'fastPBKDF2_SHA256', 'fastPBKDF2_SHA512']),
|
|
395
397
|
...E('System.Random', ['randomRIO', 'randomIO', 'newStdGen', 'getStdGen', 'mkStdGen', 'randomR', 'random', 'randoms', 'randomRs', 'getStdRandom', 'uniformR', 'uniform', 'initStdGen']),
|
|
398
|
+
// mwc-random and splitmix are statistical generators, not CSPRNGs (createSystemRandom only seeds from the OS)
|
|
399
|
+
...E('System.Random.MWC', ['uniformR', 'uniform', 'uniformRM', 'uniformM', 'createSystemRandom', 'create', 'initialize', 'withSystemRandom', 'withSystemRandomST']),
|
|
400
|
+
...E('System.Random.SplitMix', ['nextWord64', 'nextInt', 'nextDouble', 'mkSMGen', 'newSMGen', 'initSMGen']),
|
|
396
401
|
...E('Crypto.Random', ['getRandomBytes']), ...E('System.Entropy', ['getEntropy']),
|
|
397
402
|
...E('Network.Wai', ['strictRequestBody', 'lazyRequestBody']),
|
|
398
403
|
...E('Data.Time.Clock.POSIX', ['getPOSIXTime']), ...E('Data.Time.Clock', ['getCurrentTime']), ...E('System.CPUTime', ['getCPUTime']),
|
|
@@ -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,7 +75,8 @@ 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)) };
|
|
@@ -102,8 +107,64 @@ const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secre
|
|
|
102
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;
|
|
103
108
|
const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
|
|
104
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
|
+
}
|
|
105
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;
|
|
106
|
-
|
|
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;
|
|
107
168
|
|
|
108
169
|
const kids = (e) => {
|
|
109
170
|
const out = [];
|
|
@@ -115,6 +176,19 @@ const kids = (e) => {
|
|
|
115
176
|
return out;
|
|
116
177
|
};
|
|
117
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
|
+
|
|
118
192
|
function* walk(e, depth = 0) {
|
|
119
193
|
if (!e || typeof e !== 'object' || depth > 80) return;
|
|
120
194
|
yield e;
|
|
@@ -257,6 +331,44 @@ function weakDigestUse(fn, node, hashCall, hashArgs, fnBase) {
|
|
|
257
331
|
return reasons;
|
|
258
332
|
}
|
|
259
333
|
|
|
334
|
+
/**
|
|
335
|
+
* The migration shape: a fast password hash kept ONLY to verify hashes stored before a password KDF was introduced. True when every
|
|
336
|
+
* use of the function, in every file, is an operand of an equality (nothing new is stored with it) and a function that compares with
|
|
337
|
+
* it also computes a password KDF, directly or through one project function (`verify stored salt pw = if legacy stored then stored ==
|
|
338
|
+
* oldHash salt pw else stored == kdfHash salt pw`). The weak hash is still computed, so the finding is kept at `info` with this reason;
|
|
339
|
+
* a function that is also used to STORE a hash, or whose comparisons sit beside no KDF, is the full finding. Decided from the IR only,
|
|
340
|
+
* never from a name (`hashPasswordLegacy` is not evidence; the comparison and the KDF are).
|
|
341
|
+
*/
|
|
342
|
+
function legacyVerifierOnly(fn, ir, memo) {
|
|
343
|
+
if (memo.has(fn.qid)) return memo.get(fn.qid);
|
|
344
|
+
const allFns = Object.values(ir.perFile).flatMap((x) => x.functions || []);
|
|
345
|
+
const byQid = new Map(allFns.map((x) => [x.qid, x]));
|
|
346
|
+
const usesF = (e) => !!e.hs && ((e.kind === 'call' && e.hs.target === fn.qid) || e.hs.inlinedFrom === fn.qid);
|
|
347
|
+
const kdfCall = (c) => typeof c === 'string' && (STRONG_KDF.test(c) || PBKDF2_CALLS.has(c));
|
|
348
|
+
const hasKdf = (g, depth) => (g.calls || []).some((c) => kdfCall(c.callee) || (depth < 1 && c.hs && c.hs.target && c.hs.target !== g.qid && byQid.has(c.hs.target) && hasKdf(byQid.get(c.hs.target), depth + 1)));
|
|
349
|
+
let uses = 0; let compared = 0; let besideKdf = false;
|
|
350
|
+
const visit = (e, parent, g, depth) => {
|
|
351
|
+
if (!e || typeof e !== 'object' || depth > 60) return;
|
|
352
|
+
if (usesF(e)) {
|
|
353
|
+
uses++;
|
|
354
|
+
if (parent && parent.kind === 'binary' && EQUALITY_OPS.has(parent.op)) { compared++; if (hasKdf(g, 0)) besideKdf = true; }
|
|
355
|
+
return; // under an inlined root the nodes are the helper's own body, not further uses of it
|
|
356
|
+
}
|
|
357
|
+
for (const k of Object.keys(e)) {
|
|
358
|
+
if (k === 'hs') continue;
|
|
359
|
+
const v = e[k];
|
|
360
|
+
if (Array.isArray(v)) { for (const x of v) visit(x, e, g, depth + 1); } else if (v && typeof v === 'object') visit(v, e, g, depth + 1);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
for (const g of allFns) {
|
|
364
|
+
if (g.qid === fn.qid) continue;
|
|
365
|
+
for (const n of Object.values(g.cfg.nodes)) for (const root of [n.source, n.value, n.cond, ...(n.args || [])]) visit(root, null, g, 0);
|
|
366
|
+
}
|
|
367
|
+
const r = uses > 0 && compared === uses && besideKdf;
|
|
368
|
+
memo.set(fn.qid, r);
|
|
369
|
+
return r;
|
|
370
|
+
}
|
|
371
|
+
|
|
260
372
|
/**
|
|
261
373
|
* @param {Record<string,string>} files path -> source (only Haskell files are read)
|
|
262
374
|
* @param {object} [opts]
|
|
@@ -279,11 +391,14 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
279
391
|
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; }
|
|
280
392
|
}
|
|
281
393
|
|
|
394
|
+
let currentFn = null;
|
|
395
|
+
const legacyMemo = new Map();
|
|
282
396
|
const emit = (rule, file, line, extra = {}) => {
|
|
283
397
|
const r = RULES[rule];
|
|
284
398
|
const sev = extra.severity || r.severity;
|
|
285
399
|
const f = {
|
|
286
400
|
id: `hs-rule:${rule}:${file}:${line}`, severity: sev, file, line, vuln: extra.vuln || r.vuln, cwe: r.cwe,
|
|
401
|
+
_fnQid: currentFn ? currentFn.qid : null, ...(extra.inlineCtx ? { inlineCtx: extra.inlineCtx } : {}),
|
|
287
402
|
description: `${extra.vuln || r.vuln}${extra.detail ? `: ${extra.detail}` : ''}`,
|
|
288
403
|
remediation: r.remediation, parser: 'HS-RULES', family: r.family,
|
|
289
404
|
language: 'haskell', capability: 'sast', analysisKind: 'application', evidenceKind: 'source',
|
|
@@ -306,6 +421,14 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
306
421
|
}
|
|
307
422
|
}
|
|
308
423
|
for (const fn of fir.functions) {
|
|
424
|
+
currentFn = fn;
|
|
425
|
+
// A project helper the IR inlined at its call site (`digestFor (credPassword creds)` becomes the helper's body, rooted at a node with
|
|
426
|
+
// `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
|
|
427
|
+
// a rule that reports the same thing inside the helper can drop the call-site copy (the filter before the return).
|
|
428
|
+
for (const n of Object.values(fn.cfg.nodes)) {
|
|
429
|
+
for (const k of ['source', 'value', 'cond']) if (n[k]) markInlined(n[k], null, 0);
|
|
430
|
+
if (Array.isArray(n.args)) for (const a of n.args) markInlined(a, null, 0);
|
|
431
|
+
}
|
|
309
432
|
if (fn.name.endsWith('.<module>')) continue;
|
|
310
433
|
const bound = new Map(); // target var -> {callee, line}
|
|
311
434
|
const sensitive = new Map(); // var -> {class:'low'|'high', hashed:boolean, line}
|
|
@@ -422,9 +545,13 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
422
545
|
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 });
|
|
423
546
|
continue;
|
|
424
547
|
}
|
|
425
|
-
// a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")
|
|
426
|
-
|
|
427
|
-
|
|
548
|
+
// a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`, or handed to a framework's header
|
|
549
|
+
// setter: Scotty `setHeader "Set-Cookie" ("session=" <> tok <> "; Path=/")`, Spock `setHeader`, Yesod `addHeader`
|
|
550
|
+
let cookiePair = null;
|
|
551
|
+
if (e.kind === 'array' && e.hs && e.hs.tuple && e.elements.length === 2) cookiePair = e.elements;
|
|
552
|
+
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]];
|
|
553
|
+
if (cookiePair) {
|
|
554
|
+
const [k, v] = cookiePair;
|
|
428
555
|
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))));
|
|
429
556
|
if (isSetCookie && v) {
|
|
430
557
|
const text = literalTextOf(v);
|
|
@@ -477,7 +604,10 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
477
604
|
// scotty-cookie's helpers build a cookie that carries neither Secure nor HttpOnly
|
|
478
605
|
if ((c === 'Web.Scotty.Cookie.setSimpleCookie' || c === 'Web.Scotty.Cookie.makeSimpleCookie') && args[0]) {
|
|
479
606
|
const nm = args[0];
|
|
480
|
-
|
|
607
|
+
// A name that is a PARAMETER of this function is decided by the caller: the function is itself a cookie helper
|
|
608
|
+
// (scotty's own `setSimpleCookie n v`, `deleteCookie c`), and the call that passes a session name is the site to report.
|
|
609
|
+
const ownParam = nm.kind === 'ident' && Array.isArray(fn.params) && fn.params.includes(nm.name);
|
|
610
|
+
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 } });
|
|
481
611
|
}
|
|
482
612
|
// a socket read repeated by a recursive loop, with no size test anywhere in it
|
|
483
613
|
if (SOCKET_READS.has(c) && !hasSizeTest && !bounded && fn.calls.some((x) => x.callee === fn.name && x.hs && x.hs.target === fn.qid)) {
|
|
@@ -502,8 +632,13 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
502
632
|
}
|
|
503
633
|
}
|
|
504
634
|
// password hashed with a fast hash
|
|
505
|
-
|
|
506
|
-
|
|
635
|
+
// (a hash call the IR inlined from a project helper, `digestFor (credPassword creds)` at the comparison, is reported once, in the helper)
|
|
636
|
+
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(/^\./, ''))))) {
|
|
637
|
+
if (!(e.hs && e.hs.inlineCtx) && legacyVerifierOnly(fn, ir, legacyMemo)) {
|
|
638
|
+
emit('hs-fast-password-hash', file, line, { severity: 'info', confidence: 0.5, detail: `${c} applied to a password-shaped value, in a function used only to verify hashes made before a migration (every use of it is an equality, beside a password KDF): re-hash with the KDF at the next successful login and remove it once no stored hash needs it`, inlineCtx: e.hs && e.hs.inlineCtx, evidence: { legacyVerifier: true } });
|
|
639
|
+
} else {
|
|
640
|
+
emit('hs-fast-password-hash', file, line, { detail: `${c} applied to a password-shaped value`, inlineCtx: e.hs && e.hs.inlineCtx });
|
|
641
|
+
}
|
|
507
642
|
}
|
|
508
643
|
if (PBKDF2_CALLS.has(c)) {
|
|
509
644
|
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) {
|
|
@@ -514,7 +649,7 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
514
649
|
if (ECB_CALLS.has(c)) emit('hs-ecb-mode', file, line, { detail: c });
|
|
515
650
|
if (IV_CALLS.has(c) && args[0] && isLiteralLike(args[0])) emit('hs-static-iv', file, line, { detail: `${c} of a constant value` });
|
|
516
651
|
// randomness bound to a security-shaped name
|
|
517
|
-
if (RANDOM_CALLS.has(c) && node.kind === 'assign' && SECURITY_VAR.test(node.target || '')) {
|
|
652
|
+
if (RANDOM_CALLS.has(c) && node.kind === 'assign' && (SECURITY_VAR.test(node.target || '') || (CONTEXT_CODE_VAR.test(node.target || '') && fnMentions(fn, CODE_CONTEXT)))) {
|
|
518
653
|
emitRandom(line, { detail: `${c} result bound to \`${node.target}\``, evidence: { binding: node.target } });
|
|
519
654
|
}
|
|
520
655
|
if (RANDOM_CALLS.has(c) && SECURITY_FN.test(fnBase) && node.kind !== 'assign') {
|
|
@@ -562,6 +697,14 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
562
697
|
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; }
|
|
563
698
|
if (cls) {
|
|
564
699
|
let cleared = false;
|
|
700
|
+
// a yes/no answer about the credential (`accepted <- checkCredentials attempt`, a comparison, a conjunction) carries no credential text
|
|
701
|
+
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];
|
|
702
|
+
if (root && ((root.kind === 'binary' && ['==', '/=', '!=', '&&', '||', '<', '>', '<=', '>='].includes(root.op)) || root.kind === 'logical')) cleared = true;
|
|
703
|
+
// the binding says it holds a hash or digest (`hashedPassword <- hashPassword plain`): the stored form, whatever made it
|
|
704
|
+
if (HASH_NAMED.test(node.target || '')) cleared = true;
|
|
705
|
+
if (root && root.kind === 'call' && typeof root.callee === 'string' && /(?:^|\.)(?:not|elem|notElem|null|isJust|isNothing|any|all|and|or)$/.test(root.callee)) cleared = true;
|
|
706
|
+
// a project helper whose every return is such an answer (`checkCredentials a = pure (user a == "x" && password a == "y")`)
|
|
707
|
+
if (root && root.kind === 'call' && typeof root.callee === 'string' && returnsYesNo(fir, root.callee)) cleared = true;
|
|
565
708
|
for (const x of walk(src)) {
|
|
566
709
|
if (x.kind !== 'call' || typeof x.callee !== 'string') continue;
|
|
567
710
|
if (REDACTOR.test(x.callee)) cleared = true;
|
|
@@ -600,13 +743,19 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
600
743
|
for (const n of names(arg)) {
|
|
601
744
|
if (hit) break;
|
|
602
745
|
const bare = n.replace(/^\./, '');
|
|
603
|
-
|
|
604
|
-
|
|
746
|
+
// a value built from a credential (`attempt = Attempt user password`) logged through a field that is not the credential
|
|
747
|
+
// (`attemptUser attempt`, lowered to a member of `attempt`) is that field, not the secret
|
|
748
|
+
if (sensitive.has(n) && !usedOnlyAsNonSensitiveField(arg, n)) hit = { ...sensitive.get(n), var: n };
|
|
749
|
+
// a name that says it holds a hash or digest of the credential (`hashedPassword`, `tokenDigest`) is the stored form, not the secret
|
|
750
|
+
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 };
|
|
751
|
+
// the record itself, logged whole (`show attempt`), when one of its fields was built from a credential (`attempt.attemptPassword`)
|
|
752
|
+
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 }; }
|
|
605
753
|
if (hit) break;
|
|
606
754
|
}
|
|
607
755
|
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 }; }
|
|
608
756
|
if (hit && !isRedacted(arg)) {
|
|
609
757
|
emit('hs-sensitive-logging', file, e.line || node.line, {
|
|
758
|
+
inlineCtx: e.hs && e.hs.inlineCtx,
|
|
610
759
|
severity: hit.hashed ? 'low' : 'high',
|
|
611
760
|
detail: hit.hashed ? `a fast hash of low-entropy \`${hit.name}\` reaches ${e.callee}` : `\`${hit.name}\` reaches ${e.callee}`,
|
|
612
761
|
evidence: { value: hit.name, via: hit.var, channel: sink.channel, hashed: hit.hashed },
|
|
@@ -639,5 +788,16 @@ export function analyzeHaskellRules(files, opts = {}) {
|
|
|
639
788
|
const out = [];
|
|
640
789
|
for (const f of findings) { if (seen.has(f.id)) continue; seen.add(f.id); out.push(f); }
|
|
641
790
|
void notes; void opts;
|
|
642
|
-
|
|
791
|
+
// 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
|
|
792
|
+
// whose helper reports nothing (the password shape is visible only at the call) is kept.
|
|
793
|
+
// The kept finding lists the call sites it also covers (`alsoAt`), so a reviewer still sees every line of the one weakness.
|
|
794
|
+
const byKey = new Map(out.map((f) => [`${f.rule}|${f.file}|${f._fnQid}`, f]));
|
|
795
|
+
const deduped = out.filter((f) => {
|
|
796
|
+
const helper = f.inlineCtx && f._fnQid !== f.inlineCtx ? byKey.get(`${f.rule}|${f.file}|${f.inlineCtx}`) : null;
|
|
797
|
+
if (!helper) return true;
|
|
798
|
+
helper.alsoAt = [...(helper.alsoAt || []), { line: f.line, reason: 'the same helper inlined at this call' }];
|
|
799
|
+
return false;
|
|
800
|
+
});
|
|
801
|
+
for (const f of deduped) delete f._fnQid;
|
|
802
|
+
return { findings: deduped, controls, notes };
|
|
643
803
|
}
|
|
@@ -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
|
|
@@ -30,10 +34,13 @@ const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'coo
|
|
|
30
34
|
const DATA = new Set(['stdin', 'file-read']);
|
|
31
35
|
const WEAK_PARAM_LABEL = /\(caller-controlled\)$/;
|
|
32
36
|
// CWE-918 is deliberately NOT a wrapper family: `fetch url = parseRequest url >>= httpNoBody` is pinned as a finding in
|
|
33
|
-
// test/haskell/haskell-generalization.test.js (a URL the caller chooses is the SSRF shape of the development sets)
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
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)$/;
|
|
37
44
|
const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
|
|
38
45
|
// list plumbing that only lays the parameters out as text (`unwords args`, `concatMap (' ':) args`): no fixed words are added
|
|
39
46
|
const LIST_LAYOUT = /(?:^|\.)(?:unwords|unlines|intercalate|concatMap|concat|map|fmap)$/;
|
|
@@ -261,9 +268,14 @@ export function applyTrustBoundary(findings, perFile) {
|
|
|
261
268
|
else if (roots.length && [...classes].every((c) => c === 'operator')) {
|
|
262
269
|
verdict = 'operator-controlled';
|
|
263
270
|
reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
|
|
264
|
-
} 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).
|
|
265
275
|
verdict = 'operator-controlled';
|
|
266
|
-
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';
|
|
267
279
|
} else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
|
|
268
280
|
else if (classes.has('weak-param')) {
|
|
269
281
|
verdict = 'entry-parameter';
|
|
@@ -38,8 +38,12 @@ const SCOTTY_METHODS = { get: 'GET', post: 'POST', put: 'PUT', delete: 'DELETE',
|
|
|
38
38
|
const SENSITIVE_PREFIX = ['Database.', 'System.Process.', 'System.Directory.', 'Prelude.readFile', 'Prelude.writeFile', 'Prelude.appendFile', 'System.IO.', 'Network.HTTP.Simple.', 'Network.HTTP.Client.'];
|
|
39
39
|
const PERSIST_FNS = new Set(['get', 'getBy', 'selectFirst', 'selectList', 'insert', 'insert_', 'update', 'updateWhere', 'delete', 'deleteWhere', 'replace', 'runDB', 'getJust', 'upsert']);
|
|
40
40
|
const GUARD_PRIMITIVES = new Set(['Yesod.Core.requireAuthId', 'Yesod.Core.requireAuth', 'Yesod.Auth.requireAuthId', 'Yesod.Auth.requireAuth', 'Yesod.requireAuthId', 'Yesod.requireAuth', 'Network.Wai.Middleware.HttpAuth.basicAuth']);
|
|
41
|
-
|
|
41
|
+
// a header that carries the caller's credential: the standard two, and the custom names projects give a token or session header
|
|
42
|
+
const CREDENTIAL_HEADER = /^(?:authorization|cookie|x-api-key|x-api-token|x-auth-token|x-auth|x-access-token|x-session(?:-(?:id|token|key))?|x-token|x-user-token|x-csrf-token|x-xsrf-token)$/i;
|
|
42
43
|
const ADMIN_PATH = /(?:^|\/)(?:admin|internal|manage|superuser)(?:\/|$|:)/i;
|
|
44
|
+
// an authentication endpoint (sign-in, registration, password reset, logout, an OAuth callback) is public by construction
|
|
45
|
+
const AUTH_ENDPOINT_PATH = /(?:^|\/)(?:login|log-in|sign-?in|sign-?up|signup|register|registration|logout|log-out|sign-?out|password\/(?:reset|forgot)|reset-password|forgot-password|password-reset|oauth|callback|token|auth|authenticate|session)(?:\/|$|:|\.)/i;
|
|
46
|
+
const AUTH_ENDPOINT_NAME = /(?:login|signin|signup|register|logout|signout|resetpassword|passwordreset|forgotpassword|authenticate)/i;
|
|
43
47
|
const ROLE_CALL = /(?:^|\.)(?:isAdmin|hasRole|requireAdmin|checkRole|hasPermission|requireRole|requirePermission|isSuperuser)$/i;
|
|
44
48
|
const OWNER_CALL = /(?:^|\.)(?:checkOwner|ownedBy|belongsTo|isOwner|requireOwner|authorizeOwner)$/i;
|
|
45
49
|
const CSRF_CALL = /csrf|xsrf/i;
|
|
@@ -1160,8 +1164,11 @@ function emitFindings(ctx, result, yesodAuthz) {
|
|
|
1160
1164
|
if (!R.handler || R.handler.kind === 'unknown') continue;
|
|
1161
1165
|
const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
|
|
1162
1166
|
const privileged = ADMIN_PATH.test(R.path) || /admin/i.test(R.handler.name || '');
|
|
1167
|
+
// An authentication endpoint is public by construction: the request that signs in, registers or starts a password reset carries no
|
|
1168
|
+
// session yet. Its own flaws (a weak hash, a predictable code) are other families' findings; a missing-auth finding on it is noise.
|
|
1169
|
+
const authEndpoint = AUTH_ENDPOINT_PATH.test(R.path || '') || AUTH_ENDPOINT_NAME.test(R.handler.name || '');
|
|
1163
1170
|
if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: R.auth.evidence[0].order === 'unknown' ? `the rejection ${R.auth.evidence[0].callee} cannot be shown to run before the first sensitive operation (${R.auth.evidence[0].reason})` : `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
|
|
1164
|
-
else if (R.auth.status === 'none' && (writes || privileged) && !(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) {
|
|
1171
|
+
else if (R.auth.status === 'none' && (writes || privileged) && !authEndpoint && !(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) {
|
|
1165
1172
|
const how = R.method === 'ANY' ? 'any method' : writes ? 'state-changing method' : 'privileged path';
|
|
1166
1173
|
if (R.sensitive !== false) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: how });
|
|
1167
1174
|
// No database, file or process call is visible, but the handler calls something the engine cannot see into (a project function, a
|