@clear-capabilities/agentic-security-scanner 0.186.2 → 0.187.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/1602.index.js +275 -19
- package/dist/9560.index.js +61 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{4d06c8944ae121d7085c.json → deabc47d0e27e21bf99b.json} +4 -4
- package/package.json +4 -4
- package/src/capabilities/decide.js +7 -1
- package/src/dataflow/CLAUDE.md +2 -0
- package/src/dataflow/catalog-haskell.js +7 -1
- package/src/dataflow/engine.js +16 -0
- package/src/dataflow/privacy-catalog-haskell.js +2 -1
- package/src/language/CLAUDE.md +19 -0
- package/src/language/compliance-map.js +6 -0
- package/src/language/context.js +20 -0
- package/src/language/haskell-disclosure.js +16 -4
- package/src/language/haskell-guards.js +130 -3
- package/src/language/haskell-ir.js +182 -10
- package/src/language/haskell-models.js +93 -4
- package/src/language/haskell-security-rules.js +112 -34
- package/src/language/haskell-syntax.js +10 -1
- package/src/language/haskell-web.js +44 -8
- package/src/language/support-registry.js +3 -2
- package/src/mcp/tools.js +54 -3
- package/src/posture/attack-taxonomy.js +10 -0
- package/src/posture/entrypoint-inventory.js +6 -2
- package/src/posture/proof-fabric/generate-haskell.js +266 -17
- package/src/posture/proof-fabric/generate.js +10 -3
- package/src/posture/proof-fabric/oracles.js +21 -0
- package/src/posture/stable-id.js +2 -1
- package/src/report/index.js +3 -0
- package/dist/2066.index.js +0 -851
|
@@ -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)) {
|
|
@@ -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
|
|
|
@@ -363,6 +430,25 @@ function inlinableBody(fnAst) {
|
|
|
363
430
|
return acc.ok ? { params: c.pats.map((p) => p.name), body: c.rhs.body } : null;
|
|
364
431
|
}
|
|
365
432
|
|
|
433
|
+
// A point-free alias: `tidy = takeFileName`, `clean = trim . takeFileName`. A zero-argument binding whose whole right-hand side is a
|
|
434
|
+
// function name or a composition of function names IS the function it names, so applying it is applying them. Without this the
|
|
435
|
+
// application is an unresolved call, and a sanitizer (or a sink) reached through the alias loses its identity.
|
|
436
|
+
// Returns the right-hand side AST and the unqualified names it mentions, or null for anything else (guards, where, any other form).
|
|
437
|
+
function aliasBodyOf(fnAst) {
|
|
438
|
+
if (!fnAst || fnAst.clauses.length !== 1) return null;
|
|
439
|
+
const c = fnAst.clauses[0];
|
|
440
|
+
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;
|
|
441
|
+
const names = [];
|
|
442
|
+
const ok = (e) => {
|
|
443
|
+
const x = unparen(e);
|
|
444
|
+
if (!x) return false;
|
|
445
|
+
if (x.t === 'var') { if (!x.qual) names.push(x.name); return x.name !== fnAst.name; }
|
|
446
|
+
if (x.t === 'op' && x.op === '.' && !x.qual) return ok(x.l) && ok(x.r);
|
|
447
|
+
return false;
|
|
448
|
+
};
|
|
449
|
+
return ok(c.rhs.body) ? { body: c.rhs.body, names } : null;
|
|
450
|
+
}
|
|
451
|
+
|
|
366
452
|
// ── per-function lowering ────────────────────────────────────────────────────
|
|
367
453
|
class FnLowerer {
|
|
368
454
|
constructor(proj, mod, fnInfo, scopes) {
|
|
@@ -429,6 +515,10 @@ class FnLowerer {
|
|
|
429
515
|
case 'app': return this.lowerApp(e.f, e.args, ln);
|
|
430
516
|
case 'op': return this.lowerOp(e, ln);
|
|
431
517
|
case 'neg': return { kind: 'binary', op: '-', left: { kind: 'literal', value: 0 }, right: this.lowerExpr(e.e, ln) };
|
|
518
|
+
// `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
|
|
519
|
+
// 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
|
|
520
|
+
// number in `"kill " ++ show n ++ name` would mark the tainted `name` sanitized too.)
|
|
521
|
+
case 'typed': return { kind: 'literal', value: 0, hs: { numeric: e.type, inner: this.lowerExpr(e.e, ln) } };
|
|
432
522
|
case 'tuple': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { tuple: true } };
|
|
433
523
|
case 'list': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)) };
|
|
434
524
|
case 'range': return { kind: 'array', elements: e.items.map((x) => this.lowerExpr(x, ln)), hs: { range: true } };
|
|
@@ -526,7 +616,9 @@ class FnLowerer {
|
|
|
526
616
|
}
|
|
527
617
|
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
618
|
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);
|
|
619
|
+
if (f.t === 'lam') return this.betaReduce(f, argAsts, ln, via);
|
|
620
|
+
// `m >>= \case ...`: a \case applied to its argument is a case on that argument, run here (not a call of an unknown lifted function)
|
|
621
|
+
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
622
|
if (f.t === 'var') return this.applyVar(f, argAsts, ln, via);
|
|
531
623
|
if (f.t === 'con') {
|
|
532
624
|
const cons = this.mod.cons.get(f.name) || this.proj.findCon(f.name);
|
|
@@ -539,11 +631,14 @@ class FnLowerer {
|
|
|
539
631
|
return { kind: 'call', callee: '<indirect>', args: [fv, ...argAsts.map((a) => this.lowerExpr(a, ln))], hs: { status: 'unknown', reason: 'computed-function', via } };
|
|
540
632
|
}
|
|
541
633
|
|
|
542
|
-
betaReduce(lam, argAsts, ln) {
|
|
634
|
+
betaReduce(lam, argAsts, ln, via) {
|
|
543
635
|
const n = Math.min(lam.params.length, argAsts.length);
|
|
544
636
|
this.push();
|
|
545
637
|
try {
|
|
546
|
-
|
|
638
|
+
// `action >>= \x -> k` and `f <$> action` RUN the action and hand its result to the lambda, even when the lambda ignores it (`\_ -> k`):
|
|
639
|
+
// the action is executed where it is sequenced, not only where its result is used.
|
|
640
|
+
const runs = via === 'bind' || via === 'fmap' || via === 'applicative';
|
|
641
|
+
for (let i = 0; i < n; i++) this.bindPattern(lam.params[i], this.lowerExpr(argAsts[i], ln), ln, runs ? { executed: true } : undefined);
|
|
547
642
|
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
643
|
const body = this.lowerExpr(lam.body, ln);
|
|
549
644
|
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 +653,30 @@ class FnLowerer {
|
|
|
558
653
|
const out = this.lowerHof(f.name, argAsts, ln);
|
|
559
654
|
if (out) return out;
|
|
560
655
|
}
|
|
656
|
+
// `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
|
|
657
|
+
// (`maybe (pure ()) callCommand mc`) is reached by the value. The result is whichever branch ran.
|
|
658
|
+
if ((r.kind === 'builtin' || r.kind === 'external') && ((f.name === 'maybe' && argAsts.length >= 3) || (f.name === 'either' && argAsts.length >= 3))) {
|
|
659
|
+
// The scrutinee is examined whichever branch runs, so it is the condition of a two-way branch (an ignored payload, `\\_ -> ..`, still reads it).
|
|
660
|
+
const scrut = this.lowerExpr(argAsts[2], ln);
|
|
661
|
+
const inner = { t: 'lowered', expr: scrut };
|
|
662
|
+
return f.name === 'maybe'
|
|
663
|
+
? this.branch(scrut, ln, () => this.lowerApp(argAsts[1], [inner], ln, f.name), () => this.lowerExpr(argAsts[0], ln))
|
|
664
|
+
: this.branch(scrut, ln, () => this.lowerApp(argAsts[1], [inner], ln, f.name), () => this.lowerApp(argAsts[0], [inner], ln, f.name));
|
|
665
|
+
}
|
|
666
|
+
// 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`.
|
|
667
|
+
if ((r.kind === 'builtin' || r.kind === 'external') && !f.qual) {
|
|
668
|
+
const tup = (a) => { const u = unparen(a); return u && u.t === 'tuple' && u.items.length === 2 ? u.items : null; };
|
|
669
|
+
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);
|
|
670
|
+
if (f.name === 'const' && argAsts.length === 2) { this.lowerExpr(argAsts[1], ln); return this.lowerExpr(argAsts[0], ln); }
|
|
671
|
+
if (f.name === 'uncurry' && argAsts.length === 2 && tup(argAsts[1])) return this.lowerApp(argAsts[0], tup(argAsts[1]), ln, via);
|
|
672
|
+
}
|
|
561
673
|
if (r.kind === 'def' && r.def.kind === 'field' && argAsts.length >= 1) {
|
|
562
674
|
const obj = this.lowerExpr(argAsts[0], ln);
|
|
563
675
|
const mem = { kind: 'member', object: obj, prop: r.name };
|
|
564
676
|
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
677
|
}
|
|
678
|
+
const viaAlias = this.applyAlias(r, argAsts, ln, via);
|
|
679
|
+
if (viaAlias) return viaAlias;
|
|
566
680
|
let args = argAsts.map((a) => this.lowerExpr(a, ln));
|
|
567
681
|
// `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
682
|
// flows to the result, so a constant table gives a constant result however tainted the key is.
|
|
@@ -635,6 +749,39 @@ class FnLowerer {
|
|
|
635
749
|
return { kind: 'ident', name: t, hs: { sourceTemp: call.callee } };
|
|
636
750
|
}
|
|
637
751
|
|
|
752
|
+
// Apply a point-free alias (see aliasBodyOf) as the function(s) it names. The names are looked up where the alias was DEFINED; a
|
|
753
|
+
// local binding of one of them at the use site would capture it, so that case is left to the ordinary (unresolved) call.
|
|
754
|
+
applyAlias(r, argAsts, ln, via) {
|
|
755
|
+
let alias = null; let calleeMod = this.mod;
|
|
756
|
+
if (r.kind === 'local' && r.alias) alias = r.alias;
|
|
757
|
+
else if (r.kind === 'def' && r.def.kind === 'fun' && r.def.arity === 0) {
|
|
758
|
+
calleeMod = this.proj.modules.get(r.mod);
|
|
759
|
+
alias = calleeMod ? aliasBodyOf(calleeMod.groups.funs.find((f) => f.name === r.name)) : null;
|
|
760
|
+
}
|
|
761
|
+
if (!alias || (this.inlineDepth || 0) >= INLINE_MAX_DEPTH) return null;
|
|
762
|
+
if (alias.names.some((n) => this.lookupLocal(n))) return null;
|
|
763
|
+
const savedMod = this.mod;
|
|
764
|
+
// Only an alias of LIBRARY functions is expanded. A project function keeps its own summary and call-graph node, and
|
|
765
|
+
// expanding it here would replace the edge to it with edges to what it calls.
|
|
766
|
+
this.mod = calleeMod;
|
|
767
|
+
const libraryOnly = alias.names.every((n) => { const k = this.resolve(n, null).kind; return k === 'external' || k === 'builtin'; });
|
|
768
|
+
this.mod = savedMod;
|
|
769
|
+
if (!libraryOnly) return null;
|
|
770
|
+
// Arguments belong to the CALLER: lower them here, once, then hand them over as already-lowered expressions.
|
|
771
|
+
const lowered = argAsts.map((a) => ({ t: 'lowered', expr: this.lowerExpr(a, ln) }));
|
|
772
|
+
const savedOverride = this.lineOverride;
|
|
773
|
+
this.lineOverride = this.lineOverride || ln;
|
|
774
|
+
this.inlineDepth = (this.inlineDepth || 0) + 1;
|
|
775
|
+
this.mod = calleeMod;
|
|
776
|
+
// The alias is still CALLED here: keep its call-graph edge, exactly as an inlined function body does.
|
|
777
|
+
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 } : {}) } });
|
|
778
|
+
try { return this.lowerApp(alias.body, lowered, ln); } finally {
|
|
779
|
+
this.mod = savedMod;
|
|
780
|
+
this.lineOverride = savedOverride;
|
|
781
|
+
this.inlineDepth -= 1;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
638
785
|
// Substitute lowered arguments into a small pure callee body. Returns null when the callee is not inlinable.
|
|
639
786
|
tryInline(r, loweredArgs, ln, via) {
|
|
640
787
|
const calleeMod = this.proj.modules.get(r.mod);
|
|
@@ -673,19 +820,31 @@ class FnLowerer {
|
|
|
673
820
|
// Bounded collection/traversal summaries. Returns null to fall back to a plain call.
|
|
674
821
|
lowerHof(name, argAsts, ln) {
|
|
675
822
|
const spec = HOF[name];
|
|
676
|
-
const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll) + 1;
|
|
823
|
+
const need = Math.max(spec.fn === undefined ? 0 : spec.fn, spec.coll, spec.coll2 === undefined ? 0 : spec.coll2) + 1;
|
|
677
824
|
if (argAsts.length < need || (spec.seed !== undefined && argAsts.length < 3)) return null;
|
|
678
825
|
const coll = this.lowerExpr(argAsts[spec.coll], ln);
|
|
679
826
|
const elem = { kind: 'member', object: coll, prop: '[]' };
|
|
827
|
+
// a function of two collections (`zipWithM_ f xs ys`): it is applied to an element of each
|
|
828
|
+
const el2 = spec.coll2 !== undefined && argAsts[spec.coll2] ? { t: 'lowered', expr: { kind: 'member', object: this.lowerExpr(argAsts[spec.coll2], ln), prop: '[]' } } : null;
|
|
680
829
|
if (spec.kind === 'sequence') { this.sawIO = true; return coll; }
|
|
681
830
|
const fnAst = argAsts[spec.fn];
|
|
682
831
|
const el = { t: 'lowered', expr: elem };
|
|
683
832
|
if (spec.kind === 'fold') {
|
|
684
833
|
const seed = this.lowerExpr(argAsts[spec.seed], ln);
|
|
685
834
|
const step = this.lowerApp(fnAst, [spec.fn === 0 && (name === 'foldr') ? el : { t: 'lowered', expr: seed }, name === 'foldr' ? { t: 'lowered', expr: seed } : el], ln, name);
|
|
835
|
+
if (spec.monadic) this.sawIO = true;
|
|
686
836
|
return mkUnion(seed, step);
|
|
687
837
|
}
|
|
688
|
-
const res = this.lowerApp(fnAst, [el], ln, name);
|
|
838
|
+
const res = this.lowerApp(fnAst, el2 ? [el, el2] : [el], ln, name);
|
|
839
|
+
// `filter isAlphaNum s` keeps only letters and digits: no quote, separator, dot or markup character survives, so nothing in the result
|
|
840
|
+
// can change the meaning of a command, a query, a path or a page. A predicate that merely drops some characters (`/= ';'`) is not that.
|
|
841
|
+
if (spec.kind === 'filter') {
|
|
842
|
+
const pf = unparen(fnAst);
|
|
843
|
+
if (pf && pf.t === 'var') {
|
|
844
|
+
const pr = this.resolve(pf.name, pf.qual);
|
|
845
|
+
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 } };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
689
848
|
if (spec.kind === 'filter') {
|
|
690
849
|
this.cfg.add('if', ln, { cond: res, hs: { via: 'filter' } });
|
|
691
850
|
return coll;
|
|
@@ -920,6 +1079,14 @@ class FnLowerer {
|
|
|
920
1079
|
processDecls(decls, bodyAsts, ln) {
|
|
921
1080
|
if (!decls.length) return;
|
|
922
1081
|
const g = groupDecls(decls);
|
|
1082
|
+
// `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
|
|
1083
|
+
// for the value it is given, instead of being an unknown call of a local value.
|
|
1084
|
+
g.funs = g.funs.map((f) => {
|
|
1085
|
+
if (f.clauses.length !== 1) return f;
|
|
1086
|
+
const c = f.clauses[0];
|
|
1087
|
+
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;
|
|
1088
|
+
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;
|
|
1089
|
+
});
|
|
923
1090
|
const scope = this.scopes[this.scopes.length - 1];
|
|
924
1091
|
const parent = this.info.parentName || this.info.name;
|
|
925
1092
|
for (const f of g.funs) {
|
|
@@ -940,6 +1107,9 @@ class FnLowerer {
|
|
|
940
1107
|
const rhsAsts = f.clauses.map((c) => c.rhs);
|
|
941
1108
|
const others = siblingAsts.filter((a) => a && !rhsAsts.includes(a));
|
|
942
1109
|
this.bindLazy([f.name], () => this.lowerRhs(f.clauses[0].rhs, f.line || ln), bodyAsts, others, strict, f.line || ln);
|
|
1110
|
+
// bindLazy declares a plain local; a point-free alias keeps what it names so applying it resolves to that function.
|
|
1111
|
+
const alias = aliasBodyOf(f);
|
|
1112
|
+
if (alias && !alias.names.some((n) => this.lookupLocal(n))) scope.set(f.name, { kind: 'local', alias });
|
|
943
1113
|
}
|
|
944
1114
|
for (const pb of g.patbinds) {
|
|
945
1115
|
const names = patternVars(pb.pat);
|
|
@@ -1162,8 +1332,10 @@ function lowerTopFunction(proj, mod, fun, def) {
|
|
|
1162
1332
|
const kind = def.arity > 0 ? 'function' : (sig && sig.returnsIO) || fun.name === 'main' ? 'io-action' : 'value';
|
|
1163
1333
|
const exported = !mod.exports || mod.exports.some((x) => x.kind === 'var' && x.name === fun.name || x.kind === 'module' && x.name === mod.name);
|
|
1164
1334
|
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);
|
|
1335
|
+
const ann = callerControlledParams(fn, sig, exported, mod.imports.map((i) => i.module));
|
|
1336
|
+
for (const a of servantEndpointParams(mod, fn, sig, exported)) if (!ann.some((x) => x.name === a.name)) ann.push(a);
|
|
1166
1337
|
fn.hs.textParams = ann.map((a) => a.name);
|
|
1338
|
+
fn.hs.inputParams = inputParams(fn, sig, exported);
|
|
1167
1339
|
// Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
|
|
1168
1340
|
// parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
|
|
1169
1341
|
const recTypes = proj.recordTypes();
|