@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.28
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 +14 -0
- package/bin/agentic-security.js +35 -4
- package/dist/{90ebba38d8860ec77376.json → 3eabd90d6d9c8c8dbbd9.json} +4 -3
- package/dist/5856.index.js +634 -0
- package/dist/7033.index.js +74 -14
- package/dist/9220.index.js +24 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/dataflow/catalog.js +42 -1
- package/src/engine.js +90 -10
- package/src/language/CLAUDE.md +6 -0
- package/src/language/assurance.js +53 -2
- package/src/language/haskell-budget-state.js +85 -0
- package/src/language/haskell-coverage.js +230 -0
- package/src/language/haskell-project-models.js +107 -0
- package/src/language/nix-eval-isolation.js +74 -14
- package/src/llm-validator/bounded-context.js +87 -0
- package/src/llm-validator/claim-groups.js +105 -0
- package/src/llm-validator/disposition.js +264 -0
- package/src/llm-validator/index.js +52 -26
- package/src/pipeline/evidence-record.js +112 -0
- package/src/pipeline/scan-health.js +12 -1
- package/src/posture/artifact-registry.js +2 -0
- package/src/posture/drift.js +19 -1
- package/src/posture/reachability-filter.js +3 -3
- package/src/posture/suppressions.js +21 -0
- package/src/posture/watch-mode.js +22 -1
- package/src/report/disposition-view.js +90 -0
- package/src/report/index.js +47 -0
- package/src/runScan.js +60 -6
- package/src/sast/oidc-trust.js +74 -0
- package/src/sast/pipeline.js +14 -1
- package/src/sast/workflow-flow.js +272 -0
- package/src/sca/basis.js +88 -0
- package/src/scope/flags.js +55 -0
- package/src/scope/inventory.js +241 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Haskell parse budgets and the record of work left when one is hit (PRD FR 02 "budget behavior and proof of completion").
|
|
2
|
+
//
|
|
3
|
+
// The parser stops a file at a named bound (bytes, tokens, nesting, steps, wall clock) and reports `budget_exceeded`; the file is
|
|
4
|
+
// not analysed and the capability stays incomplete. This module does three things around that:
|
|
5
|
+
//
|
|
6
|
+
// 1. Operator budgets. AGENTIC_SECURITY_HASKELL_PARSE_BUDGETS raises (or lowers) named bounds: `deadlineMs=20000,maxTokens=800000`.
|
|
7
|
+
// Only the parser's own budget names are accepted, as positive integers; anything else is rejected and reported, never guessed.
|
|
8
|
+
// 2. A persisted record. Each scan writes `.agentic-security/haskell-budget.json`: the effective budgets, the count of completed
|
|
9
|
+
// files, and every remaining file with the reason and a digest of its content.
|
|
10
|
+
// 3. A comparison. The next scan reads that record and states, for each previously remaining file, whether it is now complete,
|
|
11
|
+
// still remaining on the same inputs and budgets (a deterministic bound will be hit again: raise it), still remaining after
|
|
12
|
+
// a change, or gone.
|
|
13
|
+
//
|
|
14
|
+
// What it does not do: resume. The Haskell pass parses every eligible file on every scan, so nothing is reused from a previous
|
|
15
|
+
// run, and nothing is skipped because it was analysed before. The record proves what was and was not finished; it does not
|
|
16
|
+
// shortcut the work. `deadlineMs` is wall-clock time, so a file can complete on one machine and exceed it on another.
|
|
17
|
+
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as crypto from 'node:crypto';
|
|
20
|
+
import { statePath, safeWriteState } from '../posture/state-dir.js';
|
|
21
|
+
import { DEFAULT_PARSE_BUDGETS } from './haskell-parser.js';
|
|
22
|
+
|
|
23
|
+
export const BUDGET_STATE_SCHEMA = 'haskell-budget-state/1';
|
|
24
|
+
export const HASKELL_PARSE_BUDGET_ENV = 'AGENTIC_SECURITY_HASKELL_PARSE_BUDGETS';
|
|
25
|
+
const STATE_FILE = 'haskell-budget.json';
|
|
26
|
+
const MAX_RECORDED = 5000;
|
|
27
|
+
|
|
28
|
+
/** Parse `name=value,...` into parser budget overrides. Returns { budgets, rejected }. */
|
|
29
|
+
export function parseBudgetOverrides(raw) {
|
|
30
|
+
const budgets = {};
|
|
31
|
+
const rejected = [];
|
|
32
|
+
for (const part of String(raw || '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
33
|
+
const m = /^([A-Za-z]+)\s*=\s*(\d+)$/.exec(part);
|
|
34
|
+
if (!m) { rejected.push({ entry: part, reason: 'expected name=positive-integer' }); continue; }
|
|
35
|
+
if (!Object.prototype.hasOwnProperty.call(DEFAULT_PARSE_BUDGETS, m[1])) { rejected.push({ entry: part, reason: `unknown budget "${m[1]}"` }); continue; }
|
|
36
|
+
const v = Number(m[2]);
|
|
37
|
+
if (!Number.isSafeInteger(v) || v <= 0) { rejected.push({ entry: part, reason: 'value must be a positive integer' }); continue; }
|
|
38
|
+
budgets[m[1]] = v;
|
|
39
|
+
}
|
|
40
|
+
return { budgets, rejected };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const contentDigest = (text) => crypto.createHash('sha256').update(String(text ?? '')).digest('hex');
|
|
44
|
+
|
|
45
|
+
export function loadBudgetState(scanRoot) {
|
|
46
|
+
if (!scanRoot) return null;
|
|
47
|
+
try {
|
|
48
|
+
const doc = JSON.parse(fs.readFileSync(statePath(scanRoot, STATE_FILE), 'utf8'));
|
|
49
|
+
if (!doc || doc.schema !== BUDGET_STATE_SCHEMA || !Array.isArray(doc.remaining)) return null;
|
|
50
|
+
return doc;
|
|
51
|
+
} catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sameBudgets = (a, b) => JSON.stringify(Object.entries(a || {}).sort()) === JSON.stringify(Object.entries(b || {}).sort());
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build this scan's record and compare it with the previous one.
|
|
58
|
+
* @param {{remaining:{file:string,parse:string,flowAnalysis:string,budgetName?:string}[], completedFiles:number,
|
|
59
|
+
* budgets:object, files:Record<string,string>, previous:object|null}} i
|
|
60
|
+
*/
|
|
61
|
+
export function budgetRecord({ remaining, completedFiles, budgets, files, previous }) {
|
|
62
|
+
const rows = remaining.slice(0, MAX_RECORDED).map((r) => ({ ...r, digest: contentDigest(files[r.file]) }));
|
|
63
|
+
const record = { schema: BUDGET_STATE_SCHEMA, budgets, completedFiles, remainingFiles: remaining.length, remaining: rows, truncated: remaining.length > rows.length };
|
|
64
|
+
if (!previous) return { record, sinceLast: null };
|
|
65
|
+
const now = new Map(rows.map((r) => [r.file, r]));
|
|
66
|
+
const budgetsChanged = !sameBudgets(previous.budgets, budgets);
|
|
67
|
+
const sinceLast = { budgetsChanged, completed: [], stillRemainingSameInputs: [], stillRemainingChanged: [], gone: [], newlyRemaining: [] };
|
|
68
|
+
for (const p of previous.remaining) {
|
|
69
|
+
if (!(p.file in files)) { sinceLast.gone.push(p.file); continue; }
|
|
70
|
+
const cur = now.get(p.file);
|
|
71
|
+
if (!cur) { sinceLast.completed.push(p.file); continue; }
|
|
72
|
+
// Both digests are hashes of source files the scan already read, not secrets: comparison time reveals nothing.
|
|
73
|
+
if (cur.digest === p.digest && !budgetsChanged) sinceLast.stillRemainingSameInputs.push(p.file); // agentic-security-ignore: CWE-208
|
|
74
|
+
else sinceLast.stillRemainingChanged.push(p.file);
|
|
75
|
+
}
|
|
76
|
+
const before = new Set(previous.remaining.map((p) => p.file));
|
|
77
|
+
for (const r of rows) if (!before.has(r.file)) sinceLast.newlyRemaining.push(r.file);
|
|
78
|
+
for (const k of Object.keys(sinceLast)) if (Array.isArray(sinceLast[k])) sinceLast[k].sort();
|
|
79
|
+
return { record, sinceLast };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function saveBudgetState(scanRoot, record) {
|
|
83
|
+
if (!scanRoot) return false;
|
|
84
|
+
return safeWriteState(statePath(scanRoot, STATE_FILE), JSON.stringify(record, null, 1) + '\n');
|
|
85
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Haskell coverage accounting (PRD FR 02).
|
|
2
|
+
//
|
|
3
|
+
// Answers "what actually ran" for Haskell, counted in separate stages so a
|
|
4
|
+
// recognised extension, a successful parse and a completed data-flow analysis
|
|
5
|
+
// are never conflated:
|
|
6
|
+
//
|
|
7
|
+
// read -> parse -> ruleEvaluation -> graphInclusion -> flowAnalysis
|
|
8
|
+
//
|
|
9
|
+
// Every eligible file is in the denominator whether it parsed or not. A file
|
|
10
|
+
// excluded by policy, a size cap or an excluded directory is listed with its
|
|
11
|
+
// reason and is not eligible. Nothing here reads a file, runs a tool or
|
|
12
|
+
// executes project code: it summarises results the engine already produced.
|
|
13
|
+
//
|
|
14
|
+
// Pure: inputs in, one record out.
|
|
15
|
+
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { parseCabalFile, parseHpackFile } from './haskell-manifests.js';
|
|
18
|
+
|
|
19
|
+
export const COVERAGE_SCHEMA = 'haskell-coverage/1';
|
|
20
|
+
export const DEFAULT_THRESHOLDS = Object.freeze({ parseRate: 0.95, semanticRate: 0.90 });
|
|
21
|
+
|
|
22
|
+
const HS_FORM = /\.(lhs-boot|hs-boot|lhs|hs|hsig|hsc)$/i;
|
|
23
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/');
|
|
24
|
+
export const formOf = (f) => { const m = HS_FORM.exec(norm(f)); return m ? m[1].toLowerCase() : null; };
|
|
25
|
+
const isPlainHs = (f) => formOf(f) === 'hs';
|
|
26
|
+
|
|
27
|
+
/** Rule families the Haskell pipeline can run. `needsDeep` ones only run under the IR layer. */
|
|
28
|
+
export const RULE_FAMILIES = Object.freeze([
|
|
29
|
+
{ id: 'hs-security-rules', label: 'crypto, randomness, resource, XML and sensitive-logging rules', needsDeep: false },
|
|
30
|
+
{ id: 'hs-web', label: 'web routes and authorization', needsDeep: false },
|
|
31
|
+
{ id: 'hs-llm', label: 'LLM usage rules', needsDeep: false },
|
|
32
|
+
{ id: 'ir-taint', label: 'interprocedural taint (SQL, command, path, SSRF, ...)', needsDeep: true },
|
|
33
|
+
{ id: 'hs-sca', label: 'dependency manifests and advisories', needsDeep: false },
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function packageIndex(files) {
|
|
37
|
+
const roots = [];
|
|
38
|
+
for (const [p, text] of Object.entries(files)) {
|
|
39
|
+
const np = norm(p);
|
|
40
|
+
const base = np.split('/').pop();
|
|
41
|
+
let r = null;
|
|
42
|
+
try {
|
|
43
|
+
if (/\.cabal$/i.test(base)) r = parseCabalFile(text, { file: np });
|
|
44
|
+
else if (base === 'package.yaml') r = parseHpackFile(text, { file: np });
|
|
45
|
+
} catch { r = null; }
|
|
46
|
+
if (r) roots.push({ dir: path.posix.dirname(np) === '.' ? '' : path.posix.dirname(np), name: (r.package && r.package.name) || path.posix.basename(path.posix.dirname(np)) || '(root)', manifest: np });
|
|
47
|
+
}
|
|
48
|
+
return roots.sort((a, b) => b.dir.length - a.dir.length);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function packageOf(file, index, sourceMap) {
|
|
52
|
+
const f = norm(file);
|
|
53
|
+
if (sourceMap && sourceMap.packages) {
|
|
54
|
+
let best = null;
|
|
55
|
+
for (const [name, prefixes] of Object.entries(sourceMap.packages)) {
|
|
56
|
+
for (const pre of [].concat(prefixes)) {
|
|
57
|
+
const p = norm(pre).replace(/\/+$/, '');
|
|
58
|
+
if ((f === p || f.startsWith(p + '/')) && (!best || p.length > best.len)) best = { name, len: p.length };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (best) return { name: best.name, source: 'source-map' };
|
|
62
|
+
}
|
|
63
|
+
for (const r of index) if (r.dir === '' || f.startsWith(r.dir + '/')) return { name: r.name, source: 'manifest' };
|
|
64
|
+
return { name: '(unassigned)', source: 'none' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rate = (n, d) => (d > 0 ? n / d : null);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {object} i
|
|
71
|
+
* @param {Record<string,string>} i.files sources and manifests the scan read
|
|
72
|
+
* @param {Map<string,object>|Record<string,object>} [i.parses] file -> parseHaskell result
|
|
73
|
+
* @param {object|null} [i.ir] buildHaskellIR result, or null when no graph was built
|
|
74
|
+
* @param {boolean} i.deepEnabled
|
|
75
|
+
* @param {Record<string,{ran:boolean, failed?:string, findings?:number}>} [i.ruleRuns]
|
|
76
|
+
* @param {{file:string,bytes:number,cap:number}[]} [i.sizeSkipped]
|
|
77
|
+
* @param {string[]} [i.policyExcluded]
|
|
78
|
+
* @param {string[]} [i.excludedPaths] language-excluded build-output paths seen
|
|
79
|
+
* @param {{packages:Record<string,string|string[]>}|null} [i.sourceMap]
|
|
80
|
+
* @param {{parseRate:number, semanticRate:number}} [i.thresholds]
|
|
81
|
+
*/
|
|
82
|
+
export function buildHaskellCoverage(i) {
|
|
83
|
+
const files = i.files || {};
|
|
84
|
+
const th = { ...DEFAULT_THRESHOLDS, ...(i.thresholds || {}) };
|
|
85
|
+
const parses = i.parses instanceof Map ? i.parses : new Map(Object.entries(i.parses || {}));
|
|
86
|
+
const ir = i.ir || null;
|
|
87
|
+
const index = packageIndex(files);
|
|
88
|
+
const byFile = {};
|
|
89
|
+
const skipped = [];
|
|
90
|
+
|
|
91
|
+
const sizeSkipped = new Map((i.sizeSkipped || []).filter((x) => formOf(x.file)).map((x) => [norm(x.file), x]));
|
|
92
|
+
const policy = new Set((i.policyExcluded || []).map(norm).filter((f) => formOf(f)));
|
|
93
|
+
const excl = new Set((i.excludedPaths || []).map(norm).filter((f) => formOf(f)));
|
|
94
|
+
const present = Object.keys(files).map(norm).filter((f) => formOf(f));
|
|
95
|
+
|
|
96
|
+
const add = (file, status) => { byFile[file] = { form: formOf(file), package: packageOf(file, index, i.sourceMap).name, ...status }; };
|
|
97
|
+
|
|
98
|
+
for (const f of [...sizeSkipped.keys()]) {
|
|
99
|
+
const s = sizeSkipped.get(f);
|
|
100
|
+
add(f, { eligible: true, read: 'oversize', parse: 'not-attempted', ruleEvaluation: 'not-run', graphInclusion: 'not-in-graph', flowAnalysis: 'not-run', reason: `over the read cap (${s.bytes} > ${s.cap} bytes)` });
|
|
101
|
+
}
|
|
102
|
+
for (const f of policy) if (!byFile[f]) { add(f, { eligible: false, read: 'not-read', reason: 'default-ignored directory' }); skipped.push({ file: f, reason: 'default-ignored directory' }); }
|
|
103
|
+
for (const f of excl) if (!byFile[f]) { add(f, { eligible: false, read: 'not-read', reason: 'build-output directory' }); skipped.push({ file: f, reason: 'build-output directory' }); }
|
|
104
|
+
|
|
105
|
+
const familyRan = (id) => !!(i.ruleRuns && i.ruleRuns[id] && i.ruleRuns[id].ran);
|
|
106
|
+
const staticRulesRan = familyRan('hs-security-rules') && familyRan('hs-web');
|
|
107
|
+
|
|
108
|
+
for (const f of present.sort()) {
|
|
109
|
+
if (byFile[f]) continue;
|
|
110
|
+
const form = formOf(f);
|
|
111
|
+
const p = parses.get(f);
|
|
112
|
+
const parse = !p ? 'not-attempted' : p.status; // parsed | parsed_with_errors | failed | budget_exceeded | missing_grammar
|
|
113
|
+
const parsedOk = parse === 'parsed' || parse === 'parsed_with_errors';
|
|
114
|
+
// The rule modules and the IR read `.hs` only; every other form is parse-only and is said to be.
|
|
115
|
+
const plain = form === 'hs';
|
|
116
|
+
const ruleEvaluation = !plain ? 'unsupported-form' : !parsedOk ? 'not-run' : (staticRulesRan ? 'evaluated' : 'not-run');
|
|
117
|
+
const irFile = ir && ir.perFile && ir.perFile[f];
|
|
118
|
+
const graphInclusion = !plain ? 'parse-only' : (irFile ? 'included' : 'not-in-graph');
|
|
119
|
+
let flowAnalysis;
|
|
120
|
+
let fnDiscovered = p && Array.isArray(p.functions) ? p.functions.length : 0;
|
|
121
|
+
let fnLowered = 0; let fnIncomplete = 0;
|
|
122
|
+
if (irFile) {
|
|
123
|
+
const fns = irFile.functions.filter((x) => !(x.hs && x.hs.kind === 'module-init'));
|
|
124
|
+
fnLowered = fns.length; fnIncomplete = fns.filter((x) => x.hs && x.hs.incomplete).length;
|
|
125
|
+
fnDiscovered = Math.max(fnDiscovered, fnLowered);
|
|
126
|
+
}
|
|
127
|
+
const capped = ir && (ir.diagnostics || []).some((d) => d.file === f && (d.kind === 'function-cap' || d.kind === 'ir-lowering-failure' || d.kind === 'ir-parse-failure'));
|
|
128
|
+
if (!plain) flowAnalysis = 'unsupported-form';
|
|
129
|
+
else if (!i.deepEnabled) flowAnalysis = 'not-run:deep-analysis-disabled';
|
|
130
|
+
else if (graphInclusion !== 'included') flowAnalysis = 'not-run:not-in-graph';
|
|
131
|
+
else if (capped || fnIncomplete > 0 || parse === 'parsed_with_errors') flowAnalysis = 'partial';
|
|
132
|
+
else flowAnalysis = 'completed';
|
|
133
|
+
const boundaries = p && Array.isArray(p.boundaries) ? [...new Set(p.boundaries.map((b) => b.kind))].sort() : [];
|
|
134
|
+
add(f, {
|
|
135
|
+
eligible: true, read: 'read', parse, ruleEvaluation, graphInclusion, flowAnalysis,
|
|
136
|
+
functions: { discovered: fnDiscovered, lowered: fnLowered, incomplete: fnIncomplete },
|
|
137
|
+
...(boundaries.length ? { boundaries } : {}),
|
|
138
|
+
...(p && p.errors && p.errors.length ? { parseErrors: p.errors.length } : {}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rows = Object.entries(byFile);
|
|
143
|
+
const eligible = rows.filter(([, r]) => r.eligible);
|
|
144
|
+
const eligibleHs = eligible.filter(([f]) => isPlainHs(f));
|
|
145
|
+
|
|
146
|
+
const byForm = {};
|
|
147
|
+
for (const [f, r] of rows) { const k = r.form; (byForm[k] = byForm[k] || { discovered: 0, eligible: 0, parsed: 0, parsedWithErrors: 0, failed: 0 }); byForm[k].discovered++; if (r.eligible) byForm[k].eligible++; if (r.parse === 'parsed') byForm[k].parsed++; else if (r.parse === 'parsed_with_errors') byForm[k].parsedWithErrors++; else if (r.eligible) byForm[k].failed++; }
|
|
148
|
+
|
|
149
|
+
const byPackage = {};
|
|
150
|
+
for (const [, r] of rows) {
|
|
151
|
+
const e = (byPackage[r.package] = byPackage[r.package] || { discovered: 0, eligible: 0, parsed: 0, semanticComplete: 0 });
|
|
152
|
+
e.discovered++; if (r.eligible) e.eligible++; if (r.parse === 'parsed') e.parsed++; if (r.flowAnalysis === 'completed') e.semanticComplete++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const parsedHs = eligibleHs.filter(([, r]) => r.parse === 'parsed').length;
|
|
156
|
+
const semanticHs = eligibleHs.filter(([, r]) => r.flowAnalysis === 'completed').length;
|
|
157
|
+
const fn = { discovered: 0, lowered: 0, incomplete: 0 };
|
|
158
|
+
for (const [, r] of eligible) if (r.functions) { fn.discovered += r.functions.discovered; fn.lowered += r.functions.lowered; fn.incomplete += r.functions.incomplete; }
|
|
159
|
+
const unresolvedCalls = ir && ir.callGraph && Array.isArray(ir.callGraph.unresolved) ? ir.callGraph.unresolved.length : (ir ? 0 : null);
|
|
160
|
+
|
|
161
|
+
const families = RULE_FAMILIES.map((fam) => {
|
|
162
|
+
const run = (i.ruleRuns && i.ruleRuns[fam.id]) || null;
|
|
163
|
+
const ranDeep = fam.needsDeep ? (i.deepEnabled && !!ir) : true;
|
|
164
|
+
const ran = !!(run && run.ran) && ranDeep;
|
|
165
|
+
// A caller that did not report rule runs (a direct assurance call, not an engine scan) gets 'not-reported': unknown, not a failure.
|
|
166
|
+
if (!i.ruleRuns) return { id: fam.id, label: fam.label, ran: null, status: 'not-reported', findings: null };
|
|
167
|
+
return { id: fam.id, label: fam.label, ran, status: ran ? 'executed' : (fam.needsDeep && !i.deepEnabled ? 'not-run:deep-analysis-disabled' : (run && run.failed ? `failed:${run.failed}` : 'not-run')), findings: run && Number.isInteger(run.findings) ? run.findings : null };
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const reasons = [];
|
|
171
|
+
const limitations = [];
|
|
172
|
+
if (present.length + sizeSkipped.size > 0) {
|
|
173
|
+
if (!i.deepEnabled) reasons.push('deep analysis is disabled: no Haskell data-flow analysis ran');
|
|
174
|
+
for (const f of families) if (f.ran === false && !(f.id === 'ir-taint' && !i.deepEnabled) && f.id !== 'hs-sca') reasons.push(`rule family ${f.id} did not run (${f.status})`);
|
|
175
|
+
const pr = rate(parsedHs, eligibleHs.length);
|
|
176
|
+
const sr = rate(semanticHs, eligibleHs.length);
|
|
177
|
+
if (pr !== null && pr < th.parseRate) reasons.push(`parse success ${(pr * 100).toFixed(1)}% is below ${(th.parseRate * 100)}% of eligible .hs files`);
|
|
178
|
+
if (sr !== null && sr < th.semanticRate) reasons.push(`semantic analysis completed for ${(sr * 100).toFixed(1)}% of eligible .hs files, below ${(th.semanticRate * 100)}%`);
|
|
179
|
+
const nonHs = eligible.filter(([f]) => !isPlainHs(f)).length;
|
|
180
|
+
if (nonHs) limitations.push(`${nonHs} eligible file(s) in a non-.hs form (.lhs, .hs-boot, .hsig, .hsc) are parse-only: no rules or data-flow analysis`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const incompleteEntries = eligibleHs.filter(([, r]) => r.parse !== 'parsed' || r.flowAnalysis !== 'completed')
|
|
184
|
+
.map(([file, r]) => ({ file, parse: r.parse, flowAnalysis: r.flowAnalysis })).slice(0, 500);
|
|
185
|
+
const mains = [];
|
|
186
|
+
if (ir && ir.callGraph && ir.callGraph.functions) {
|
|
187
|
+
for (const f of ir.callGraph.functions.values()) if (/\.main$/.test(f.name || '') && byFile[f.file] && byFile[f.file].flowAnalysis !== 'completed') mains.push({ name: f.name, file: f.file, flowAnalysis: byFile[f.file].flowAnalysis });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Work completed and work remaining when a limit stopped the analysis (PRD FR 02). A file is remaining when its parse hit a
|
|
191
|
+
// budget or its flow analysis did not complete. Nothing is sampled: the lists are exhaustive up to the cap, in path order, so a
|
|
192
|
+
// rerun over the same inputs names the same files. `resume` says only what the scanner can do today (assurance.js adds the
|
|
193
|
+
// effective budgets and the comparison with the previous scan's record, language/haskell-budget-state.js).
|
|
194
|
+
const remaining = eligibleHs.filter(([, r]) => r.parse === 'budget_exceeded' || (r.parse === 'parsed' && r.flowAnalysis !== 'completed' && r.flowAnalysis !== 'not-applicable'))
|
|
195
|
+
.map(([file, r]) => ({ file, parse: r.parse, flowAnalysis: r.flowAnalysis })).sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
196
|
+
const budget = {
|
|
197
|
+
exhausted: eligibleHs.some(([, r]) => r.parse === 'budget_exceeded'),
|
|
198
|
+
completedFiles: eligibleHs.length - remaining.length,
|
|
199
|
+
remainingFiles: remaining.length,
|
|
200
|
+
remaining: remaining.slice(0, 500),
|
|
201
|
+
remainingTruncated: remaining.length > 500,
|
|
202
|
+
// The Haskell pass parses every eligible file on every scan: nothing is reused from a previous run, so there is no resume.
|
|
203
|
+
resume: { supported: false, toFinish: 'raise the named parse budget with AGENTIC_SECURITY_HASKELL_PARSE_BUDGETS (name=value,...) and rescan; a file whose flow analysis did not run needs deep analysis on. The remaining files are never skipped silently, and the capability stays incomplete until they are analysed' },
|
|
204
|
+
};
|
|
205
|
+
// The full list (not capped) for the persisted record in haskell-budget-state.js; non-enumerable so the report shape is unchanged.
|
|
206
|
+
Object.defineProperty(budget, 'remainingAll', { value: remaining, enumerable: false });
|
|
207
|
+
const hasHaskell = present.length + sizeSkipped.size + policy.size + excl.size > 0;
|
|
208
|
+
return {
|
|
209
|
+
schema: COVERAGE_SCHEMA,
|
|
210
|
+
present: hasHaskell,
|
|
211
|
+
// `complete` only when every stage that was required ran to completion; never inferred from a zero-finding scan.
|
|
212
|
+
capability: { status: !hasHaskell ? 'not-applicable' : (reasons.length || sizeSkipped.size) ? 'incomplete' : 'complete', reasons, limitations },
|
|
213
|
+
totals: { discovered: rows.length, eligible: eligible.length, eligibleHs: eligibleHs.length, skipped: skipped.length },
|
|
214
|
+
byForm, byPackage,
|
|
215
|
+
stages: {
|
|
216
|
+
parse: { success: parsedHs, partial: eligibleHs.filter(([, r]) => r.parse === 'parsed_with_errors').length, failed: eligibleHs.filter(([, r]) => !['parsed', 'parsed_with_errors'].includes(r.parse)).length, rate: rate(parsedHs, eligibleHs.length) },
|
|
217
|
+
semantic: { completed: semanticHs, rate: rate(semanticHs, eligibleHs.length) },
|
|
218
|
+
},
|
|
219
|
+
functions: fn,
|
|
220
|
+
unresolvedCalls,
|
|
221
|
+
ruleFamilies: families,
|
|
222
|
+
thresholds: th,
|
|
223
|
+
meetsThresholds: hasHaskell ? ((rate(parsedHs, eligibleHs.length) ?? 1) >= th.parseRate && (rate(semanticHs, eligibleHs.length) ?? 1) >= th.semanticRate) : null,
|
|
224
|
+
incompleteEntries,
|
|
225
|
+
incompleteMains: mains,
|
|
226
|
+
budget,
|
|
227
|
+
skipped: skipped.slice(0, 500),
|
|
228
|
+
byFile,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Declarative project models for Haskell wrappers (PRD FR 03).
|
|
2
|
+
//
|
|
3
|
+
// A wrapper the shipped registry does not know (a project's own `runQuery`, a vendored HTTP helper) can be declared in
|
|
4
|
+
// `.agentic-security/haskell-models.json`. A suggestive function name is never enough: every model must carry provenance (who
|
|
5
|
+
// reviewed it and why), the argument position it concerns, and a paired positive and negative test that exist in the scanned
|
|
6
|
+
// source. A model that lacks any of these is rejected, with the reason, and has no effect on the scan.
|
|
7
|
+
//
|
|
8
|
+
// File shape:
|
|
9
|
+
// { "schema": "haskell-project-models/1",
|
|
10
|
+
// "models": [ { "id": "app-runquery", "role": "sink", "module": "App.Db", "name": "runQuery",
|
|
11
|
+
// "family": "sql", "cwe": "CWE-89", "argIndex": 0,
|
|
12
|
+
// "provenance": { "reviewer": "…", "reviewedAt": "2026-11-01", "reason": "…" },
|
|
13
|
+
// "tests": { "positive": "test/RunQueryVuln.hs", "negative": "test/RunQuerySafe.hs" } } ] }
|
|
14
|
+
//
|
|
15
|
+
// Roles: `source` (its result is untrusted input), `sink` (argument `argIndex` is dangerous for `family`), `sanitizer`
|
|
16
|
+
// (its result is safe for the families in `appliesTo`, and only those; `*` is not accepted).
|
|
17
|
+
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { statePath } from '../posture/state-dir.js';
|
|
21
|
+
|
|
22
|
+
export const PROJECT_MODEL_SCHEMA = 'haskell-project-models/1';
|
|
23
|
+
|
|
24
|
+
const FAMILIES = Object.freeze({
|
|
25
|
+
sql: 'CWE-89', cmd: 'CWE-78', path: 'CWE-22', ssrf: 'CWE-918', xss: 'CWE-79', log: 'CWE-532', code: 'CWE-94', redirect: 'CWE-601',
|
|
26
|
+
});
|
|
27
|
+
const SEVERITY = { sql: 'high', cmd: 'high', path: 'high', ssrf: 'high', xss: 'high', log: 'medium', code: 'critical', redirect: 'medium' };
|
|
28
|
+
const MODULE_RE = /^[A-Z][A-Za-z0-9_']*(?:\.[A-Z][A-Za-z0-9_']*)*$/;
|
|
29
|
+
const NAME_RE = /^[a-z_][A-Za-z0-9_']*$/;
|
|
30
|
+
|
|
31
|
+
function check(m, exists) {
|
|
32
|
+
if (!m || typeof m !== 'object') return 'not an object';
|
|
33
|
+
if (!m.id || typeof m.id !== 'string') return 'missing id';
|
|
34
|
+
if (!['source', 'sink', 'sanitizer'].includes(m.role)) return `role must be source, sink or sanitizer, got ${JSON.stringify(m.role)}`;
|
|
35
|
+
if (!MODULE_RE.test(String(m.module || ''))) return 'module must be a qualified module name such as App.Db';
|
|
36
|
+
if (!NAME_RE.test(String(m.name || ''))) return 'name must be a function name';
|
|
37
|
+
const pv = m.provenance;
|
|
38
|
+
if (!pv || !pv.reviewer || !pv.reviewedAt || !pv.reason) return 'provenance needs reviewer, reviewedAt and reason: a function name alone is not a model';
|
|
39
|
+
const t = m.tests;
|
|
40
|
+
if (!t || !t.positive || !t.negative) return 'a paired positive and negative test is required';
|
|
41
|
+
if (t.positive === t.negative) return 'the positive and negative tests must be different files';
|
|
42
|
+
for (const k of ['positive', 'negative']) {
|
|
43
|
+
if (!exists(String(t[k]))) return `${k} test "${t[k]}" does not exist inside the project`;
|
|
44
|
+
}
|
|
45
|
+
if (m.role === 'sink') {
|
|
46
|
+
if (!FAMILIES[m.family]) return `family must be one of ${Object.keys(FAMILIES).join(', ')}`;
|
|
47
|
+
if (!(Number.isInteger(m.argIndex) && m.argIndex >= 0) && m.argIndex !== 'all') return 'a sink needs argIndex (a non-negative integer or "all")';
|
|
48
|
+
}
|
|
49
|
+
if (m.role === 'sanitizer') {
|
|
50
|
+
if (!Array.isArray(m.appliesTo) || !m.appliesTo.length) return 'a sanitizer needs appliesTo, the families it neutralises';
|
|
51
|
+
if (m.appliesTo.includes('*')) return 'a project sanitizer may not claim every family';
|
|
52
|
+
const bad = m.appliesTo.find((f) => !FAMILIES[f]);
|
|
53
|
+
if (bad) return `unknown family in appliesTo: ${bad}`;
|
|
54
|
+
}
|
|
55
|
+
if (m.role === 'source' && !m.label) return 'a source needs a label saying what it returns';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A predicate for 'this relative path is a regular file inside the project root'. */
|
|
60
|
+
export function projectFileExists(scanRoot) {
|
|
61
|
+
const root = path.resolve(scanRoot);
|
|
62
|
+
return (rel) => {
|
|
63
|
+
try {
|
|
64
|
+
if (path.isAbsolute(rel)) return false;
|
|
65
|
+
const abs = path.resolve(root, rel);
|
|
66
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) return false;
|
|
67
|
+
return fs.lstatSync(abs).isFile();
|
|
68
|
+
} catch { return false; }
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read the model file from the scan root (an exact path, never a traversal). Returns the parsed document or null. */
|
|
73
|
+
export function readProjectModelFile(scanRoot) {
|
|
74
|
+
try {
|
|
75
|
+
const fp = statePath(scanRoot, 'haskell-models.json');
|
|
76
|
+
return JSON.parse(fs.readFileSync(fp, 'utf8'));
|
|
77
|
+
} catch { return null; }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @returns {{present: boolean, accepted: object[], rejected: {id: string, reason: string}[], catalog: object[]}}
|
|
82
|
+
*/
|
|
83
|
+
export function loadProjectModels(doc, exists = () => false) {
|
|
84
|
+
if (!doc) return { present: false, accepted: [], rejected: [], catalog: [] };
|
|
85
|
+
if (doc.schema !== PROJECT_MODEL_SCHEMA || !Array.isArray(doc.models)) {
|
|
86
|
+
return { present: true, accepted: [], rejected: [{ id: '(file)', reason: `expected schema ${PROJECT_MODEL_SCHEMA} with a models array` }], catalog: [] };
|
|
87
|
+
}
|
|
88
|
+
const accepted = []; const rejected = []; const seen = new Set();
|
|
89
|
+
for (const m of doc.models) {
|
|
90
|
+
const id = String(m && m.id || '(no id)');
|
|
91
|
+
const why = seen.has(id) ? 'duplicate id' : check(m, exists);
|
|
92
|
+
seen.add(id);
|
|
93
|
+
if (why) rejected.push({ id, reason: why }); else accepted.push(m);
|
|
94
|
+
}
|
|
95
|
+
const catalog = accepted.map((m) => {
|
|
96
|
+
const callee = `${m.module}.${m.name}`;
|
|
97
|
+
const base = { id: `hs-project-${m.id}`.toLowerCase(), language: 'hs', framework: 'haskell', match: { type: 'call', callee }, projectModel: m.id };
|
|
98
|
+
if (m.role === 'source') return { ...base, kind: 'source', label: m.label, provenance: 'project-model' };
|
|
99
|
+
if (m.role === 'sink') return {
|
|
100
|
+
...base, kind: 'sink', argIndex: m.argIndex,
|
|
101
|
+
vuln: { name: m.vuln || `${m.family} sink (project model ${m.id})`, severity: SEVERITY[m.family], cwe: m.cwe || FAMILIES[m.family], remediation: m.remediation || 'See the project model that declares this wrapper.' },
|
|
102
|
+
hs: { family: m.family, shell: m.shell === true, argv: false, package: 'project', htmlSkeleton: false },
|
|
103
|
+
};
|
|
104
|
+
return { ...base, kind: 'sanitizer', effect: 'strip', appliesTo: m.appliesTo, note: `project model ${m.id}` };
|
|
105
|
+
});
|
|
106
|
+
return { present: true, accepted, rejected, catalog };
|
|
107
|
+
}
|
|
@@ -69,7 +69,7 @@ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p);
|
|
|
69
69
|
* The sandboxed command for a backend. Reads are an ALLOWLIST (the listed roots, system library paths and the
|
|
70
70
|
* evaluator); writes are an allowlist of one scratch directory; network and process creation are denied.
|
|
71
71
|
* @param {{backend:string, path?:string}} sb
|
|
72
|
-
* @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string}} spec
|
|
72
|
+
* @param {{execFile:string, args:string[], readOnly:string[], readFiles?:string[], writable:string[], cwd:string, env?:Record<string,string>}} spec
|
|
73
73
|
*/
|
|
74
74
|
export function sandboxCommand(sb, spec) {
|
|
75
75
|
const ro = [...new Set(spec.readOnly.map(real))];
|
|
@@ -92,34 +92,94 @@ export function sandboxCommand(sb, spec) {
|
|
|
92
92
|
for (const p of ro) a.push('--ro-bind', p, p);
|
|
93
93
|
for (const p of files) a.push('--ro-bind', p, p);
|
|
94
94
|
for (const p of rw) a.push('--bind', p, p);
|
|
95
|
+
// --clearenv empties the environment bwrap itself was given, so the clean one is set explicitly inside.
|
|
96
|
+
for (const [k, v] of Object.entries(spec.env || {})) a.push('--setenv', k, String(v));
|
|
97
|
+
// Namespaces do not stop process creation; the seccomp filter does. Before it, a sandboxed process could spawn anything the
|
|
98
|
+
// read-only binds expose (found the first time a CI runner had bwrap installed). On an architecture with no filter there is
|
|
99
|
+
// none, and the probe then reports the spawn and refuses to evaluate.
|
|
100
|
+
const seccomp = seccompNoProcesses(sb.arch || process.arch);
|
|
101
|
+
if (seccomp) a.push('--seccomp', '3');
|
|
95
102
|
a.push('--chdir', spec.cwd, exec, ...spec.args);
|
|
96
|
-
return { file: sb.path, args: a, profile: null };
|
|
103
|
+
return { file: sb.path, args: a, profile: null, fd3: seccomp };
|
|
97
104
|
}
|
|
98
105
|
return null;
|
|
99
106
|
}
|
|
100
107
|
|
|
108
|
+
// ── seccomp: threads yes, processes no ──────────────────────────────────────
|
|
109
|
+
// A classic-BPF seccomp program, as `bwrap --seccomp FD` reads it (struct sock_filter, native little-endian). It denies fork and vfork,
|
|
110
|
+
// denies clone unless CLONE_THREAD is set (so pthread_create works and posix_spawn, fork and vfork through clone do not), answers clone3
|
|
111
|
+
// with ENOSYS (its flags are behind a pointer a filter cannot read; glibc then falls back to clone), denies x32 syscalls, and kills a
|
|
112
|
+
// process that switches to another syscall ABI.
|
|
113
|
+
const SECCOMP_ARCH = {
|
|
114
|
+
x64: { audit: 0xc000003e, clone: 56, clone3: 435, deny: [57, 58], x32: true },
|
|
115
|
+
arm64: { audit: 0xc00000b7, clone: 220, clone3: 435, deny: [], x32: false },
|
|
116
|
+
};
|
|
117
|
+
const CLONE_THREAD = 0x10000;
|
|
118
|
+
export function seccompNoProcesses(arch) {
|
|
119
|
+
const A = SECCOMP_ARCH[arch];
|
|
120
|
+
if (!A) return null;
|
|
121
|
+
const LD = 0x20, JEQ = 0x15, JGE = 0x35, JSET = 0x45, RET = 0x06;
|
|
122
|
+
const ALLOW = 0x7fff0000, EPERM = 0x00050000 | 1, ENOSYS = 0x00050000 | 38, KILL = 0x80000000;
|
|
123
|
+
// Instructions with symbolic jump targets, resolved to relative offsets below.
|
|
124
|
+
const prog = [
|
|
125
|
+
[LD, 0, 0, 4], // arch
|
|
126
|
+
[JEQ, null, 'kill', A.audit],
|
|
127
|
+
[LD, 0, 0, 0], // syscall number
|
|
128
|
+
...(A.x32 ? [[JGE, 'eperm', null, 0x40000000]] : []),
|
|
129
|
+
[JEQ, 'enosys', null, A.clone3],
|
|
130
|
+
...A.deny.map((nr) => [JEQ, 'eperm', null, nr]),
|
|
131
|
+
[JEQ, null, 'allow', A.clone],
|
|
132
|
+
[LD, 0, 0, 16], // clone flags, args[0] low word
|
|
133
|
+
[JSET, 'allow', 'eperm', CLONE_THREAD],
|
|
134
|
+
['allow', RET, ALLOW], ['eperm', RET, EPERM], ['enosys', RET, ENOSYS], ['kill', RET, KILL],
|
|
135
|
+
];
|
|
136
|
+
const at = {};
|
|
137
|
+
prog.forEach((ins, i) => { if (typeof ins[0] === 'string') at[ins[0]] = i; });
|
|
138
|
+
const buf = Buffer.alloc(prog.length * 8);
|
|
139
|
+
prog.forEach((ins, i) => {
|
|
140
|
+
const [code, jt, jf, k] = typeof ins[0] === 'string' ? [ins[1], 0, 0, ins[2]] : ins;
|
|
141
|
+
const rel = (t) => (t === null || t === 0 ? 0 : at[t] - i - 1);
|
|
142
|
+
buf.writeUInt16LE(code, i * 8); buf.writeUInt8(rel(jt), i * 8 + 2); buf.writeUInt8(rel(jf), i * 8 + 3); buf.writeUInt32LE(k >>> 0, i * 8 + 4);
|
|
143
|
+
});
|
|
144
|
+
return buf;
|
|
145
|
+
}
|
|
146
|
+
|
|
101
147
|
// ── process supervisor ───────────────────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
|
|
148
|
+
// The memory of the whole process tree under `root`, in MB. Not the process group: bwrap's --new-session puts the sandboxed process in
|
|
149
|
+
// a session and group of its own, so a group sum never saw the evaluator on Linux.
|
|
150
|
+
function rssOfTree(root) {
|
|
151
|
+
const r = spawnSync('/bin/ps', ['-A', '-o', 'pid=,ppid=,rss='], { encoding: 'utf8', timeout: 2000 });
|
|
104
152
|
if (r.status !== 0) return 0;
|
|
105
|
-
|
|
153
|
+
const kids = new Map(); const rss = new Map();
|
|
154
|
+
for (const line of r.stdout.split('\n')) {
|
|
155
|
+
const [pid, ppid, kb] = line.trim().split(/\s+/).map((x) => parseInt(x, 10));
|
|
156
|
+
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
|
|
157
|
+
rss.set(pid, Number.isFinite(kb) ? kb : 0);
|
|
158
|
+
if (!kids.has(ppid)) kids.set(ppid, []);
|
|
159
|
+
kids.get(ppid).push(pid);
|
|
160
|
+
}
|
|
161
|
+
let total = 0; const seen = new Set(); const stack = [root];
|
|
162
|
+
while (stack.length) { const p = stack.pop(); if (seen.has(p)) continue; seen.add(p); total += rss.get(p) || 0; stack.push(...(kids.get(p) || [])); }
|
|
163
|
+
return total / 1024;
|
|
106
164
|
}
|
|
107
165
|
function killGroup(pid, sig) { try { process.kill(-pid, sig); } catch { try { process.kill(pid, sig); } catch { /* gone */ } } }
|
|
108
166
|
|
|
109
167
|
/** Run a command to completion under a deadline, an output cap and a memory ceiling. Never throws. */
|
|
110
|
-
export function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500 }) {
|
|
168
|
+
export function supervise(file, args, { env, cwd, deadlineMs, maxOutputBytes, maxRssMb, killGraceMs = 1500, fd3 = null }) {
|
|
111
169
|
return new Promise((resolveP) => {
|
|
112
170
|
const t0 = Date.now();
|
|
113
171
|
let child;
|
|
114
|
-
try { child = spawn(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
172
|
+
try { child = spawn(file, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe', ...(fd3 ? ['pipe'] : [])] }); }
|
|
115
173
|
catch (e) { return resolveP({ status: 'failed', reason: `could not start: ${e.message}`, stdout: '', stderr: '', exitCode: null, signal: null, ms: 0 }); }
|
|
174
|
+
// fd 3 carries the seccomp program to bwrap; a child that exits before reading it closes the pipe, which is not an error here.
|
|
175
|
+
if (fd3) { child.stdio[3].on('error', () => {}); child.stdio[3].end(fd3); }
|
|
116
176
|
let out = ''; let err = ''; let bytes = 0; let state = null; let done = false;
|
|
117
177
|
const finish = (extra = {}) => { if (done) return; done = true; clearInterval(poll); clearTimeout(dl); resolveP({ stdout: out, stderr: err.slice(0, 4000), ms: Date.now() - t0, ...extra }); };
|
|
118
178
|
const stop = (why) => { if (state) return; state = why; killGroup(child.pid, 'SIGTERM'); setTimeout(() => killGroup(child.pid, 'SIGKILL'), killGraceMs).unref(); };
|
|
119
179
|
child.stdout.on('data', (d) => { bytes += d.length; if (bytes > maxOutputBytes) { stop('output-limit'); return; } out += d.toString('utf8'); });
|
|
120
180
|
child.stderr.on('data', (d) => { if (err.length < 8000) err += d.toString('utf8'); });
|
|
121
181
|
const dl = setTimeout(() => stop('timed-out'), deadlineMs);
|
|
122
|
-
const poll = setInterval(() => { if (!state && maxRssMb &&
|
|
182
|
+
const poll = setInterval(() => { if (!state && maxRssMb && rssOfTree(child.pid) > maxRssMb) stop('resource-limit'); }, 250);
|
|
123
183
|
child.on('error', (e) => finish({ status: 'failed', reason: e.message, exitCode: null, signal: null }));
|
|
124
184
|
child.on('close', (code, signal) => {
|
|
125
185
|
killGroup(child.pid, 'SIGKILL'); // reap any descendants
|
|
@@ -197,8 +257,8 @@ export async function probeSandbox(sb, opts = {}) {
|
|
|
197
257
|
const port = tcp.address().port;
|
|
198
258
|
try {
|
|
199
259
|
const exe = opts.nodePath || process.execPath;
|
|
200
|
-
const cmd = sandboxCommand(sb, { execFile: exe, args: [script, join(allowed, 'a.txt'), join(outside, 'secret.txt'), join(outside, 'w.txt'), join(scratch, 'w.txt'), String(port), sockPath], readOnly: [allowed, dirname(dirname(real(exe)))], readFiles: [script], writable: [scratch], cwd: scratch });
|
|
201
|
-
const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(scratch), cwd: scratch, deadlineMs: limits.probeDeadlineMs, maxOutputBytes: 64 * 1024, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
260
|
+
const cmd = sandboxCommand(sb, { env: cleanEnv(scratch), execFile: exe, args: [script, join(allowed, 'a.txt'), join(outside, 'secret.txt'), join(outside, 'w.txt'), join(scratch, 'w.txt'), String(port), sockPath], readOnly: [allowed, dirname(dirname(real(exe)))], readFiles: [script], writable: [scratch], cwd: scratch });
|
|
261
|
+
const r = await supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(scratch), cwd: scratch, deadlineMs: limits.probeDeadlineMs, maxOutputBytes: 64 * 1024, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
202
262
|
let attempts = null;
|
|
203
263
|
try { attempts = JSON.parse(r.stdout.trim()); } catch { /* unparseable: not verified */ }
|
|
204
264
|
if (!attempts) return { verified: false, backend: sb.backend, reason: `the probe did not complete inside the sandbox (${r.reason || r.stderr.slice(0, 120) || 'no output'})`, attempts: {} };
|
|
@@ -221,8 +281,8 @@ export async function probeSandbox(sb, opts = {}) {
|
|
|
221
281
|
/** @param {{file:string, prefixArgs?:string[]}} nix the evaluator command (a stand-in in tests) */
|
|
222
282
|
export async function detectEvaluator(nix, sb, ctx) {
|
|
223
283
|
const run = async (args) => {
|
|
224
|
-
const cmd = sandboxCommand(sb, { execFile: nix.file, args: [...(nix.prefixArgs || []), ...args], readOnly: ctx.readOnly, readFiles: [...(ctx.readFiles || []), ...(nix.prefixArgs || []).filter((a) => !a.startsWith('-'))], writable: [ctx.work], cwd: ctx.work });
|
|
225
|
-
return supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work), cwd: ctx.work, deadlineMs: 10_000, maxOutputBytes: 64 * 1024, maxRssMb: ctx.limits.maxRssMb, killGraceMs: ctx.limits.killGraceMs });
|
|
284
|
+
const cmd = sandboxCommand(sb, { env: cleanEnv(ctx.work), execFile: nix.file, args: [...(nix.prefixArgs || []), ...args], readOnly: ctx.readOnly, readFiles: [...(ctx.readFiles || []), ...(nix.prefixArgs || []).filter((a) => !a.startsWith('-'))], writable: [ctx.work], cwd: ctx.work });
|
|
285
|
+
return supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(ctx.work), cwd: ctx.work, deadlineMs: 10_000, maxOutputBytes: 64 * 1024, maxRssMb: ctx.limits.maxRssMb, killGraceMs: ctx.limits.killGraceMs });
|
|
226
286
|
};
|
|
227
287
|
const v = await run(['--version']);
|
|
228
288
|
const ver = (/(\d+\.\d+(?:\.\d+)?)/.exec(v.stdout) || [])[1] || null;
|
|
@@ -276,8 +336,8 @@ export async function runIsolatedEval(o) {
|
|
|
276
336
|
const feat = await detectEvaluator(o.nix, sb, ctx);
|
|
277
337
|
if (!feat.ok) return stopBefore('unsupported', `${feat.reason}; the project's Nix code was NOT evaluated`, { sandbox: sb, probe: { verified: true }, evaluator: feat });
|
|
278
338
|
const args = [...(o.nix.prefixArgs || []), ...buildEvalArgs(target)];
|
|
279
|
-
const cmd = sandboxCommand(sb, { execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
|
|
280
|
-
const r = await supervise(cmd.file, cmd.args, { env: cleanEnv(ctx.work, o.env || {}), cwd: ctx.work, deadlineMs: limits.deadlineMs, maxOutputBytes: limits.maxOutputBytes, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
339
|
+
const cmd = sandboxCommand(sb, { env: cleanEnv(ctx.work, o.env || {}), execFile: o.nix.file, args, readOnly: ctx.readOnly, readFiles: nixFiles, writable: [ctx.work], cwd: ctx.work });
|
|
340
|
+
const r = await supervise(cmd.file, cmd.args, { fd3: cmd.fd3, env: cleanEnv(ctx.work, o.env || {}), cwd: ctx.work, deadlineMs: limits.deadlineMs, maxOutputBytes: limits.maxOutputBytes, maxRssMb: limits.maxRssMb, killGraceMs: limits.killGraceMs });
|
|
281
341
|
const base = { ...keep, projectCodeEvaluated: true, sandbox: { backend: sb.backend }, evaluator: { version: feat.version }, ms: r.ms, target: { attribute: target.attribute, system: target.system, installable: target.installable } };
|
|
282
342
|
if (r.status !== 'ok') return { ...base, status: r.status === 'failed' ? 'failed' : r.status, reason: r.reason || r.stderr.slice(0, 200) };
|
|
283
343
|
let data;
|