@clear-capabilities/agentic-security-scanner 0.153.2 → 0.154.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 +34 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -2
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Controlled proof witnesses for Haskell and Nix findings (X-009).
|
|
2
|
+
//
|
|
3
|
+
// A witness is a small, bounded program that DEMONSTRATES the predicted effect inside the confinement sandbox
|
|
4
|
+
// (src/sandbox: writes confined to a temporary root, no network, wall-clock and resource limits, fail-closed when no
|
|
5
|
+
// primitive is available). The effect is always the same observable: the witness payload creates the marker file
|
|
6
|
+
// `PROVEN` in its working directory. Nothing else counts.
|
|
7
|
+
//
|
|
8
|
+
// Supported witnesses, stated explicitly:
|
|
9
|
+
//
|
|
10
|
+
// nix-shell A shell script a Nix expression generates (systemd script, build phase). The generated script text is
|
|
11
|
+
// taken from the NIX-003 analysis, the interpolation the finding concerns is replaced by an attacker
|
|
12
|
+
// payload shaped for its shell context and passed through the SAME escaping the Nix code applies
|
|
13
|
+
// (lib.escapeShellArg), and the script is run for real by the host shell. The payload is only shell
|
|
14
|
+
// text: it runs only inside the sandbox, and only `touch PROVEN` is ever asked of it.
|
|
15
|
+
// haskell Needs a Haskell toolchain (runghc). Without one the result is `unavailable`, which is neither proof
|
|
16
|
+
// nor refutation. This module never installs or downloads a toolchain.
|
|
17
|
+
//
|
|
18
|
+
// What is NOT proof, ever: a syntax error in the witness, a missing executable, a sandbox that could not start or was
|
|
19
|
+
// refused, a timeout or resource limit, or an output cap. Each is reported as `invalid` with its reason and leaves
|
|
20
|
+
// the finding's static proof tier unchanged. Only the marker proves; its absence after a clean run is
|
|
21
|
+
// `not-reproduced`, which is evidence about THIS witness, not a proof that the code is safe.
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { runConfined, sandboxAvailable } from '../sandbox/index.js';
|
|
28
|
+
import { analyzeNixScripts, SHELL_PLACEHOLDER } from './nix-script-taint.js';
|
|
29
|
+
|
|
30
|
+
export const WITNESS_VERSION = 'language-witness/1';
|
|
31
|
+
export const PROOF_MARKER = 'PROVEN';
|
|
32
|
+
export const WITNESS_STATUS = Object.freeze(['reproduced', 'not-reproduced', 'invalid', 'unavailable', 'unsupported']);
|
|
33
|
+
const SHELLS = new Set(['bash', 'sh', 'dash', 'ash', 'ksh', 'mksh']);
|
|
34
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
35
|
+
const MAX_SCRIPT_BYTES = 256 * 1024;
|
|
36
|
+
|
|
37
|
+
const shEscape = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
38
|
+
|
|
39
|
+
/** The text one placeholder takes in the generated script, given the Nix-side protection and the shell context. */
|
|
40
|
+
export function witnessPayload(context, protection) {
|
|
41
|
+
const cmd = `touch ${PROOF_MARKER}`;
|
|
42
|
+
let raw;
|
|
43
|
+
if (context === 'single') raw = `'; ${cmd}; echo '`;
|
|
44
|
+
else if (context === 'double') raw = `$(${cmd})`;
|
|
45
|
+
else raw = `$(${cmd})`;
|
|
46
|
+
// escapeShellArg(s) wraps in single quotes at EVALUATION time, before the shell ever sees the text. The shell then
|
|
47
|
+
// reads that result inside whatever quote context the script put it in, which is exactly the wrong-context case.
|
|
48
|
+
if (protection === 'escapeShellArg') return shEscape(raw);
|
|
49
|
+
if (protection === 'escapeShellArgs') return shEscape(raw);
|
|
50
|
+
return raw;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const placeholderIndexes = (text) => { const out = []; for (let i = 0; i < text.length; i++) if (text[i] === SHELL_PLACEHOLDER) out.push(i); return out; };
|
|
54
|
+
const indexOfPosition = (text, line, col) => { let l = 1; let i = 0; while (i < text.length && l < line) { if (text[i] === '\n') l++; i++; } return i + col; };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Builds a witness for one flow of one generated script.
|
|
58
|
+
* @param {{script:{generated:string, shell:string|null, attrPath:string, file:string}, flow:{generatedLocation:{line:number,startColumn:number}, context:string, protection:string|null}}} t
|
|
59
|
+
*/
|
|
60
|
+
export function buildNixScriptWitness({ script, flow }) {
|
|
61
|
+
if (!script || typeof script.generated !== 'string' || !flow || !flow.generatedLocation) return { status: 'unsupported', reason: 'the flow has no generated-script location' };
|
|
62
|
+
const shell = script.shell || 'bash';
|
|
63
|
+
if (!SHELLS.has(shell)) return { status: 'unsupported', reason: `shell "${shell}" is not supported by the witness runner` };
|
|
64
|
+
if (Buffer.byteLength(script.generated) > MAX_SCRIPT_BYTES) return { status: 'unsupported', reason: 'the generated script exceeds the witness size limit' };
|
|
65
|
+
const all = placeholderIndexes(script.generated);
|
|
66
|
+
const at = indexOfPosition(script.generated, flow.generatedLocation.line, flow.generatedLocation.startColumn);
|
|
67
|
+
const ordinal = all.indexOf(at);
|
|
68
|
+
if (ordinal < 0) return { status: 'unsupported', reason: 'the flow location does not match an interpolation in the generated script' };
|
|
69
|
+
const payload = witnessPayload(flow.context, flow.protection);
|
|
70
|
+
let out = ''; let n = 0;
|
|
71
|
+
for (const ch of script.generated) { if (ch === SHELL_PLACEHOLDER) { out += n === ordinal ? payload : 'benign'; n++; } else out += ch; }
|
|
72
|
+
const body = out.replace(/^#!.*\n/, '');
|
|
73
|
+
return { status: 'ready', lang: 'nix-shell', shell, ordinal, attrPath: script.attrPath, file: script.file, payload, code: `${body}\n`, context: flow.context, protection: flow.protection || null };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function which(name) {
|
|
77
|
+
for (const d of String(process.env.PATH || '').split(path.delimiter)) { const p = path.join(d, name); try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ } }
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Runs a witness built by buildNixScriptWitness inside the confinement sandbox. Never throws. */
|
|
82
|
+
export function runNixScriptWitness(w, { timeoutMs = DEFAULT_TIMEOUT_MS, force } = {}) {
|
|
83
|
+
const base = { witness: WITNESS_VERSION, lang: 'nix-shell', ran: false, backend: null, marker: PROOF_MARKER };
|
|
84
|
+
if (!w || w.status !== 'ready') return { ...base, status: w && w.status === 'unsupported' ? 'unsupported' : 'invalid', reason: (w && w.reason) || 'no witness' };
|
|
85
|
+
const shellBin = which(w.shell);
|
|
86
|
+
if (!shellBin) return { ...base, status: 'invalid', reason: `executable not found: ${w.shell}` };
|
|
87
|
+
if (!sandboxAvailable()) return { ...base, status: 'invalid', reason: 'no confinement primitive available; refusing to execute' };
|
|
88
|
+
let root;
|
|
89
|
+
try {
|
|
90
|
+
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'witness-')));
|
|
91
|
+
fs.writeFileSync(path.join(root, 'witness.sh'), w.code, 'utf8');
|
|
92
|
+
// 1. a witness that does not parse proves nothing in either direction
|
|
93
|
+
const syn = runConfined([shellBin, '-n', 'witness.sh'], { root, timeoutMs, force });
|
|
94
|
+
if (syn.status === 'disabled' || syn.status === 'error') return { ...base, backend: syn.backend, status: 'invalid', reason: `the sandbox could not run the syntax check (${syn.backend}): ${String(syn.stderr || '').trim() || 'no detail'}` };
|
|
95
|
+
if (syn.timedOut) return { ...base, backend: syn.backend, status: 'invalid', reason: 'the syntax check exceeded its time budget' };
|
|
96
|
+
if (syn.exitCode !== 0) return { ...base, backend: syn.backend, status: 'invalid', reason: `syntax error in the generated witness: ${String(syn.stderr || '').trim().split('\n')[0] || 'shell rejected it'}` };
|
|
97
|
+
// 2. the real run
|
|
98
|
+
const r = runConfined([shellBin, 'witness.sh'], { root, timeoutMs, force });
|
|
99
|
+
const ran = !r.timedOut && r.status !== 'disabled' && r.status !== 'error';
|
|
100
|
+
if (!ran) return { ...base, backend: r.backend, status: 'invalid', reason: r.timedOut ? 'the witness exceeded its time budget' : `the sandbox could not execute it (${r.backend}): ${String(r.stderr || '').trim() || r.status}`, timedOut: !!r.timedOut };
|
|
101
|
+
const proven = fs.existsSync(path.join(root, PROOF_MARKER));
|
|
102
|
+
return { ...base, ran: true, backend: r.backend, status: proven ? 'reproduced' : 'not-reproduced', exitCode: r.exitCode,
|
|
103
|
+
observed: proven ? `the payload created '${PROOF_MARKER}' by running inside the generated script` : null,
|
|
104
|
+
reason: proven ? null : 'the script ran and the marker was not created: this witness did not reproduce the effect' };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { ...base, status: 'invalid', reason: `witness harness error: ${String((e && e.message) || e)}` };
|
|
107
|
+
} finally { if (root) fs.rmSync(root, { recursive: true, force: true }); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Finds the flow for (attrPath, ordinal) in a fresh analysis of `files`, and builds+runs its witness. */
|
|
111
|
+
export function witnessNixFlow(files, target, opts = {}) {
|
|
112
|
+
const a = analyzeNixScripts({ files });
|
|
113
|
+
const script = a.scripts.find((s) => s.attrPath === target.attrPath && (!target.file || s.file === target.file));
|
|
114
|
+
if (!script) return { status: 'unsupported', ran: false, reason: `no generated script ${target.attrPath}` };
|
|
115
|
+
const idx = placeholderIndexes(script.generated);
|
|
116
|
+
const flow = a.flows.find((f) => f.attrPath === script.attrPath && f.generatedLocation && idx.indexOf(indexOfPosition(script.generated, f.generatedLocation.line, f.generatedLocation.startColumn)) === target.ordinal);
|
|
117
|
+
if (!flow) return { status: 'unsupported', ran: false, reason: `no interpolation #${target.ordinal} in ${target.attrPath}` };
|
|
118
|
+
return runNixScriptWitness(buildNixScriptWitness({ script, flow }), opts);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The (attrPath, ordinal) of the flow a finding describes, taken from a fresh analysis. */
|
|
122
|
+
export function nixFlowTarget(files, finding) {
|
|
123
|
+
const a = analyzeNixScripts({ files });
|
|
124
|
+
const flow = a.flows.find((f) => f.file === finding.file && f.line === finding.line && f.attrPath && f.generatedLocation)
|
|
125
|
+
|| a.flows.find((f) => f.file === finding.file && f.attrPath === finding.attrPath && f.generatedLocation);
|
|
126
|
+
if (!flow) return null;
|
|
127
|
+
const script = a.scripts.find((s) => s.attrPath === flow.attrPath && s.file === (flow.generatedLocation.file || finding.file));
|
|
128
|
+
if (!script) return null;
|
|
129
|
+
const ordinal = placeholderIndexes(script.generated).indexOf(indexOfPosition(script.generated, flow.generatedLocation.line, flow.generatedLocation.startColumn));
|
|
130
|
+
return ordinal < 0 ? null : { attrPath: script.attrPath, file: script.file, ordinal };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Before/after judgement for a fix. A fix is witness-verified only when the ORIGINAL reproduced and the PATCHED code
|
|
135
|
+
* ran cleanly without reproducing. Anything invalid on either side verifies nothing.
|
|
136
|
+
*/
|
|
137
|
+
export function judgeFixWitness(before, after) {
|
|
138
|
+
if (!before || before.status !== 'reproduced') return { ok: false, verified: false, reason: `the original code did not reproduce (${(before && before.status) || 'no witness'}${before && before.reason ? `: ${before.reason}` : ''}), so the fix cannot be witness-verified` };
|
|
139
|
+
if (!after || after.status === 'invalid' || after.status === 'unavailable' || after.status === 'unsupported') return { ok: false, verified: false, reason: `the patched witness could not be judged (${(after && after.status) || 'no witness'}${after && after.reason ? `: ${after.reason}` : ''})` };
|
|
140
|
+
if (after.status === 'reproduced') return { ok: false, verified: false, stillReproduces: true, reason: 'the patched code still reproduces the effect' };
|
|
141
|
+
return { ok: true, verified: true, reason: null, note: 'the witness reproduced before the patch and not after; this is evidence about this witness, not a proof of safety' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Haskell: optional, toolchain-gated ───────────────────────────────────────
|
|
145
|
+
/** Whether a Haskell runner exists. Never installs anything. */
|
|
146
|
+
export function haskellRunner() {
|
|
147
|
+
const p = which('runghc') || which('runhaskell');
|
|
148
|
+
if (!p) return { available: false, reason: 'no Haskell toolchain (runghc) on PATH; witnesses for Haskell findings are unavailable here' };
|
|
149
|
+
const v = spawnSync(p, ['--version'], { encoding: 'utf8', timeout: 10_000 });
|
|
150
|
+
return v.status === 0 ? { available: true, path: p, version: String(v.stdout || v.stderr).trim() } : { available: false, reason: 'the Haskell runner did not start' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Runs a Haskell program text as a witness. `main` must create PROVEN in its working directory when the effect is
|
|
155
|
+
* reproduced. Without a toolchain the answer is `unavailable`: not proof, not refutation.
|
|
156
|
+
*/
|
|
157
|
+
export function runHaskellWitness(source, { timeoutMs = 60_000, force } = {}) {
|
|
158
|
+
const base = { witness: WITNESS_VERSION, lang: 'haskell', ran: false, backend: null, marker: PROOF_MARKER };
|
|
159
|
+
const hr = haskellRunner();
|
|
160
|
+
if (!hr.available) return { ...base, status: 'unavailable', reason: hr.reason };
|
|
161
|
+
if (!sandboxAvailable()) return { ...base, status: 'invalid', reason: 'no confinement primitive available; refusing to execute' };
|
|
162
|
+
let root;
|
|
163
|
+
try {
|
|
164
|
+
root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'witness-hs-')));
|
|
165
|
+
fs.writeFileSync(path.join(root, 'Witness.hs'), source, 'utf8');
|
|
166
|
+
const r = runConfined([hr.path, 'Witness.hs'], { root, timeoutMs, force });
|
|
167
|
+
if (r.status === 'disabled' || r.status === 'error') return { ...base, backend: r.backend, status: 'invalid', reason: `the sandbox could not execute it: ${String(r.stderr || '').trim() || r.status}` };
|
|
168
|
+
if (r.timedOut) return { ...base, backend: r.backend, status: 'invalid', reason: 'the witness exceeded its time budget', timedOut: true };
|
|
169
|
+
if (/error|parse error|Could not find module/i.test(String(r.stderr || '')) && r.exitCode !== 0) return { ...base, backend: r.backend, status: 'invalid', reason: `the program did not compile or run: ${String(r.stderr).trim().split('\n')[0]}` };
|
|
170
|
+
const proven = fs.existsSync(path.join(root, PROOF_MARKER));
|
|
171
|
+
return { ...base, ran: true, backend: r.backend, status: proven ? 'reproduced' : 'not-reproduced', exitCode: r.exitCode, reason: proven ? null : 'the program ran and the marker was not created' };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return { ...base, status: 'invalid', reason: `witness harness error: ${String((e && e.message) || e)}` };
|
|
174
|
+
} finally { if (root) fs.rmSync(root, { recursive: true, force: true }); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A `witness` hook for runFixLifecycle over a Nix generated-script finding: runs the original and the patched tree. */
|
|
178
|
+
export function nixFixWitness(finding, opts = {}) {
|
|
179
|
+
return (plan, files, patched) => {
|
|
180
|
+
const nixOnly = (t) => Object.fromEntries(Object.entries(t).filter(([p, x]) => /\.nix$/i.test(p) && typeof x === 'string'));
|
|
181
|
+
const target = nixFlowTarget(nixOnly(files), finding);
|
|
182
|
+
if (!target) return { ok: false, verified: false, reason: 'the finding is not a generated-script interpolation, so no witness applies' };
|
|
183
|
+
return judgeFixWitness(witnessNixFlow(nixOnly(files), target, opts), witnessNixFlow(nixOnly(patched), target, opts));
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/lineage/CLAUDE.md
CHANGED
|
@@ -1201,3 +1201,11 @@ way `recipient-profiles.json`/`privacy-policy.json` already are).
|
|
|
1201
1201
|
allowed to live. No other module in this package — and per PRD Appendix
|
|
1202
1202
|
D.1, no UI code in a later milestone — may special-case a fixture name.
|
|
1203
1203
|
The generic hook is `graph.scope.source === 'fixture'`.
|
|
1204
|
+
|
|
1205
|
+
## Haskell and Nix views
|
|
1206
|
+
|
|
1207
|
+
`haskell-view.js` rewrites a clone of each Haskell function so a monadic source read becomes a seedable member read on the
|
|
1208
|
+
synthetic root `$hs`, seeds record-parameter sources, and treats `length`/`null` as non-content. `nix-view.js` builds
|
|
1209
|
+
field-to-store flows from `language/nix-privacy.js` (a personal-data `config.<module>.<field>` interpolated into another option;
|
|
1210
|
+
hashed, measured or compared references are protected and make no flow). Coverage reports `unknown` for both languages: no
|
|
1211
|
+
taint-recall tier is borrowed. See `scanner/src/language/CLAUDE.md`.
|
package/src/lineage/coverage.js
CHANGED
|
@@ -328,6 +328,8 @@ const LANGUAGE_EXT_PATTERNS = Object.freeze([
|
|
|
328
328
|
[/\.go$/i, 'go'],
|
|
329
329
|
[/\.(?:php|phtml)$/i, 'php'],
|
|
330
330
|
[/\.rb$/i, 'ruby'],
|
|
331
|
+
[/\.l?hs(?:c|ig)?$|\.hs-boot$/i, 'haskell'],
|
|
332
|
+
[/\.nix$/i, 'nix'],
|
|
331
333
|
[/\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/i, 'cpp'],
|
|
332
334
|
// Milestone 5, language coverage-tier disclosure: these 4 have ZERO
|
|
333
335
|
// lineage/taint wiring (tree-sitter-pattern-only, sast/tree-sitter-sinks.js
|
|
@@ -99,6 +99,28 @@ export function validateCrossRepoLink(record) {
|
|
|
99
99
|
_validateEndpoint(record.local, 'local', err);
|
|
100
100
|
_validateEndpoint(record.remote, 'remote', err, ['repository', 'sourceFile']);
|
|
101
101
|
|
|
102
|
+
// Optional scope and evidence. Haskell and Nix endpoints carry an analysis scope (static configuration is not runtime
|
|
103
|
+
// exposure) and limitations; a link records them rather than flattening them away. `evidence` states that a link is a
|
|
104
|
+
// DECLARATION: it is an operator assertion, never an observed data flow, and a record claiming otherwise is invalid.
|
|
105
|
+
if (record.scope !== undefined && record.scope !== null) {
|
|
106
|
+
if (!_isPlainObject(record.scope)) err('$.scope', 'scope must be an object');
|
|
107
|
+
else {
|
|
108
|
+
for (const side of ['local', 'remote']) {
|
|
109
|
+
const sc = record.scope[side];
|
|
110
|
+
if (sc === undefined || sc === null) continue;
|
|
111
|
+
if (!_isPlainObject(sc)) { err(`$.scope.${side}`, `scope.${side} must be an object`); continue; }
|
|
112
|
+
if (!_isStringOrNull(sc.target)) err(`$.scope.${side}.target`, 'target must be a string or null');
|
|
113
|
+
if (!_isStringOrNull(sc.config)) err(`$.scope.${side}.config`, 'config must be a string or null');
|
|
114
|
+
if (sc.analysis !== undefined && sc.analysis !== null && !_isPlainObject(sc.analysis)) err(`$.scope.${side}.analysis`, 'analysis must be an object or null');
|
|
115
|
+
if (sc.limitations !== undefined && !(Array.isArray(sc.limitations) && sc.limitations.every((x) => typeof x === 'string'))) err(`$.scope.${side}.limitations`, 'limitations must be an array of strings');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (record.evidence !== undefined && record.evidence !== null) {
|
|
120
|
+
if (!_isPlainObject(record.evidence) || record.evidence.declared !== true || record.evidence.observed !== false) {
|
|
121
|
+
err('$.evidence', 'evidence must be {declared: true, observed: false}: a declared link is never an observed data flow');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
102
124
|
if (!_isStringOrNull(record.rationale)) err('$.rationale', 'rationale must be a string or null');
|
|
103
125
|
if (!_isNonEmptyString(record.declaredBy)) err('$.declaredBy', 'declaredBy is required');
|
|
104
126
|
if (!_isNonEmptyString(record.declaredAt)) err('$.declaredAt', 'declaredAt is required');
|
|
@@ -171,7 +171,11 @@ function resolveSinkAtCallSite(calleeExpr, file) {
|
|
|
171
171
|
const pool = justified.length > 1 ? justified : cands;
|
|
172
172
|
const cats = [...new Set(pool.map((c) => c.decision.category))];
|
|
173
173
|
if (cats.length === 1) {
|
|
174
|
-
|
|
174
|
+
// Haskell: a security sink watches the QUERY/URL argument, a privacy sink watches the DATA argument. When both
|
|
175
|
+
// agree on the destination, the data-bearing (privacy) entry is the one a field-level flow can reach.
|
|
176
|
+
const dataBearing = /\.l?hs$/i.test(String(file)) ? pool.find((c) => c.entry.vuln && c.entry.vuln.cwe === 'CWE-359') : null;
|
|
177
|
+
const pick = dataBearing || pool[0];
|
|
178
|
+
return { ...pick, ambiguity: { resolvedBy: 'unanimous-category', alternatives: pool.filter((c) => c !== pick).map((c) => c.entry.id).sort() } };
|
|
175
179
|
}
|
|
176
180
|
const counts = new Map();
|
|
177
181
|
for (const c of pool) counts.set(c.decision.category, (counts.get(c.decision.category) ?? 0) + 1);
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// A lineage VIEW of the Haskell call graph (X-004).
|
|
2
|
+
//
|
|
3
|
+
// The field-identity engine behind the Data Flow Explorer seeds identities at a function's ENTRY from
|
|
4
|
+
// matched member reads (`req.body.email`), and enumerates privacy sinks only at call-STATEMENT sites. The
|
|
5
|
+
// Haskell IR is JS-shaped but differs in exactly the ways that matter to that machinery:
|
|
6
|
+
//
|
|
7
|
+
// * a source is a monadic call bound to a variable (`s <- jsonData`), and its fields are read later
|
|
8
|
+
// (`email s`), so there is no member read to seed;
|
|
9
|
+
// * a sink is wrapped (`liftIO (execute conn q row)`) or bound to a discarded variable, so it is never a
|
|
10
|
+
// statement-level call;
|
|
11
|
+
// * a request body setter was lowered to an assignment (to keep the URL clean of body taint).
|
|
12
|
+
//
|
|
13
|
+
// This module rewrites a CLONE of each Haskell function so the same machinery sees what the code means,
|
|
14
|
+
// and nothing else: sources become member reads on a synthetic root (`$hs.<Module.name>[.<field>]`) with
|
|
15
|
+
// one property per FIELD the program actually reads, lifts are unwrapped, and body-setter assignments become
|
|
16
|
+
// call statements. The original call graph (shared with the taint engine) is never mutated.
|
|
17
|
+
//
|
|
18
|
+
// What it does not do is also part of the contract: a field the program never reads on a source value is not
|
|
19
|
+
// invented, and a source whose fields cannot be enumerated (it is only passed whole to code outside the
|
|
20
|
+
// project) is seeded at container level and disclosed as such, never as a set of made-up fields.
|
|
21
|
+
|
|
22
|
+
import { matchSource } from '../dataflow/catalog.js';
|
|
23
|
+
|
|
24
|
+
export const HS_LINEAGE_ROOT = '$hs';
|
|
25
|
+
// the label of a record parameter handed to an exported function (see language/haskell-ir.js `recordParams`)
|
|
26
|
+
export const HS_PARAM_SOURCE = 'hs:record-parameter';
|
|
27
|
+
const isHsFile = (f) => typeof f === 'string' && /\.l?hs$/i.test(f);
|
|
28
|
+
const LIFT = /(?:^|\.)(?:liftIO|lift)$/;
|
|
29
|
+
const MAX_DEPTH = 3;
|
|
30
|
+
|
|
31
|
+
const clone = (o) => { try { return structuredClone(o); } catch { return JSON.parse(JSON.stringify(o)); } };
|
|
32
|
+
|
|
33
|
+
function walk(e, visit, depth = 0) {
|
|
34
|
+
if (!e || typeof e !== 'object' || depth > 60) return;
|
|
35
|
+
visit(e);
|
|
36
|
+
switch (e.kind) {
|
|
37
|
+
case 'member': walk(e.object, visit, depth + 1); break;
|
|
38
|
+
case 'call': walk(e.callee, visit, depth + 1); for (const a of e.args || []) walk(a, visit, depth + 1); break;
|
|
39
|
+
case 'binary': case 'logical': walk(e.left, visit, depth + 1); walk(e.right, visit, depth + 1); break;
|
|
40
|
+
case 'tpl': for (const p of e.parts || []) walk(p, visit, depth + 1); break;
|
|
41
|
+
case 'union': for (const p of e.options || []) walk(p, visit, depth + 1); break;
|
|
42
|
+
case 'array': for (const p of e.elements || []) walk(p, visit, depth + 1); break;
|
|
43
|
+
case 'object': for (const p of e.props || []) walk(p && p.value, visit, depth + 1); break;
|
|
44
|
+
default: break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const nodesOf = (fn) => Object.values(fn.cfg.nodes);
|
|
48
|
+
const roots = (n) => { const r = []; if (n.kind === 'assign' && n.source) r.push(n.source); if (n.kind === 'call') { if (n.callee && typeof n.callee === 'object') r.push(n.callee); for (const a of n.args || []) r.push(a); } if (n.kind === 'return' && n.value) r.push(n.value); return r; };
|
|
49
|
+
|
|
50
|
+
/** unwrap `liftIO (x)` / `lift (x)` wrappers around a call expression (transparent for data flow). */
|
|
51
|
+
function unwrapLift(e) {
|
|
52
|
+
let cur = e;
|
|
53
|
+
while (cur && cur.kind === 'call' && typeof cur.callee === 'string' && LIFT.test(cur.callee) && (cur.args || []).length === 1 && cur.args[0] && cur.args[0].kind === 'call') cur = cur.args[0];
|
|
54
|
+
return cur;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** property names read off variable `name` across a function body. */
|
|
58
|
+
function fieldsRead(fn, name) {
|
|
59
|
+
const props = new Set();
|
|
60
|
+
let whole = false;
|
|
61
|
+
for (const n of nodesOf(fn)) for (const r of roots(n)) {
|
|
62
|
+
const parents = new Map();
|
|
63
|
+
walk(r, (e) => {
|
|
64
|
+
if (e.kind === 'member' && e.object && e.object.kind === 'ident' && e.object.name === name && typeof e.prop === 'string') props.add(e.prop);
|
|
65
|
+
for (const k of ['object', 'left', 'right']) if (e[k] && typeof e[k] === 'object') parents.set(e[k], e);
|
|
66
|
+
for (const k of ['args', 'elements', 'parts', 'options']) if (Array.isArray(e[k])) for (const c of e[k]) if (c) parents.set(c, e);
|
|
67
|
+
if (Array.isArray(e.props)) for (const p of e.props) if (p && p.value) parents.set(p.value, e);
|
|
68
|
+
});
|
|
69
|
+
walk(r, (e) => { if (e.kind === 'ident' && e.name === name) { const p = parents.get(e); if (!(p && p.kind === 'member' && p.object === e)) whole = true; } });
|
|
70
|
+
}
|
|
71
|
+
return { props, whole };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** fields read through a resolved callee's parameter when the variable is passed whole. */
|
|
75
|
+
function fieldsViaCallees(fn, name, fnByQid, depth, seen) {
|
|
76
|
+
const out = new Set();
|
|
77
|
+
if (depth > MAX_DEPTH) return out;
|
|
78
|
+
for (const n of nodesOf(fn)) for (const r of roots(n)) walk(r, (e) => {
|
|
79
|
+
if (e.kind !== 'call' || !e.hs || !e.hs.target) return;
|
|
80
|
+
(e.args || []).forEach((a, i) => {
|
|
81
|
+
if (!(a && a.kind === 'ident' && a.name === name)) return;
|
|
82
|
+
const callee = fnByQid.get(e.hs.target);
|
|
83
|
+
if (!callee || seen.has(`${callee.qid}#${i}`)) return;
|
|
84
|
+
seen.add(`${callee.qid}#${i}`);
|
|
85
|
+
const p = (callee.params || [])[i];
|
|
86
|
+
const pname = typeof p === 'string' ? p : (p && p.name);
|
|
87
|
+
if (!pname) return;
|
|
88
|
+
for (const f of fieldsRead(callee, pname).props) out.add(f);
|
|
89
|
+
for (const f of fieldsViaCallees(callee, pname, fnByQid, depth + 1, seen)) out.add(f);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const memberOf = (obj, prop) => ({ kind: 'member', object: obj, prop });
|
|
96
|
+
const rootMember = (label) => memberOf({ kind: 'ident', name: HS_LINEAGE_ROOT }, label);
|
|
97
|
+
|
|
98
|
+
export function sourceLabelOf(call) { return typeof call.callee === 'string' ? call.callee : null; }
|
|
99
|
+
|
|
100
|
+
function transformFunction(fn, fnByQid) {
|
|
101
|
+
const stats = { sources: 0, containerOnly: 0, unwrapped: 0, bodySetters: 0 };
|
|
102
|
+
for (const [id, n] of Object.entries(fn.cfg.nodes)) {
|
|
103
|
+
// lifts and body setters at statement level
|
|
104
|
+
if (n.kind === 'call' && typeof n.callee === 'string' && LIFT.test(n.callee) && (n.args || []).length === 1 && n.args[0] && n.args[0].kind === 'call') {
|
|
105
|
+
const inner = unwrapLift(n);
|
|
106
|
+
n.callee = inner.callee; n.args = inner.args; n.hs = inner.hs || n.hs; stats.unwrapped++;
|
|
107
|
+
}
|
|
108
|
+
if (n.kind === 'assign' && n.source && n.source.kind === 'call') {
|
|
109
|
+
const inner = unwrapLift(n.source);
|
|
110
|
+
if (inner !== n.source) { n.source = inner; stats.unwrapped++; }
|
|
111
|
+
if (n.hs && n.hs.bodySetter && n.source.kind === 'call') {
|
|
112
|
+
// `$bodyN = setRequestBodyJSON body req` -> a call statement, so the sink is enumerable
|
|
113
|
+
n.kind = 'call'; n.callee = n.source.callee; n.args = n.source.args; n.line = n.line ?? n.source.line; delete n.source; delete n.target; stats.bodySetters++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// a discarded or unused bind of a call: `_ <- execute ...` is a statement-level call
|
|
117
|
+
if ((n.target === '_' || /^_\w*$/.test(n.target || '')) && !matchSource(n.source, fn.file)) { n.kind = 'call'; n.callee = n.source.callee; n.args = n.source.args; delete n.source; delete n.target; continue; }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// The last expression of a function body is a `return` of a call (`handle acct = putStrLn (email acct)`). A sink is only
|
|
121
|
+
// enumerated at a call STATEMENT, so the call is also placed before the return as a statement of its own; the return stays
|
|
122
|
+
// (a caller still needs what the function returns).
|
|
123
|
+
for (const [id, n] of Object.entries({ ...fn.cfg.nodes })) {
|
|
124
|
+
if (n.kind !== 'return' || !n.value) continue;
|
|
125
|
+
const v = unwrapLift(n.value);
|
|
126
|
+
if (!v || v.kind !== 'call' || typeof v.callee !== 'string') continue;
|
|
127
|
+
const sid = `${id}_stmt`;
|
|
128
|
+
const stmt = { kind: 'call', line: v.line ?? n.line, callee: v.callee, args: v.args || [], hs: v.hs, succ: [id], pred: [...(n.pred || [])] };
|
|
129
|
+
for (const pid of n.pred || []) {
|
|
130
|
+
const pn = fn.cfg.nodes[pid];
|
|
131
|
+
if (!pn) continue;
|
|
132
|
+
pn.succ = (pn.succ || []).map((x) => (x === id ? sid : x));
|
|
133
|
+
if (pn.thenEntry === id) pn.thenEntry = sid;
|
|
134
|
+
if (pn.elseEntry === id) pn.elseEntry = sid;
|
|
135
|
+
}
|
|
136
|
+
n.pred = [sid];
|
|
137
|
+
fn.cfg.nodes[sid] = stmt;
|
|
138
|
+
stats.returnSinks = (stats.returnSinks || 0) + 1;
|
|
139
|
+
}
|
|
140
|
+
// sources
|
|
141
|
+
for (const [id, n] of Object.entries(fn.cfg.nodes)) {
|
|
142
|
+
if (n.kind !== 'assign' || !n.source || n.source.kind !== 'call' || typeof n.target !== 'string' || n.target.includes('.')) continue;
|
|
143
|
+
const entry = matchSource(n.source, fn.file);
|
|
144
|
+
if (!entry || !String(entry.id).startsWith('hs-src-')) continue;
|
|
145
|
+
const label = sourceLabelOf(n.source);
|
|
146
|
+
if (!label) continue;
|
|
147
|
+
const lit = (n.source.args || []).find((a) => a && a.kind === 'literal' && typeof a.value === 'string' && /^[A-Za-z_][\w.-]*$/.test(a.value));
|
|
148
|
+
const target = n.target;
|
|
149
|
+
if (lit) {
|
|
150
|
+
// `param "email"`: the argument names the field
|
|
151
|
+
n.source = memberOf(rootMember(label), lit.value);
|
|
152
|
+
n.hs = { ...(n.hs || {}), lineageSource: { label, fields: [lit.value], form: 'named-parameter' } };
|
|
153
|
+
stats.sources++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const { props } = fieldsRead(fn, target);
|
|
157
|
+
for (const f of fieldsViaCallees(fn, target, fnByQid, 1, new Set())) props.add(f);
|
|
158
|
+
if (props.size) {
|
|
159
|
+
n.source = { kind: 'object', props: [...props].sort().map((p) => ({ key: p, value: memberOf(rootMember(label), p) })) };
|
|
160
|
+
n.hs = { ...(n.hs || {}), lineageSource: { label, fields: [...props].sort(), form: 'record-fields' } };
|
|
161
|
+
} else {
|
|
162
|
+
n.source = rootMember(label);
|
|
163
|
+
n.hs = { ...(n.hs || {}), lineageSource: { label, fields: [], form: 'container' } };
|
|
164
|
+
stats.containerOnly++;
|
|
165
|
+
}
|
|
166
|
+
stats.sources++;
|
|
167
|
+
}
|
|
168
|
+
// record-typed parameters of an entry point: a source of the record's fields, one per field the body (and its callees) reads
|
|
169
|
+
for (const rp of (fn.hs && fn.hs.recordParams) || []) {
|
|
170
|
+
const { props } = fieldsRead(fn, rp.name);
|
|
171
|
+
for (const f of fieldsViaCallees(fn, rp.name, fnByQid, 1, new Set())) props.add(f);
|
|
172
|
+
const fields = [...props].filter((f) => (rp.fields || []).includes(f)).sort();
|
|
173
|
+
if (!fields.length) continue;
|
|
174
|
+
const entry = Object.entries(fn.cfg.nodes).find(([, c]) => c.kind === 'entry');
|
|
175
|
+
if (!entry) continue;
|
|
176
|
+
const id = `n_param_${rp.name}`;
|
|
177
|
+
const next = [...(entry[1].succ || [])];
|
|
178
|
+
const node = { kind: 'assign', line: fn.line, target: rp.name, source: { kind: 'object', props: fields.map((p) => ({ key: p, value: memberOf(rootMember(HS_PARAM_SOURCE), p) })) }, succ: next, pred: [entry[0]], hs: { lineageSource: { label: HS_PARAM_SOURCE, fields, form: 'record-parameter', type: rp.type } } };
|
|
179
|
+
fn.cfg.nodes[id] = node;
|
|
180
|
+
for (const nid of next) { const nn = fn.cfg.nodes[nid]; if (nn) nn.pred = [...new Set([...(nn.pred || []).filter((x) => x !== entry[0]), id])]; }
|
|
181
|
+
entry[1].succ = [id];
|
|
182
|
+
stats.sources++; stats.paramSources = (stats.paramSources || 0) + 1;
|
|
183
|
+
}
|
|
184
|
+
cutNonContent(fn, stats);
|
|
185
|
+
fn.hs = { ...(fn.hs || {}), lineageView: stats };
|
|
186
|
+
return fn;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A size or emptiness test carries none of the value's content: `length`/`null` of a field is a number or a boolean. In the lineage
|
|
190
|
+
// clone the call is replaced by a literal, so the field identity ends there; the original call graph is untouched, and the
|
|
191
|
+
// replacement is counted on the function (`lineageView.nonContentCuts`).
|
|
192
|
+
const NON_CONTENT = /^(?:(?:Prelude|Data\.List|Data\.Foldable|Data\.Text|Data\.Text\.Lazy|Data\.ByteString|Data\.ByteString\.Char8|Data\.ByteString\.Lazy|Data\.ByteString\.Lazy\.Char8|Data\.Map|Data\.Set)\.)?(?:length|null)$|^(?:length|null)$/;
|
|
193
|
+
function cutNonContent(fn, stats) {
|
|
194
|
+
const rewrite = (e, depth = 0) => {
|
|
195
|
+
if (!e || typeof e !== 'object' || depth > 60) return e;
|
|
196
|
+
if (Array.isArray(e)) return e.map((x) => rewrite(x, depth + 1));
|
|
197
|
+
if (e.kind === 'call' && typeof e.callee === 'string' && NON_CONTENT.test(e.callee)) {
|
|
198
|
+
stats.nonContentCuts = (stats.nonContentCuts || 0) + 1;
|
|
199
|
+
return { kind: 'literal', value: '<size>', hs: { derived: 'non-content', callee: e.callee } };
|
|
200
|
+
}
|
|
201
|
+
const out = { ...e };
|
|
202
|
+
for (const k of ['object', 'callee', 'left', 'right', 'value', 'source']) if (e[k] && typeof e[k] === 'object') out[k] = rewrite(e[k], depth + 1);
|
|
203
|
+
for (const k of ['args', 'elements', 'parts', 'options', 'branches']) if (Array.isArray(e[k])) out[k] = e[k].map((x) => rewrite(x, depth + 1));
|
|
204
|
+
if (Array.isArray(e.props)) out.props = e.props.map((p) => (p && p.value ? { ...p, value: rewrite(p.value, depth + 1) } : p));
|
|
205
|
+
if (e.hs && Array.isArray(e.hs.branchConds)) out.hs = { ...e.hs, branchConds: e.hs.branchConds.map((b) => (b && b.cond ? { ...b, cond: rewrite(b.cond, depth + 1) } : b)) };
|
|
206
|
+
return out;
|
|
207
|
+
};
|
|
208
|
+
for (const n of Object.values(fn.cfg.nodes)) {
|
|
209
|
+
if (n.source) n.source = rewrite(n.source);
|
|
210
|
+
if (n.value) n.value = rewrite(n.value);
|
|
211
|
+
if (n.cond) n.cond = rewrite(n.cond);
|
|
212
|
+
if (Array.isArray(n.args)) n.args = rewrite(n.args);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── callee inlining ──────────────────────────────────────────────────────────
|
|
217
|
+
// The field-identity engine analyses one function at a time and does not carry a caller's parameter identities
|
|
218
|
+
// into a callee's sink sites. For Haskell, whose handlers delegate to other modules, that would drop every
|
|
219
|
+
// cross-module flow, so each statement-level call to a project function is replaced by a renamed COPY of the
|
|
220
|
+
// callee's CFG (bounded depth and size; recursion is not inlined). Parameters become assignments from the call's
|
|
221
|
+
// arguments, locals are prefixed so two inlined copies never collide, and a `return` becomes an assignment to the
|
|
222
|
+
// call's result variable. Branch structure is preserved: nothing is flattened.
|
|
223
|
+
const INLINE_MAX_DEPTH = 3;
|
|
224
|
+
const INLINE_MAX_NODES = 600;
|
|
225
|
+
|
|
226
|
+
const paramName = (p) => (typeof p === 'string' ? p : p && p.name);
|
|
227
|
+
|
|
228
|
+
function renameExpr(e, map) {
|
|
229
|
+
if (!e || typeof e !== 'object') return e;
|
|
230
|
+
if (Array.isArray(e)) return e.map((x) => renameExpr(x, map));
|
|
231
|
+
const out = { ...e };
|
|
232
|
+
if (e.kind === 'ident' && typeof e.name === 'string' && map.has(e.name)) out.name = map.get(e.name);
|
|
233
|
+
for (const k of ['object', 'callee', 'left', 'right', 'value', 'source']) if (e[k] && typeof e[k] === 'object') out[k] = renameExpr(e[k], map);
|
|
234
|
+
for (const k of ['args', 'elements', 'parts', 'options']) if (Array.isArray(e[k])) out[k] = e[k].map((x) => renameExpr(x, map));
|
|
235
|
+
if (Array.isArray(e.props)) out.props = e.props.map((p) => (p && p.value ? { ...p, value: renameExpr(p.value, map) } : p));
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
const renameTarget = (t, map) => { if (typeof t !== 'string') return t; const head = t.split('.')[0]; return map.has(head) ? map.get(head) + t.slice(head.length) : t; };
|
|
239
|
+
|
|
240
|
+
let inlineSeq = 0;
|
|
241
|
+
function inlineCalls(fn, ctx, stack = []) {
|
|
242
|
+
if (fn.hs && fn.hs.lineageInlined) return;
|
|
243
|
+
fn.hs = { ...(fn.hs || {}), lineageInlined: true };
|
|
244
|
+
let total = Object.keys(fn.cfg.nodes).length;
|
|
245
|
+
const skipped = [];
|
|
246
|
+
for (const [id, n] of Object.entries({ ...fn.cfg.nodes })) {
|
|
247
|
+
const isCall = n.kind === 'call' && typeof n.callee === 'string';
|
|
248
|
+
const isAssignCall = n.kind === 'assign' && n.source && n.source.kind === 'call' && typeof n.source.callee === 'string';
|
|
249
|
+
if (!isCall && !isAssignCall) continue;
|
|
250
|
+
const call = isCall ? n : n.source;
|
|
251
|
+
const target = (call.hs && call.hs.target) || (ctx.resolve ? ctx.resolve(call.callee, fn.file) : null);
|
|
252
|
+
if (!target) continue;
|
|
253
|
+
const callee = ctx.clones.get(target);
|
|
254
|
+
if (!callee) continue;
|
|
255
|
+
if (stack.includes(target) || stack.length >= INLINE_MAX_DEPTH) { skipped.push({ callee: call.callee, reason: stack.includes(target) ? 'recursion' : 'depth' }); continue; }
|
|
256
|
+
inlineCalls(callee, ctx, [...stack, fn.qid]);
|
|
257
|
+
const body = Object.entries(callee.cfg.nodes).filter(([, c]) => c.kind !== 'entry' && c.kind !== 'exit');
|
|
258
|
+
if (!body.length || total + body.length > INLINE_MAX_NODES) { if (body.length) skipped.push({ callee: call.callee, reason: 'size' }); continue; }
|
|
259
|
+
const tag = `__i${++inlineSeq}_`;
|
|
260
|
+
const params = (callee.params || []).map(paramName).filter(Boolean);
|
|
261
|
+
const map = new Map();
|
|
262
|
+
for (const p of params) map.set(p, `${tag}${p}`);
|
|
263
|
+
for (const [, c] of body) if (c.kind === 'assign' && typeof c.target === 'string') { const head = c.target.split('.')[0]; if (!map.has(head)) map.set(head, `${tag}${head}`); }
|
|
264
|
+
const idMap = new Map(body.map(([cid]) => [cid, `${id}~${tag}${cid}`]));
|
|
265
|
+
const newNodes = new Map();
|
|
266
|
+
// parameter binding nodes
|
|
267
|
+
const binds = params.map((p, i) => ({ id: `${id}~${tag}bind${i}`, node: { kind: 'assign', line: n.line, target: map.get(p), source: (call.args || [])[i] || { kind: 'literal', value: null } } }));
|
|
268
|
+
for (const [cid, c] of body) {
|
|
269
|
+
const nn = { ...c, succ: (c.succ || []).map((x) => idMap.get(x) || null).filter(Boolean), pred: (c.pred || []).map((x) => idMap.get(x) || null).filter(Boolean) };
|
|
270
|
+
if (nn.kind === 'assign') { nn.target = renameTarget(nn.target, map); nn.source = renameExpr(nn.source, map); }
|
|
271
|
+
else if (nn.kind === 'call') { nn.args = renameExpr(nn.args, map); if (nn.callee && typeof nn.callee === 'object') nn.callee = renameExpr(nn.callee, map); }
|
|
272
|
+
else if (nn.kind === 'return') {
|
|
273
|
+
const value = renameExpr(nn.value, map);
|
|
274
|
+
if (isAssignCall && value) { nn.kind = 'assign'; nn.target = n.target; nn.source = value; delete nn.value; } else if (value && value.kind === 'call') { nn.kind = 'call'; nn.callee = value.callee; nn.args = value.args; nn.hs = value.hs || nn.hs; delete nn.value; } else { nn.kind = 'noop'; delete nn.value; }
|
|
275
|
+
} else if (nn.kind === 'if' || nn.kind === 'branch') { if (nn.cond) nn.cond = renameExpr(nn.cond, map); if (nn.thenEntry) nn.thenEntry = idMap.get(nn.thenEntry) || nn.thenEntry; if (nn.elseEntry) nn.elseEntry = idMap.get(nn.elseEntry) || nn.elseEntry; }
|
|
276
|
+
newNodes.set(idMap.get(cid), nn);
|
|
277
|
+
}
|
|
278
|
+
// wire: pred(X) -> binds -> callee entry successors ; callee returns/exit predecessors -> succ(X)
|
|
279
|
+
const entry = Object.entries(callee.cfg.nodes).find(([, c]) => c.kind === 'entry');
|
|
280
|
+
const exit = Object.entries(callee.cfg.nodes).find(([, c]) => c.kind === 'exit');
|
|
281
|
+
const entryNext = ((entry && entry[1].succ) || []).map((x) => idMap.get(x)).filter(Boolean);
|
|
282
|
+
const exitPreds = ((exit && exit[1].pred) || []).map((x) => idMap.get(x)).filter(Boolean);
|
|
283
|
+
const chain = binds.map((b) => b.id);
|
|
284
|
+
const first = chain.length ? chain[0] : null;
|
|
285
|
+
for (let i = 0; i < binds.length; i++) newNodes.set(binds[i].id, { ...binds[i].node, succ: i + 1 < binds.length ? [binds[i + 1].id] : entryNext, pred: i === 0 ? [...(n.pred || [])] : [binds[i - 1].id] });
|
|
286
|
+
const startIds = first ? [first] : entryNext;
|
|
287
|
+
for (const nid of entryNext) { const nn = newNodes.get(nid); if (nn) nn.pred = binds.length ? [binds[binds.length - 1].id] : [...(n.pred || [])]; }
|
|
288
|
+
for (const xid of exitPreds) { const nn = newNodes.get(xid); if (nn) nn.succ = [...(n.succ || [])]; }
|
|
289
|
+
for (const pid of n.pred || []) { const pn = fn.cfg.nodes[pid]; if (pn) pn.succ = [...new Set([...(pn.succ || []).filter((x) => x !== id), ...startIds])]; if (pn && pn.thenEntry === id) pn.thenEntry = startIds[0]; if (pn && pn.elseEntry === id) pn.elseEntry = startIds[0]; }
|
|
290
|
+
for (const sid of n.succ || []) { const sn = fn.cfg.nodes[sid]; if (sn) sn.pred = [...new Set([...(sn.pred || []).filter((x) => x !== id), ...exitPreds])]; }
|
|
291
|
+
delete fn.cfg.nodes[id];
|
|
292
|
+
for (const [nid, nn] of newNodes) fn.cfg.nodes[nid] = nn;
|
|
293
|
+
total += newNodes.size;
|
|
294
|
+
fn.hs.lineageInlinedCalls = (fn.hs.lineageInlinedCalls || 0) + 1;
|
|
295
|
+
}
|
|
296
|
+
if (skipped.length) fn.hs.lineageNotInlined = skipped;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* @param {object} callGraph the shared call graph (never mutated)
|
|
301
|
+
* @returns {object} a call graph whose Haskell functions are lineage-view clones; the input itself when there is no Haskell
|
|
302
|
+
*/
|
|
303
|
+
export function adaptCallGraphForLineage(callGraph) {
|
|
304
|
+
if (!callGraph || !callGraph.haskell || typeof callGraph.functions?.values !== 'function') return callGraph;
|
|
305
|
+
const hsOriginals = [...callGraph.functions.values()].filter((f) => isHsFile(f.file));
|
|
306
|
+
if (!hsOriginals.length) return callGraph;
|
|
307
|
+
const originalsByQid = new Map(hsOriginals.map((f) => [f.qid, f]));
|
|
308
|
+
const clones = new Map();
|
|
309
|
+
for (const f of hsOriginals) clones.set(f.qid, clone(f));
|
|
310
|
+
for (const c of clones.values()) transformFunction(c, originalsByQid);
|
|
311
|
+
const ctx = { clones, resolve: callGraph.resolveKnownCallee ? (name, file) => { const r = callGraph.resolveKnownCallee(name, file); return typeof r === 'string' ? r : (r && r.qid) || null; } : null };
|
|
312
|
+
for (const c of clones.values()) inlineCalls(c, ctx);
|
|
313
|
+
const functions = new Map();
|
|
314
|
+
for (const [k, f] of callGraph.functions) functions.set(k, isHsFile(f.file) && clones.has(f.qid) ? clones.get(f.qid) : f);
|
|
315
|
+
const swap = (f) => (f && isHsFile(f.file) && clones.has(f.qid) ? clones.get(f.qid) : f);
|
|
316
|
+
const resolve = callGraph.resolve ? (...a) => swap(callGraph.resolve(...a)) : undefined;
|
|
317
|
+
const resolveKnownCallee = callGraph.resolveKnownCallee ? (...a) => swap(callGraph.resolveKnownCallee(...a)) : undefined;
|
|
318
|
+
return { ...callGraph, functions, ...(resolve ? { resolve } : {}), ...(resolveKnownCallee ? { resolveKnownCallee } : {}), lineageView: { haskell: true, functions: clones.size } };
|
|
319
|
+
}
|
package/src/lineage/index.js
CHANGED
|
@@ -69,6 +69,8 @@ import { loadObservations } from './observation-store.js';
|
|
|
69
69
|
// mirroring `scenario.js`'s own boundary) or `federation-loader.js`
|
|
70
70
|
// (which owns only the REMOTE side).
|
|
71
71
|
import { validateCrossRepoLink, CROSS_REPO_LINKS_FILENAME } from './cross-repo-link.js';
|
|
72
|
+
import { contributeNixConfigLineage } from './nix-view.js';
|
|
73
|
+
import { adaptCallGraphForLineage } from './haskell-view.js';
|
|
72
74
|
|
|
73
75
|
// A small, LOCAL, tolerant loader for the operator-declared
|
|
74
76
|
// cross-repo-links.json config file — mirrors `loadRecipientConfig`'s own
|
|
@@ -257,7 +259,10 @@ export function buildLineageGraph(callGraph, opts = {}) {
|
|
|
257
259
|
const crossRepoLinkRecords = _crossRepoLinksFile && fs.existsSync(_crossRepoLinksFile)
|
|
258
260
|
? _loadCrossRepoLinkRecords(_crossRepoLinksFile)
|
|
259
261
|
: undefined;
|
|
260
|
-
|
|
262
|
+
// Haskell is rewritten into a lineage VIEW (source reads become seedable member reads, sinks become
|
|
263
|
+
// statement-level calls); every other language, and the shared call graph itself, are untouched.
|
|
264
|
+
const lineageCallGraph = adaptCallGraphForLineage(callGraph);
|
|
265
|
+
const built = buildGraphWithCoverage(lineageCallGraph, {
|
|
261
266
|
repository: opts.repository,
|
|
262
267
|
generatedAt: opts.deterministic ? undefined : new Date().toISOString(),
|
|
263
268
|
perFile: opts.perFile,
|
|
@@ -273,6 +278,8 @@ export function buildLineageGraph(callGraph, opts = {}) {
|
|
|
273
278
|
observationWindowEnd: opts.observationWindowEnd,
|
|
274
279
|
onProgress: opts.onProgress,
|
|
275
280
|
});
|
|
281
|
+
const nixFiles = opts.fileContents && Object.keys(opts.fileContents).some((f) => /\.nix$/i.test(f));
|
|
282
|
+
if (nixFiles) contributeNixConfigLineage(built.graph, opts.fileContents, { repository: opts.repository });
|
|
276
283
|
return { status: 'complete', graph: built.graph, transitEvidence, failure: null, elapsedMs: Date.now() - t0 };
|
|
277
284
|
} catch (e) {
|
|
278
285
|
// Best-effort (DESIGN_GRAPH_BUILDER.md §9.5 item 1): recorded, never
|