@clear-capabilities/agentic-security-scanner 0.186.2 → 0.188.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/1602.index.js +275 -19
- package/dist/{4d06c8944ae121d7085c.json → 92f78fd3e2dbe30fb58d.json} +5 -5
- package/dist/9560.index.js +61 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +5 -5
- package/src/capabilities/decide.js +7 -1
- package/src/dataflow/CLAUDE.md +15 -0
- package/src/dataflow/catalog-haskell.js +7 -1
- package/src/dataflow/catalog.js +14 -0
- package/src/dataflow/engine.js +147 -10
- package/src/dataflow/privacy-catalog-haskell.js +2 -1
- package/src/engine.js +11 -3
- package/src/language/CLAUDE.md +26 -0
- package/src/language/compliance-map.js +6 -0
- package/src/language/context.js +20 -0
- package/src/language/haskell-comments.js +65 -0
- package/src/language/haskell-disclosure.js +16 -4
- package/src/language/haskell-findings.js +2 -1
- package/src/language/haskell-fix.js +118 -2
- package/src/language/haskell-guards.js +132 -5
- package/src/language/haskell-ir.js +228 -13
- package/src/language/haskell-models.js +108 -8
- package/src/language/haskell-security-rules.js +223 -41
- package/src/language/haskell-syntax.js +10 -1
- package/src/language/haskell-trust.js +268 -0
- package/src/language/haskell-web.js +46 -9
- package/src/language/support-registry.js +3 -2
- package/src/mcp/tools.js +54 -3
- package/src/posture/attack-taxonomy.js +10 -0
- package/src/posture/entrypoint-inventory.js +6 -2
- package/src/posture/proof-fabric/generate-haskell.js +266 -17
- package/src/posture/proof-fabric/generate.js +10 -3
- package/src/posture/proof-fabric/oracles.js +21 -0
- package/src/posture/stable-id.js +2 -1
- package/src/report/index.js +3 -0
- package/src/sast/cloud-iam.js +3 -1
- package/dist/2066.index.js +0 -851
|
@@ -30,7 +30,7 @@ const FAMILY_BY_CWE = { 'CWE-22': 'path', 'CWE-918': 'url', 'CWE-78': 'cmd', 'CW
|
|
|
30
30
|
|
|
31
31
|
const isStr = (e) => e && e.kind === 'literal' && typeof e.value === 'string';
|
|
32
32
|
|
|
33
|
-
function identNames(e, out = new Set(), depth = 0) {
|
|
33
|
+
export function identNames(e, out = new Set(), depth = 0) {
|
|
34
34
|
if (!e || typeof e !== 'object' || depth > 30) return out;
|
|
35
35
|
if (e.kind === 'ident' && e.name && !(e.hs && e.hs.functionRef)) out.add(e.name);
|
|
36
36
|
for (const k of ['left', 'right', 'object', 'value', 'callee']) if (e[k] && typeof e[k] === 'object') identNames(e[k], out, depth + 1);
|
|
@@ -44,6 +44,8 @@ const mentions = (e, vars) => { for (const n of identNames(e)) if (vars.has(n))
|
|
|
44
44
|
/** Classify a condition over the value `vars`. -> {safeWhen, kind, families, needsCanon?, subject} | null */
|
|
45
45
|
export function classifyCondition(c, vars, derived = new Set()) {
|
|
46
46
|
if (!c || typeof c !== 'object') return null;
|
|
47
|
+
// The IR lowers `&&` / `||` to a `logical` node (`and` / `or`); a `binary` node is kept for the same operators from other lowerings.
|
|
48
|
+
if (c.kind === 'logical' && (c.op === 'and' || c.op === 'or')) return classifyCondition({ kind: 'binary', op: c.op === 'and' ? '&&' : '||', left: c.left, right: c.right }, vars, derived);
|
|
47
49
|
if (c.kind === 'binary') {
|
|
48
50
|
if (c.op === '&&') { const a = classifyCondition(c.left, vars, derived); const b = classifyCondition(c.right, vars, derived); return [a, b].find((x) => x && x.safeWhen === true) || null; }
|
|
49
51
|
if (c.op === '||') { const a = classifyCondition(c.left, vars, derived); const b = classifyCondition(c.right, vars, derived); return [a, b].find((x) => x && x.safeWhen === false) || null; }
|
|
@@ -72,8 +74,12 @@ export function classifyCondition(c, vars, derived = new Set()) {
|
|
|
72
74
|
}
|
|
73
75
|
// `uriRegName a `elem` ["api.example.com"]` where `a` was parsed from the checked URL: the host is on an allow-list, which is the
|
|
74
76
|
// URL family's control. Only a HOST accessor over a value derived from the checked one counts (a path or query allow-list does not).
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
// The list may be written inline (a literal list of strings) or be a named constant (`allowedHosts`): either way it must not be built from the
|
|
78
|
+
// checked value, or the check would compare the value with itself.
|
|
79
|
+
const fixedList = (l) => l && ((l.kind === 'array' && l.elements.length && l.elements.every((x) => isStr(x) || (x && x.kind === 'call' && /(?:^|\.)(?:pack|fromString)$/.test(x.callee || '') && x.args && x.args.length === 1 && isStr(x.args[0]))))
|
|
80
|
+
|| (l.kind === 'ident' && !mentions(l, vars) && !mentions(l, derived)));
|
|
81
|
+
if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && a[0] && a[0].kind === 'call' && /(?:^|\.)(?:uriRegName|uriAuthority|getHost|host|hostName)$/.test(a[0].callee || '') && a[0].args && a[0].args[0] && a[0].args[0].kind === 'ident'
|
|
82
|
+
&& (vars.has(a[0].args[0].name) || derived.has(a[0].args[0].name)) && fixedList(a[1])) {
|
|
77
83
|
return { safeWhen: IS_ELEM.has(name), kind: 'host-allow-list', families: ['url'], subject: a[0].args[0].name };
|
|
78
84
|
}
|
|
79
85
|
if ((IS_ELEM.has(name) || NOT_ELEM.has(name)) && a[0] && a[0].kind === 'ident' && vars.has(a[0].name) && !mentions(a[1], vars)) {
|
|
@@ -112,7 +118,7 @@ const exprChildren = (e) => {
|
|
|
112
118
|
};
|
|
113
119
|
|
|
114
120
|
// Find the sink call at `line` with callee `callee`; returns [{ nodeId, facts, call }].
|
|
115
|
-
function findSites(fn, callee, line) {
|
|
121
|
+
export function findSites(fn, callee, line) {
|
|
116
122
|
const sites = [];
|
|
117
123
|
const visit = (e, nodeId, facts, depth) => {
|
|
118
124
|
if (!e || typeof e !== 'object' || depth > 60) return;
|
|
@@ -264,6 +270,62 @@ function isCanonical(fn, name) {
|
|
|
264
270
|
return false;
|
|
265
271
|
}
|
|
266
272
|
|
|
273
|
+
// A hand-written HTML escaper: a project function (or one of its local helpers) that spells out the entities for `<`, `>` and `&`. Recognised by
|
|
274
|
+
// what the code does (it produces those entity strings), never by its name. Kept per IR build.
|
|
275
|
+
const ESCAPERS = new WeakMap();
|
|
276
|
+
const ENTITY = { lt: /<|�*60;|�*3c;/i, gt: />|�*62;|�*3e;/i, amp: /&|�*38;|�*26;/i };
|
|
277
|
+
function stringLiteralsOf(fn) {
|
|
278
|
+
const out = [];
|
|
279
|
+
const visit = (e, depth) => {
|
|
280
|
+
if (!e || typeof e !== 'object' || depth > 60) return;
|
|
281
|
+
if (e.kind === 'literal' && typeof e.value === 'string') out.push(e.value);
|
|
282
|
+
for (const [, c] of exprChildren(e)) visit(c, depth + 1);
|
|
283
|
+
if (Array.isArray(e.branches)) for (const b of e.branches) visit(b, depth + 1); // the arms of a conditional or a multi-clause function
|
|
284
|
+
};
|
|
285
|
+
for (const n of nodeList(fn)) { for (const k of ['source', 'value', 'cond']) visit(n[k], 0); if (Array.isArray(n.args)) for (const a of n.args) visit(a, 0); }
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
export function htmlEscapers(perFile) {
|
|
289
|
+
if (!perFile || typeof perFile !== 'object') return new Set();
|
|
290
|
+
let hit = ESCAPERS.get(perFile);
|
|
291
|
+
if (hit) return hit;
|
|
292
|
+
hit = new Set();
|
|
293
|
+
for (const ir of Object.values(perFile)) {
|
|
294
|
+
const fns = ir.functions || [];
|
|
295
|
+
// local helpers and lambdas record their parent's qid: one pass groups them, so the whole file costs one visit per function
|
|
296
|
+
const children = new Map();
|
|
297
|
+
for (const fn of fns) { const par = fn.hs && fn.hs.parent; if (par) { if (!children.has(par)) children.set(par, []); children.get(par).push(fn); } }
|
|
298
|
+
const lits = new Map();
|
|
299
|
+
const litsOf = (fn, depth = 0) => {
|
|
300
|
+
if (lits.has(fn.qid)) return lits.get(fn.qid);
|
|
301
|
+
let out = stringLiteralsOf(fn);
|
|
302
|
+
if (depth < 8) for (const c of children.get(fn.qid) || []) out = out.concat(litsOf(c, depth + 1));
|
|
303
|
+
lits.set(fn.qid, out);
|
|
304
|
+
return out;
|
|
305
|
+
};
|
|
306
|
+
for (const fn of fns) {
|
|
307
|
+
if (!fn.name || fn.name.endsWith('.<module>') || (fn.hs && (fn.hs.kind === 'closure' || fn.hs.kind === 'local-function'))) continue;
|
|
308
|
+
const all = litsOf(fn);
|
|
309
|
+
if (all.some((l) => ENTITY.lt.test(l)) && all.some((l) => ENTITY.gt.test(l)) && all.some((l) => ENTITY.amp.test(l))) hit.add(fn.name);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
ESCAPERS.set(perFile, hit);
|
|
313
|
+
return hit;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Every occurrence of a variable inside `e` is an argument of an escaper (or a value an escaper call produced): nothing raw reaches the sink. */
|
|
317
|
+
function allUsesEscaped(fn, e, escapers, depth = 0) {
|
|
318
|
+
if (!e || typeof e !== 'object' || depth > 40) return true;
|
|
319
|
+
if (e.kind === 'call' && escapers.has(e.callee)) return true; // whatever it receives is escaped
|
|
320
|
+
if (e.kind === 'ident') {
|
|
321
|
+
if (e.hs && e.hs.functionRef) return true;
|
|
322
|
+
const defs = nodeList(fn).filter((n) => n.kind === 'assign' && n.target === e.name && n.source);
|
|
323
|
+
return defs.length > 0 && defs.every((n) => n.source.kind === 'call' && escapers.has(n.source.callee));
|
|
324
|
+
}
|
|
325
|
+
for (const [, c] of exprChildren(e)) if (!allUsesEscaped(fn, c, escapers, depth + 1)) return false;
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
267
329
|
/**
|
|
268
330
|
* Judge one finding. Mutates it when a dominating, family-appropriate guard refutes the flow.
|
|
269
331
|
* @returns {{status:'refuted'|'weak-guard'|'none', guard?:object}}
|
|
@@ -289,7 +351,16 @@ export function judgeFinding(f, perFile) {
|
|
|
289
351
|
const derived = derivedFrom(fn, vars); // values built FROM the checked one (`a` in `Just a <- parseURI url`)
|
|
290
352
|
const facts = [...site.facts, ...cfgBranchFacts(fn, site.nodeId), ...exitGuardFacts(fn, site.nodeId)];
|
|
291
353
|
let refuted = null;
|
|
354
|
+
if (fam === 'xss') {
|
|
355
|
+
const escapers = htmlEscapers(perFile);
|
|
356
|
+
const calls = [];
|
|
357
|
+
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); };
|
|
358
|
+
find(argExpr, 0);
|
|
359
|
+
for (const n of nodeList(fn)) if (n.kind === 'assign' && n.source && identNames(argExpr).has(n.target)) find(n.source, 0);
|
|
360
|
+
if (calls.length && allUsesEscaped(fn, argExpr, escapers)) refuted = { kind: 'html-escaper', line: calls[0].line || fn.line, subject: calls[0].callee };
|
|
361
|
+
}
|
|
292
362
|
for (const fact of facts) {
|
|
363
|
+
if (refuted) break;
|
|
293
364
|
const cls = classifyCondition(fact.cond, vars, derived);
|
|
294
365
|
if (!cls || cls.safeWhen !== fact.when) continue;
|
|
295
366
|
if (!(cls.families.includes('*') || cls.families.includes(fam))) continue; // wrong-context control
|
|
@@ -346,7 +417,10 @@ export function promoteShellInterpreter(f, perFile) {
|
|
|
346
417
|
if (!isStr(prog) || !SHELLS.test(prog.value) || !list || list.kind !== 'array') continue;
|
|
347
418
|
const flag = list.elements.findIndex((e) => isStr(e) && SHELL_C.test(e.value));
|
|
348
419
|
if (flag < 0) continue;
|
|
349
|
-
|
|
420
|
+
// `sh -c SCRIPT arg0 arg1 ..`: only the SCRIPT is parsed by the shell. The elements after it are the positional parameters ($0, $1, ...), plain
|
|
421
|
+
// data to a script that does not interpolate them into code; a tainted script is injection, tainted positionals are judged by `quotedPositionals`.
|
|
422
|
+
const script = list.elements[flag + 1];
|
|
423
|
+
if (script && script.kind !== 'literal') {
|
|
350
424
|
f.cwe = 'CWE-78'; f.severity = 'critical'; f.vuln = 'OS Command Injection (shell interpreter -c)';
|
|
351
425
|
f.id = String(f.id).replace('cwe-88', 'cwe-78'); f.shellInterpreter = prog.value;
|
|
352
426
|
return true;
|
|
@@ -355,12 +429,65 @@ export function promoteShellInterpreter(f, perFile) {
|
|
|
355
429
|
return false;
|
|
356
430
|
}
|
|
357
431
|
|
|
432
|
+
/**
|
|
433
|
+
* `callProcess "sh" ["-c", "gzip -9 -- \"$1\"", "sh", target]`: the script is a constant and the tainted value is a positional parameter the
|
|
434
|
+
* script expands INSIDE DOUBLE QUOTES after `--`. The shell does not re-parse a quoted expansion, and `--` ends the program's option parsing, so
|
|
435
|
+
* neither shell nor option injection is possible through it. A reference that is unquoted, quoted with single quotes, or not preceded by `--` is not
|
|
436
|
+
* covered: the finding stays. Returns the control or null.
|
|
437
|
+
*/
|
|
438
|
+
export function quotedPositionals(f, perFile) {
|
|
439
|
+
if (!f || f.cwe !== 'CWE-88' || !PROCESS_FNS.has(f.callee)) return null;
|
|
440
|
+
const ir = perFile && perFile[f.file];
|
|
441
|
+
const fn = ir && f._funcQid && ir.functions.find((x) => x.qid === f._funcQid);
|
|
442
|
+
if (!fn) return null;
|
|
443
|
+
const sites = findSites(fn, f.callee, f.line);
|
|
444
|
+
if (!sites.length) return null;
|
|
445
|
+
for (const site of sites) {
|
|
446
|
+
const [prog, list] = site.call.args || [];
|
|
447
|
+
if (!isStr(prog) || !SHELLS.test(prog.value) || !list || list.kind !== 'array') return null;
|
|
448
|
+
const flag = list.elements.findIndex((e) => isStr(e) && SHELL_C.test(e.value));
|
|
449
|
+
const script = flag >= 0 ? list.elements[flag + 1] : null;
|
|
450
|
+
if (!isStr(script)) return null;
|
|
451
|
+
// a tainted element BEFORE the script (or the script itself) is not a positional parameter
|
|
452
|
+
if (list.elements.slice(0, flag + 2).some((e) => e && e.kind !== 'literal')) return null;
|
|
453
|
+
// the literal keeps the Haskell string's own escapes (`\"`, `\\`, `\n`): decode them to get the text the shell receives
|
|
454
|
+
const text = script.value.replace(/\\(["'\\ntr])/g, (m, c) => ({ n: '\n', t: '\t', r: '\r' }[c] || c));
|
|
455
|
+
let dq = false; let sq = false; let seenDashes = false;
|
|
456
|
+
for (let i = 0; i < text.length; i++) {
|
|
457
|
+
const ch = text[i];
|
|
458
|
+
if (ch === '\\' && !sq) { i++; continue; }
|
|
459
|
+
if (ch === "'" && !dq) { sq = !sq; continue; }
|
|
460
|
+
if (ch === '"' && !sq) { dq = !dq; continue; }
|
|
461
|
+
if (!dq && !sq && ch === '-' && text[i + 1] === '-' && /\s/.test(text[i + 2] || ' ') && (i === 0 || /\s/.test(text[i - 1]))) { seenDashes = true; continue; }
|
|
462
|
+
if (ch === '$' && !sq) {
|
|
463
|
+
const rest = text.slice(i + 1);
|
|
464
|
+
if (/^(?:[0-9]|\{[0-9]+\}|[@*]|\{[@*]\})/.test(rest)) {
|
|
465
|
+
if (!dq || !seenDashes) return null;
|
|
466
|
+
} else if (/^[A-Za-z_(`{]/.test(rest)) { return null; } // another expansion or a command substitution: not judged here
|
|
467
|
+
}
|
|
468
|
+
if (ch === '`' && !sq) return null;
|
|
469
|
+
}
|
|
470
|
+
return { kind: 'quoted-positional-parameters' };
|
|
471
|
+
}
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
358
475
|
/** Applies judgeFinding to every Haskell taint finding. Returns counts. */
|
|
359
476
|
export function applyHaskellGuards(findings, perFile) {
|
|
360
477
|
const stats = { refuted: 0, weak: 0 };
|
|
361
478
|
for (const f of findings || []) {
|
|
362
479
|
if (!f || f.parser !== 'IR-TAINT') continue;
|
|
363
480
|
try { promoteShellInterpreter(f, perFile); } catch { /* judged as an argument-injection below */ }
|
|
481
|
+
try {
|
|
482
|
+
const qp = quotedPositionals(f, perFile);
|
|
483
|
+
if (qp && !f._quotedPositionals) {
|
|
484
|
+
f._quotedPositionals = true; f.severity = 'info';
|
|
485
|
+
f.controls = [...(f.controls || []), { kind: 'quoted-positional-parameters', evidenceKind: 'source' }];
|
|
486
|
+
f.guardNote = { kind: 'quoted-positional-parameters', reason: 'the script is a constant and expands the tainted value only inside double quotes after "--"; the shell does not re-parse it and the program stops reading options' };
|
|
487
|
+
stats.weak++;
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
} catch { /* judged below */ }
|
|
364
491
|
try {
|
|
365
492
|
const ot = optionTerminator(f, perFile);
|
|
366
493
|
if (ot && !f._optionTerminated) {
|
|
@@ -27,7 +27,7 @@ import { createHash } from 'node:crypto';
|
|
|
27
27
|
import { parseSyntax, groupDecls, patternVars } from './haskell-syntax.js';
|
|
28
28
|
import { preprocessForSyntax } from './haskell-parser.js';
|
|
29
29
|
import { deriveCppContext } from './haskell-cpp.js';
|
|
30
|
-
import { isKnownApi, isSourceApi, qualifyAmbiguous } from './haskell-models.js';
|
|
30
|
+
import { isKnownApi, isSourceApi, qualifyAmbiguous, handlerFrameworkOf } from './haskell-models.js';
|
|
31
31
|
|
|
32
32
|
export const HS_IR_LIMITS = Object.freeze({
|
|
33
33
|
maxExprDepth: 200,
|
|
@@ -50,9 +50,14 @@ const HOF = {
|
|
|
50
50
|
forM_: { fn: 1, coll: 0, kind: 'traverse_' }, for_: { fn: 1, coll: 0, kind: 'traverse_' },
|
|
51
51
|
filter: { fn: 0, coll: 1, kind: 'filter' },
|
|
52
52
|
foldr: { fn: 0, seed: 1, coll: 2, kind: 'fold' }, foldl: { fn: 0, seed: 1, coll: 2, kind: 'fold' }, "foldl'": { fn: 0, seed: 1, coll: 2, kind: 'fold' },
|
|
53
|
+
foldM: { fn: 0, seed: 1, coll: 2, kind: 'fold', monadic: true }, foldM_: { fn: 0, seed: 1, coll: 2, kind: 'fold', monadic: true },
|
|
54
|
+
zipWith: { fn: 0, coll: 1, coll2: 2, kind: 'map' }, zipWithM: { fn: 0, coll: 1, coll2: 2, kind: 'traverse' }, zipWithM_: { fn: 0, coll: 1, coll2: 2, kind: 'traverse_' },
|
|
53
55
|
sequence: { coll: 0, kind: 'sequence' }, sequenceA: { coll: 0, kind: 'sequence' }, sequence_: { coll: 0, kind: 'sequence' },
|
|
54
56
|
};
|
|
55
57
|
|
|
58
|
+
// Predicates that accept only letters and digits (Data.Char): a string filtered by one holds nothing else.
|
|
59
|
+
const CHAR_CLASS = new Set(['Data.Char.isAlphaNum', 'Data.Char.isAlpha', 'Data.Char.isDigit', 'Data.Char.isHexDigit', 'Data.Char.isOctDigit', 'Data.Char.isLetter', 'Data.Char.isNumber']);
|
|
60
|
+
|
|
56
61
|
const ARITH = new Set(['+', '-', '*', '/', '^', '**', '==', '/=', '<', '<=', '>', '>=', '!!', '%']);
|
|
57
62
|
|
|
58
63
|
const unknownExpr = (hs) => (hs ? { kind: 'unknown', hs } : { kind: 'unknown' });
|
|
@@ -84,7 +89,7 @@ function sigInfo(tokens, sig) {
|
|
|
84
89
|
else if (depth === 0 && t.t === 'rop' && t.v === '->') { argTypes.push(tokens.slice(start, i).map((x) => x.v).join(' ')); start = i + 1; }
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
|
-
return { arity, returnsIO: !!first && first.t === 'con' && first.v === 'IO', text: tokens.slice(sig.ta, sig.tb).map((t) => t.v).join(' '), argTypes };
|
|
92
|
+
return { arity, returnsIO: !!first && first.t === 'con' && first.v === 'IO', text: tokens.slice(sig.ta, sig.tb).map((t) => t.v).join(' '), argTypes, resultText: tokens.slice(resStart, sig.tb).map((t) => t.v).join(' ') };
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
// Parameters of an EXPORTED function whose declared type is text-like: the caller supplies that text, and nothing in the
|
|
@@ -92,10 +97,72 @@ function sigInfo(tokens, sig) {
|
|
|
92
97
|
// request or stdin read: the finding says so). A function without a signature, a non-exported function and a parameter
|
|
93
98
|
// of any other type (a connection, a number, a record) are never sources.
|
|
94
99
|
const TEXT_LIKE = /^\(?\s*(?:Maybe\s+)?(?:String|Text|ByteString|FilePath|URI|Url|URL|\[\s*Char\s*\]|\[\s*(?:String|Text)\s*\])\s*\)?$/;
|
|
95
|
-
function
|
|
100
|
+
// Parameters of an exported function whose declared type is a container or text (a list, a map, a set, a sequence, a vector, a string):
|
|
101
|
+
// a PARTIAL function (`maximum`, `head`, `Map.!`) applied to one fails on the empty or missing case the caller can supply. Wider than
|
|
102
|
+
// TEXT_LIKE on purpose, and used only by the partial-function rule, never as an injection source.
|
|
103
|
+
const COLLECTION_LIKE = /^\(?\s*(?:Maybe\s+)?(?:\[.*\]|(?:Map|HashMap|IntMap|Set|HashSet|IntSet|Seq|Vector|Text|String|ByteString)\b.*)\)?$/;
|
|
104
|
+
function inputParams(fn, sig, exported) {
|
|
105
|
+
if (!exported || !sig || !Array.isArray(sig.argTypes) || !Array.isArray(fn.params)) return [];
|
|
106
|
+
const out = [];
|
|
107
|
+
sig.argTypes.forEach((t, i) => { const name = fn.params[i]; if (name && (TEXT_LIKE.test(t) || COLLECTION_LIKE.test(t))) out.push(name); });
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
function callerControlledParams(fn, sig, exported, imports) {
|
|
96
111
|
if (!exported || !sig || !Array.isArray(sig.argTypes) || !Array.isArray(fn.params)) return [];
|
|
97
112
|
const out = [];
|
|
98
|
-
|
|
113
|
+
// The result type is the part of the signature after the last top-level arrow; a function returning a framework handler value gets its text
|
|
114
|
+
// parameters labelled as request input, any other exported function keeps the weaker caller-controlled label.
|
|
115
|
+
const handler = handlerFrameworkOf(imports, sig.resultText);
|
|
116
|
+
const decorator = handler ? handler.decorator : 'hs:caller-controlled';
|
|
117
|
+
sig.argTypes.forEach((t, i) => { const name = fn.params[i]; if (name && TEXT_LIKE.test(t)) out.push({ index: i, name, decorator }); });
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// A Servant endpoint written as a function (`server acct rowId = ...` for `type API = AuthProtect "jwt" :> Capture "id" Int :> Get ..`): its parameters are
|
|
122
|
+
// the values the API type takes from the request, in order of appearance. Only a single-endpoint API can be paired with one function by position (several
|
|
123
|
+
// endpoints are joined with `:<|>` and each handler is its own function, which the handler-result rule already covers). A parameter is a request source
|
|
124
|
+
// when its API combinator reads the request (capture, query, header, body) and its declared type is text; an authenticated principal is not.
|
|
125
|
+
const SERVANT_READS = new Set(['Capture', "Capture'", 'QueryParam', "QueryParam'", 'QueryParams', 'Header', "Header'", 'ReqBody', "ReqBody'"]);
|
|
126
|
+
const SERVANT_PRINCIPAL = new Set(['AuthProtect', 'BasicAuth', 'Auth']);
|
|
127
|
+
function servantEndpointParams(mod, fn, sig, exported) {
|
|
128
|
+
if (!exported || !sig || !sig.resultText || !Array.isArray(fn.params) || !fn.params.length) return [];
|
|
129
|
+
const m = /^\s*(?:Server|ServerT)\s+([A-Z][A-Za-z0-9_']*)\b/.exec(sig.resultText);
|
|
130
|
+
if (!m || !mod.imports.some((i) => /^Servant(?:\.|$)/.test(i.module))) return [];
|
|
131
|
+
const td = (mod.parse.typeDecls || []).find((d) => d.name === m[1]);
|
|
132
|
+
if (!td) return [];
|
|
133
|
+
const T = mod.parse.tokens;
|
|
134
|
+
const slice = T.slice(td.a, td.b);
|
|
135
|
+
const eq = slice.findIndex((t) => t.t === 'rop' && t.v === '=');
|
|
136
|
+
if (eq < 0) return [];
|
|
137
|
+
const split = (tokens, sep) => {
|
|
138
|
+
const parts = [[]]; let depth = 0;
|
|
139
|
+
for (const t of tokens) {
|
|
140
|
+
if (t.t === 'sp' && (t.v === '(' || t.v === '[')) depth++;
|
|
141
|
+
if (t.t === 'sp' && (t.v === ')' || t.v === ']')) depth--;
|
|
142
|
+
if (depth === 0 && t.t === 'op' && t.v === sep) { parts.push([]); continue; }
|
|
143
|
+
parts[parts.length - 1].push(t);
|
|
144
|
+
}
|
|
145
|
+
return parts;
|
|
146
|
+
};
|
|
147
|
+
const body = slice.slice(eq + 1);
|
|
148
|
+
if (split(body, ':<|>').filter((p) => p.length).length !== 1) return [];
|
|
149
|
+
const out = [];
|
|
150
|
+
let idx = 0;
|
|
151
|
+
for (const part of split(body, ':>').filter((p) => p.length)) {
|
|
152
|
+
const head = part[0];
|
|
153
|
+
if (!head || head.t !== 'con') continue;
|
|
154
|
+
const isPrincipal = SERVANT_PRINCIPAL.has(head.v);
|
|
155
|
+
if (!isPrincipal && !SERVANT_READS.has(head.v)) continue;
|
|
156
|
+
const name = fn.params[idx];
|
|
157
|
+
idx++;
|
|
158
|
+
if (isPrincipal || !name) continue;
|
|
159
|
+
// the carried type is what follows the name string (or the content-type list for a body): its text decides whether it is text
|
|
160
|
+
const lastStr = part.map((t) => t.t).lastIndexOf('str');
|
|
161
|
+
let rest = part.slice(lastStr >= 0 ? lastStr + 1 : 1);
|
|
162
|
+
if (/^ReqBody/.test(head.v)) { const close = rest.findIndex((t) => t.t === 'sp' && t.v === ']'); rest = close >= 0 ? rest.slice(close + 1) : rest; }
|
|
163
|
+
const type = rest.map((t) => t.v).join(' ').trim();
|
|
164
|
+
if (type && TEXT_LIKE.test(type.replace(/^\[\s*(.*)\s*\]$/, '[ $1 ]').replace(/\s+/g, ' '))) out.push({ index: idx - 1, name, decorator: 'hs:handler-param:servant' });
|
|
165
|
+
}
|
|
99
166
|
return out;
|
|
100
167
|
}
|
|
101
168
|
|
|
@@ -197,6 +264,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
197
264
|
const hits = [];
|
|
198
265
|
const wildcard = [];
|
|
199
266
|
let unknownReason = null;
|
|
267
|
+
let unknownCandidates = null;
|
|
200
268
|
for (const imp of visibleImports(mod, name, qual)) {
|
|
201
269
|
const target = proj.modules.get(imp.module);
|
|
202
270
|
if (target) {
|
|
@@ -207,7 +275,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
207
275
|
const owner = r.kind === 'def' ? (r.def.kind === 'field' ? (proj.modules.get(r.mod)?.cons.get(r.def.con)?.type) : r.def.kind === 'method' ? r.def.cls : null) : null;
|
|
208
276
|
if (!owner || !(imp.typeAll || []).includes(owner)) continue;
|
|
209
277
|
}
|
|
210
|
-
if (r.kind === 'unknown') unknownReason = r.reason; else hits.push(r);
|
|
278
|
+
if (r.kind === 'unknown') { unknownReason = r.reason; if (r.candidates) unknownCandidates = r.candidates; } else hits.push(r);
|
|
211
279
|
} else if (qual || (imp.items && !imp.hiding && imp.items.includes(name))) hits.push({ kind: 'external', module: imp.module, name, certain: true });
|
|
212
280
|
else wildcard.push(imp.module); // includes `import M (T(..))` of a module we cannot read: possible, never certain
|
|
213
281
|
}
|
|
@@ -215,7 +283,7 @@ function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
|
215
283
|
const distinct = [...new Map(hits.map((r) => [keyOf(r), r])).values()];
|
|
216
284
|
if (distinct.length === 1) return distinct[0];
|
|
217
285
|
if (distinct.length > 1) return { kind: 'unknown', reason: 'ambiguous-import', candidates: distinct.map(keyOf) };
|
|
218
|
-
if (unknownReason) return { kind: 'unknown', reason: unknownReason };
|
|
286
|
+
if (unknownReason) return unknownCandidates ? { kind: 'unknown', reason: unknownReason, candidates: unknownCandidates } : { kind: 'unknown', reason: unknownReason };
|
|
219
287
|
if (!qual && PRELUDE.has(name)) return { kind: 'builtin', module: 'Prelude', name };
|
|
220
288
|
if (wildcard.length) return { kind: 'external', module: wildcard.length === 1 ? wildcard[0] : null, candidates: wildcard, name, certain: false };
|
|
221
289
|
return { kind: 'unknown', reason: qual ? 'unknown-qualifier' : 'unbound-name' };
|
|
@@ -363,6 +431,25 @@ function inlinableBody(fnAst) {
|
|
|
363
431
|
return acc.ok ? { params: c.pats.map((p) => p.name), body: c.rhs.body } : null;
|
|
364
432
|
}
|
|
365
433
|
|
|
434
|
+
// A point-free alias: `tidy = takeFileName`, `clean = trim . takeFileName`. A zero-argument binding whose whole right-hand side is a
|
|
435
|
+
// function name or a composition of function names IS the function it names, so applying it is applying them. Without this the
|
|
436
|
+
// application is an unresolved call, and a sanitizer (or a sink) reached through the alias loses its identity.
|
|
437
|
+
// Returns the right-hand side AST and the unqualified names it mentions, or null for anything else (guards, where, any other form).
|
|
438
|
+
function aliasBodyOf(fnAst) {
|
|
439
|
+
if (!fnAst || fnAst.clauses.length !== 1) return null;
|
|
440
|
+
const c = fnAst.clauses[0];
|
|
441
|
+
if (c.pats.length || !c.rhs || (c.rhs.guards && c.rhs.guards.length) || (c.rhs.where && c.rhs.where.length) || !c.rhs.body) return null;
|
|
442
|
+
const names = [];
|
|
443
|
+
const ok = (e) => {
|
|
444
|
+
const x = unparen(e);
|
|
445
|
+
if (!x) return false;
|
|
446
|
+
if (x.t === 'var') { if (!x.qual) names.push(x.name); return x.name !== fnAst.name; }
|
|
447
|
+
if (x.t === 'op' && x.op === '.' && !x.qual) return ok(x.l) && ok(x.r);
|
|
448
|
+
return false;
|
|
449
|
+
};
|
|
450
|
+
return ok(c.rhs.body) ? { body: c.rhs.body, names } : null;
|
|
451
|
+
}
|
|
452
|
+
|
|
366
453
|
// ── per-function lowering ────────────────────────────────────────────────────
|
|
367
454
|
class FnLowerer {
|
|
368
455
|
constructor(proj, mod, fnInfo, scopes) {
|
|
@@ -429,6 +516,10 @@ class FnLowerer {
|
|
|
429
516
|
case 'app': return this.lowerApp(e.f, e.args, ln);
|
|
430
517
|
case 'op': return this.lowerOp(e, ln);
|
|
431
518
|
case 'neg': return { kind: 'binary', op: '-', left: { kind: 'literal', value: 0 }, right: this.lowerExpr(e.e, ln) };
|
|
519
|
+
// `read s :: Int`: the value is a number, so it is a clean constant for the taint engine. The lowered inner expression rides on `hs.inner`, which the rule and web
|
|
520
|
+
// analyses read (an unsafe coercion in it, the id it names), exactly as they read `hs.selector`. (A sanitizer call would be wrong here: the engine's sanitizer label is per expression, so a
|
|
521
|
+
// number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized too.)
|
|
522
|
+
case 'typed': return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
|
|
432
523
|
case 'tuple': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { tuple: true } };
|
|
433
524
|
case 'list': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)) };
|
|
434
525
|
case 'range': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { range: true } };
|
|
@@ -494,6 +585,7 @@ class FnLowerer {
|
|
|
494
585
|
return { kind: 'ident', name: this.canonical(r), hs: { functionRef: r.qid } };
|
|
495
586
|
}
|
|
496
587
|
if (r.kind === 'param' || r.kind === 'local') return { kind: 'ident', name: e.name };
|
|
588
|
+
if (r.kind === 'unknown' && r.reason === 'ambiguous-import') for (const q of r.candidates || []) this.noteRef(q, 'ambiguous-import', ln);
|
|
497
589
|
const lib = libraryName(r, e.name, e.qual);
|
|
498
590
|
const viaReg = lib.viaRegistry ? { viaRegistry: true, certain: false } : {};
|
|
499
591
|
// A modelled SOURCE action named as a value (`getLine >>= f`, `fmap g getLine`) yields untrusted data
|
|
@@ -526,7 +618,9 @@ class FnLowerer {
|
|
|
526
618
|
}
|
|
527
619
|
if (f.t === 'rsec') return this.lowerOp({ t: 'op', op: f.op, qual: f.qual, l: argAsts[0], r: f.e, line: ln }, ln);
|
|
528
620
|
if (f.t === 'lsec') return this.lowerOp({ t: 'op', op: f.op, qual: f.qual, l: f.e, r: argAsts[0], line: ln }, ln);
|
|
529
|
-
if (f.t === 'lam') return this.betaReduce(f, argAsts, ln);
|
|
621
|
+
if (f.t === 'lam') return this.betaReduce(f, argAsts, ln, via);
|
|
622
|
+
// `m >>= \case ...`: a \case applied to its argument is a case on that argument, run here (not a call of an unknown lifted function)
|
|
623
|
+
if (f.t === 'lamcase') return this.betaReduce({ params: [{ t: 'pvar', name: '$lc' }], body: { t: 'case', scrut: { t: 'var', name: '$lc', qual: null }, alts: f.alts, line: ln }, line: ln }, argAsts, ln, via);
|
|
530
624
|
if (f.t === 'var') return this.applyVar(f, argAsts, ln, via);
|
|
531
625
|
if (f.t === 'con') {
|
|
532
626
|
const cons = this.mod.cons.get(f.name) || this.proj.findCon(f.name);
|
|
@@ -539,11 +633,14 @@ class FnLowerer {
|
|
|
539
633
|
return { kind: 'call', callee: '<indirect>', args: [fv, ...argAsts.map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'computed-function', via } };
|
|
540
634
|
}
|
|
541
635
|
|
|
542
|
-
betaReduce(lam, argAsts, ln) {
|
|
636
|
+
betaReduce(lam, argAsts, ln, via) {
|
|
543
637
|
const n = Math.min(lam.params.length, argAsts.length);
|
|
544
638
|
this.push();
|
|
545
639
|
try {
|
|
546
|
-
|
|
640
|
+
// `action >>= \x -> k` and `f <$> action` RUN the action and hand its result to the lambda, even when the lambda ignores it (`\_ -> k`):
|
|
641
|
+
// the action is executed where it is sequenced, not only where its result is used.
|
|
642
|
+
const runs = via === 'bind' || via === 'fmap' || via === 'applicative';
|
|
643
|
+
for (let i = 0; i < n; i++) this.bindPattern(lam.params[i], this.lowerExpr(argAsts[i], ln), ln, runs ? { executed: true } : undefined);
|
|
547
644
|
if (lam.params.length > argAsts.length) return { kind: 'array', elements: argAsts.map((a) => this.lowerExpr(a, ln)), hs: { partial: { remaining: lam.params.length - argAsts.length }, closure: true } };
|
|
548
645
|
const body = this.lowerExpr(lam.body, ln);
|
|
549
646
|
if (argAsts.length > n) return { kind: 'call', callee: '<indirect>', args: [body, ...argAsts.slice(n).map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'result-applied' } };
|
|
@@ -558,11 +655,30 @@ class FnLowerer {
|
|
|
558
655
|
const out = this.lowerHof(f.name, argAsts, ln);
|
|
559
656
|
if (out) return out;
|
|
560
657
|
}
|
|
658
|
+
// `maybe d f m` and `either f g e` apply a function to the value inside: `f` (and `g`) are called with it, so a sink passed as the function
|
|
659
|
+
// (`maybe (pure ()) callCommand mc`) is reached by the value. The result is whichever branch ran.
|
|
660
|
+
if ((r.kind === 'builtin' || r.kind === 'external') && ((f.name === 'maybe' && argAsts.length >= 3) || (f.name === 'either' && argAsts.length >= 3))) {
|
|
661
|
+
// The scrutinee is examined whichever branch runs, so it is the condition of a two-way branch (an ignored payload, `\\_ -> ..`, still reads it).
|
|
662
|
+
const scrut = this.lowerExpr(argAsts[2], ln);
|
|
663
|
+
const inner = { t: 'lowered', expr: scrut };
|
|
664
|
+
return f.name === 'maybe'
|
|
665
|
+
? this.branch(scrut, ln, () => this.lowerApp(argAsts[1], [inner], ln, f.name), () => this.lowerExpr(argAsts[0], ln))
|
|
666
|
+
: this.branch(scrut, ln, () => this.lowerApp(argAsts[1], [inner], ln, f.name), () => this.lowerApp(argAsts[0], [inner], ln, f.name));
|
|
667
|
+
}
|
|
668
|
+
// Selectors and combinators over a tuple that is written out: `fst (a, b)` is `a`, `const x y` is `x`, `uncurry f (a, b)` is `f a b`.
|
|
669
|
+
if ((r.kind === 'builtin' || r.kind === 'external') && !f.qual) {
|
|
670
|
+
const tup = (a) => { const u = unparen(a); return u && u.t === 'tuple' && u.items.length === 2 ? u.items : null; };
|
|
671
|
+
if ((f.name === 'fst' || f.name === 'snd') && argAsts.length === 1 && tup(argAsts[0])) return this.lowerExpr(tup(argAsts[0])[f.name === 'fst' ? 0 : 1], ln);
|
|
672
|
+
if (f.name === 'const' && argAsts.length === 2) { this.lowerExpr(argAsts[1], ln); return this.lowerExpr(argAsts[0], ln); }
|
|
673
|
+
if (f.name === 'uncurry' && argAsts.length === 2 && tup(argAsts[1])) return this.lowerApp(argAsts[0], tup(argAsts[1]), ln, via);
|
|
674
|
+
}
|
|
561
675
|
if (r.kind === 'def' && r.def.kind === 'field' && argAsts.length >= 1) {
|
|
562
676
|
const obj = this.lowerExpr(argAsts[0], ln);
|
|
563
677
|
const mem = { kind: 'member', object: obj, prop: r.name };
|
|
564
678
|
return argAsts.length === 1 ? mem : { kind: 'call', callee: '<indirect>', args: [mem, ...argAsts.slice(1).map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'field-applied' } };
|
|
565
679
|
}
|
|
680
|
+
const viaAlias = this.applyAlias(r, argAsts, ln, via);
|
|
681
|
+
if (viaAlias) return viaAlias;
|
|
566
682
|
let args = argAsts.map((a) => this.lowerExpr(a, ln));
|
|
567
683
|
// `lookup key table` returns an element OF the table: the key chooses which, it is not where the value comes from. Only the table
|
|
568
684
|
// flows to the result, so a constant table gives a constant result however tainted the key is.
|
|
@@ -611,6 +727,10 @@ class FnLowerer {
|
|
|
611
727
|
if (IO_NAMES.has(f.name)) this.sawIO = true;
|
|
612
728
|
} else {
|
|
613
729
|
hs.status = 'unknown'; hs.reason = r.reason || 'unresolved'; hs.effect = 'unknown';
|
|
730
|
+
// An import that two modules both satisfy (the Posix and Windows variants of one module behind a CPP conditional, two re-exports of
|
|
731
|
+
// one name) is a call to ONE of them, which one is not decided here. Each candidate is referenced, so none of them reads as a
|
|
732
|
+
// function nothing in the project uses.
|
|
733
|
+
if (r.reason === 'ambiguous-import') for (const q of r.candidates || []) this.noteRef(q, 'ambiguous-import', ln);
|
|
614
734
|
}
|
|
615
735
|
if (hs.status === 'resolved' || hs.status === 'typeclass') {
|
|
616
736
|
const ctxTargets = args.map((a) => argTargets(a));
|
|
@@ -635,6 +755,39 @@ class FnLowerer {
|
|
|
635
755
|
return { kind: 'ident', name: t, hs: { sourceTemp: call.callee } };
|
|
636
756
|
}
|
|
637
757
|
|
|
758
|
+
// Apply a point-free alias (see aliasBodyOf) as the function(s) it names. The names are looked up where the alias was DEFINED; a
|
|
759
|
+
// local binding of one of them at the use site would capture it, so that case is left to the ordinary (unresolved) call.
|
|
760
|
+
applyAlias(r, argAsts, ln, via) {
|
|
761
|
+
let alias = null; let calleeMod = this.mod;
|
|
762
|
+
if (r.kind === 'local' && r.alias) alias = r.alias;
|
|
763
|
+
else if (r.kind === 'def' && r.def.kind === 'fun' && r.def.arity === 0) {
|
|
764
|
+
calleeMod = this.proj.modules.get(r.mod);
|
|
765
|
+
alias = calleeMod ? aliasBodyOf(calleeMod.groups.funs.find((f) => f.name === r.name)) : null;
|
|
766
|
+
}
|
|
767
|
+
if (!alias || (this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
|
|
768
|
+
if (alias.names.some((n) => this.lookupLocal(n))) return null;
|
|
769
|
+
const savedMod = this.mod;
|
|
770
|
+
// Only an alias of LIBRARY functions is expanded. A project function keeps its own summary and call-graph node, and
|
|
771
|
+
// expanding it here would replace the edge to it with edges to what it calls.
|
|
772
|
+
this.mod = calleeMod;
|
|
773
|
+
const libraryOnly = alias.names.every((n) => { const k = this.resolve(n, null).kind; return k === 'external' || k === 'builtin'; });
|
|
774
|
+
this.mod = savedMod;
|
|
775
|
+
if (!libraryOnly) return null;
|
|
776
|
+
// Arguments belong to the CALLER: lower them here, once, then hand them over as already-lowered expressions.
|
|
777
|
+
const lowered = argAsts.map((a) => ({ t: 'lowered', expr: this.lowerExpr(a, ln) }));
|
|
778
|
+
const savedOverride = this.lineOverride;
|
|
779
|
+
this.lineOverride = this.lineOverride || ln;
|
|
780
|
+
this.inlineDepth = (this.inlineDepth || 0) + 1;
|
|
781
|
+
this.mod = calleeMod;
|
|
782
|
+
// The alias is still CALLED here: keep its call-graph edge, exactly as an inlined function body does.
|
|
783
|
+
if (r.kind === 'def') (this.inlinedCalls ||= []).push({ site: null, callee: this.canonical(r), args: [], line: ln, hs: { status: 'resolved', target: r.qid, effect: 'unknown', inlined: true, ...(via ? { via } : {}) } });
|
|
784
|
+
try { return this.lowerApp(alias.body, lowered, ln); } finally {
|
|
785
|
+
this.mod = savedMod;
|
|
786
|
+
this.lineOverride = savedOverride;
|
|
787
|
+
this.inlineDepth -= 1;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
638
791
|
// Substitute lowered arguments into a small pure callee body. Returns null when the callee is not inlinable.
|
|
639
792
|
tryInline(r, loweredArgs, ln, via) {
|
|
640
793
|
const calleeMod = this.proj.modules.get(r.mod);
|
|
@@ -673,19 +826,31 @@ class FnLowerer {
|
|
|
673
826
|
// Bounded collection/traversal summaries. Returns null to fall back to a plain call.
|
|
674
827
|
lowerHof(name, argAsts, ln) {
|
|
675
828
|
const spec = HOF[name];
|
|
676
|
-
const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll) + 1;
|
|
829
|
+
const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll, spec.coll2 === undefined ? 0 : spec.coll2) + 1;
|
|
677
830
|
if (argAsts.length < need || (spec.seed !== undefined && argAsts.length < 3)) return null;
|
|
678
831
|
const coll = this.lowerExpr(argAsts[spec.coll], ln);
|
|
679
832
|
const elem = { kind: 'member', object: coll, prop: '[]' };
|
|
833
|
+
// a function of two collections (`zipWithM_ f xs ys`): it is applied to an element of each
|
|
834
|
+
const el2 = spec.coll2 !== undefined && argAsts[spec.coll2] ? { t: 'lowered', expr: { kind: 'member', object: this.lowerExpr(argAsts[spec.coll2], ln), prop: '[]' } } : null;
|
|
680
835
|
if (spec.kind === 'sequence') { this.sawIO = true; return coll; }
|
|
681
836
|
const fnAst = argAsts[spec.fn];
|
|
682
837
|
const el = { t: 'lowered', expr: elem };
|
|
683
838
|
if (spec.kind === 'fold') {
|
|
684
839
|
const seed = this.lowerExpr(argAsts[spec.seed], ln);
|
|
685
840
|
const step = this.lowerApp(fnAst, [spec.fn === 0 && (name === 'foldr') ? el : { t: 'lowered', expr: seed }, name === 'foldr' ? { t: 'lowered', expr: seed } : el], ln, name);
|
|
841
|
+
if (spec.monadic) this.sawIO = true;
|
|
686
842
|
return mkUnion(seed, step);
|
|
687
843
|
}
|
|
688
|
-
const res = this.lowerApp(fnAst, [el], ln, name);
|
|
844
|
+
const res = this.lowerApp(fnAst, el2 ? [el, el2] : [el], ln, name);
|
|
845
|
+
// `filter isAlphaNum s` keeps only letters and digits: no quote, separator, dot or markup character survives, so nothing in the result
|
|
846
|
+
// can change the meaning of a command, a query, a path or a page. A predicate that merely drops some characters (`/= ';'`) is not that.
|
|
847
|
+
if (spec.kind === 'filter') {
|
|
848
|
+
const pf = unparen(fnAst);
|
|
849
|
+
if (pf && pf.t === 'var') {
|
|
850
|
+
const pr = this.resolve(pf.name, pf.qual);
|
|
851
|
+
if ((pr.kind === 'external' || pr.kind === 'builtin') && CHAR_CLASS.has(libraryName(pr, pf.name, pf.qual).name)) return { kind: 'literal', value: '', hs: { charClassFilter: pf.name } };
|
|
852
|
+
}
|
|
853
|
+
}
|
|
689
854
|
if (spec.kind === 'filter') {
|
|
690
855
|
this.cfg.add('if', ln, { cond: res, hs: { via: 'filter' } });
|
|
691
856
|
return coll;
|
|
@@ -920,6 +1085,14 @@ class FnLowerer {
|
|
|
920
1085
|
processDecls(decls, bodyAsts, ln) {
|
|
921
1086
|
if (!decls.length) return;
|
|
922
1087
|
const g = groupDecls(decls);
|
|
1088
|
+
// `let run = \x -> ...` is a function of one parameter written in lambda form: treat it as `run x = ...` so a call `run c` is resolved and analysed
|
|
1089
|
+
// for the value it is given, instead of being an unknown call of a local value.
|
|
1090
|
+
g.funs = g.funs.map((f) => {
|
|
1091
|
+
if (f.clauses.length !== 1) return f;
|
|
1092
|
+
const c = f.clauses[0];
|
|
1093
|
+
const body = c.pats.length === 0 && c.rhs && !(c.rhs.guards && c.rhs.guards.length) && !(c.rhs.where && c.rhs.where.length) ? unparen(c.rhs.body) : null;
|
|
1094
|
+
return body && body.t === 'lam' && body.params.every((p) => p && p.t === 'pvar') ? { ...f, clauses: [{ ...c, pats: body.params, rhs: { ...c.rhs, body: body.body } }] } : f;
|
|
1095
|
+
});
|
|
923
1096
|
const scope = this.scopes[this.scopes.length - 1];
|
|
924
1097
|
const parent = this.info.parentName || this.info.name;
|
|
925
1098
|
for (const f of g.funs) {
|
|
@@ -940,6 +1113,9 @@ class FnLowerer {
|
|
|
940
1113
|
const rhsAsts = f.clauses.map((c) => c.rhs);
|
|
941
1114
|
const others = siblingAsts.filter((a) => a && !rhsAsts.includes(a));
|
|
942
1115
|
this.bindLazy([f.name], () => this.lowerRhs(f.clauses[0].rhs, f.line || ln), bodyAsts, others, strict, f.line || ln);
|
|
1116
|
+
// bindLazy declares a plain local; a point-free alias keeps what it names so applying it resolves to that function.
|
|
1117
|
+
const alias = aliasBodyOf(f);
|
|
1118
|
+
if (alias && !alias.names.some((n) => this.lookupLocal(n))) scope.set(f.name, { kind: 'local', alias });
|
|
943
1119
|
}
|
|
944
1120
|
for (const pb of g.patbinds) {
|
|
945
1121
|
const names = patternVars(pb.pat);
|
|
@@ -1162,8 +1338,11 @@ function lowerTopFunction(proj, mod, fun, def) {
|
|
|
1162
1338
|
const kind = def.arity > 0 ? 'function' : (sig && sig.returnsIO) || fun.name === 'main' ? 'io-action' : 'value';
|
|
1163
1339
|
const exported = !mod.exports || mod.exports.some((x) => x.kind === 'var' && x.name === fun.name || x.kind === 'module' && x.name === mod.name);
|
|
1164
1340
|
const fn = L.finishFn({ kind, arity: def.arity, effect: sig ? (sig.returnsIO ? 'io' : 'pure') : (L.sawIO ? 'io' : 'unknown'), sig: sig ? sig.text : null, exported });
|
|
1165
|
-
const ann = callerControlledParams(fn, sig, exported);
|
|
1341
|
+
const ann = callerControlledParams(fn, sig, exported, mod.imports.map((i) => i.module));
|
|
1342
|
+
for (const a of servantEndpointParams(mod, fn, sig, exported)) if (!ann.some((x) => x.name === a.name)) ann.push(a);
|
|
1166
1343
|
fn.hs.textParams = ann.map((a) => a.name);
|
|
1344
|
+
fn.hs.paramSources = ann.map((a) => ({ name: a.name, decorator: a.decorator }));
|
|
1345
|
+
fn.hs.inputParams = inputParams(fn, sig, exported);
|
|
1167
1346
|
// Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
|
|
1168
1347
|
// parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
|
|
1169
1348
|
const recTypes = proj.recordTypes();
|
|
@@ -1282,7 +1461,13 @@ function buildHaskellIRUncached(fileContents) {
|
|
|
1282
1461
|
const reached = new Set();
|
|
1283
1462
|
for (const e of callGraph.edges || []) if (e && e.callee) reached.add(e.callee);
|
|
1284
1463
|
for (const fn of callGraph.functions.values()) for (const r of (fn.hs && fn.hs.refs) || []) if (r && r.target) reached.add(r.target);
|
|
1285
|
-
|
|
1464
|
+
assignEntryRoles(proj, callGraph);
|
|
1465
|
+
for (const fn of callGraph.functions.values()) {
|
|
1466
|
+
// `reached`: something in the analysed code calls or references this function, so its parameters take their values from THOSE callers
|
|
1467
|
+
// (which the taint engine follows) and not from an unknown outside one. Rules that reason about "caller-supplied" text read it.
|
|
1468
|
+
if (fn.hs) fn.hs.reached = reached.has(fn.qid);
|
|
1469
|
+
if (reached.has(fn.qid)) { if (fn.paramAnnotations) delete fn.paramAnnotations; if (fn.hs && fn.hs.recordParams) fn.hs.recordParams = []; }
|
|
1470
|
+
}
|
|
1286
1471
|
return { perFile, callGraph, modules: proj.modules, diagnostics: proj.diagnostics, project: proj };
|
|
1287
1472
|
}
|
|
1288
1473
|
|
|
@@ -1296,6 +1481,36 @@ function incomplete(proj, mod, f, err) {
|
|
|
1296
1481
|
proj.diagnostics.push({ file: mod.file, line: f.line, kind: 'ir-lowering-failure', detail: String(err && err.message) });
|
|
1297
1482
|
}
|
|
1298
1483
|
|
|
1484
|
+
// ── entry roles ──────────────────────────────────────────────────────────────
|
|
1485
|
+
// Who is on the other end of the program's inputs depends on what kind of program the function belongs to. A function reachable from a
|
|
1486
|
+
// `main` whose reachable modules import no server framework or socket library belongs to a command-line program: whoever runs it supplies its
|
|
1487
|
+
// arguments, environment, standard input and the files it is pointed at. One reachable from a `main` that imports a server framework (a WAI,
|
|
1488
|
+
// Scotty, Servant, Yesod, Snap, Happstack or Spock application, a socket listener) belongs to a service, whose inputs come from its clients.
|
|
1489
|
+
// A function no `main` reaches is `library`: its callers are unknown. The call graph is incomplete (an unresolved call ends a path), so a
|
|
1490
|
+
// function the walk does not reach is `library`, never `cli`.
|
|
1491
|
+
const SERVER_IMPORT = /^(?:Network\.Wai|Web\.Scotty|Servant|Yesod|Snap|Happstack\.Server|Web\.Spock)(?:\.|$)|^Network\.(?:Socket|Simple\.TCP|Run\.TCP|HTTP\.Server)$/;
|
|
1492
|
+
export function assignEntryRoles(proj, callGraph) {
|
|
1493
|
+
const out = new Map();
|
|
1494
|
+
for (const e of callGraph.edges || []) if (e && e.callee && e.caller) { if (!out.has(e.caller)) out.set(e.caller, []); out.get(e.caller).push(e.callee); }
|
|
1495
|
+
const mains = [];
|
|
1496
|
+
for (const fn of callGraph.functions.values()) if (fn.hs && fn.hs.module && fn.name === `${fn.hs.module}.main`) mains.push(fn);
|
|
1497
|
+
const cli = new Set(); const service = new Set();
|
|
1498
|
+
for (const m of mains) {
|
|
1499
|
+
const seen = new Set([m.qid]);
|
|
1500
|
+
const todo = [m.qid];
|
|
1501
|
+
while (todo.length) { const q = todo.pop(); for (const c of out.get(q) || []) if (!seen.has(c)) { seen.add(c); todo.push(c); } }
|
|
1502
|
+
const mods = new Set();
|
|
1503
|
+
for (const q of seen) { const f = callGraph.functions.get(q); if (f && f.hs && f.hs.module) mods.add(f.hs.module); }
|
|
1504
|
+
let isService = false;
|
|
1505
|
+
for (const name of mods) { const mi = proj.modules.get(name); if (mi && (mi.imports || []).some((i) => SERVER_IMPORT.test(i.module))) { isService = true; break; } }
|
|
1506
|
+
for (const q of seen) (isService ? service : cli).add(q);
|
|
1507
|
+
}
|
|
1508
|
+
for (const fn of callGraph.functions.values()) {
|
|
1509
|
+
if (!fn.hs) continue;
|
|
1510
|
+
fn.hs.entryRole = service.has(fn.qid) ? 'service' : (cli.has(fn.qid) ? 'cli' : 'library');
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1299
1514
|
// ── call graph ───────────────────────────────────────────────────────────────
|
|
1300
1515
|
function buildHaskellCallGraph(perFile, proj) {
|
|
1301
1516
|
const functions = new Map();
|