@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.29
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 +24 -0
- package/bin/agentic-security.js +41 -4
- 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/dist/{90ebba38d8860ec77376.json → c06a1b4c20155c06fb48.json} +4 -3
- package/package.json +4 -3
- package/src/dataflow/catalog.js +42 -1
- package/src/engine.js +91 -11
- package/src/language/CLAUDE.md +7 -1
- 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-guards.js +26 -5
- 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 +193 -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,634 @@
|
|
|
1
|
+
export const id = 5856;
|
|
2
|
+
export const ids = [5856];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 25856:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// EXPORTS
|
|
10
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
+
assessLanguageAssurance: () => (/* binding */ assessLanguageAssurance)
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// UNUSED EXPORTS: ASSURANCE_VERSION, OPTIONAL_MODES, REQUIRED_CAPABILITIES
|
|
15
|
+
|
|
16
|
+
// EXTERNAL MODULE: ./src/language/contracts.js
|
|
17
|
+
var contracts = __webpack_require__(43790);
|
|
18
|
+
// EXTERNAL MODULE: ./src/language/haskell-parser.js + 1 modules
|
|
19
|
+
var haskell_parser = __webpack_require__(99864);
|
|
20
|
+
// EXTERNAL MODULE: ./src/language/haskell-grammar.js
|
|
21
|
+
var haskell_grammar = __webpack_require__(50029);
|
|
22
|
+
;// CONCATENATED MODULE: ./src/language/haskell-adapter.js
|
|
23
|
+
// Adapter that plugs the Haskell parser into the language pipeline contracts
|
|
24
|
+
// (CORE-003), so parse outcomes reach scan health instead of staying inside the
|
|
25
|
+
// parser. A missing or corrupt grammar becomes `missing_grammar`, a budget bound
|
|
26
|
+
// becomes `timed_out` (deadline) or `unresolved` (size/depth), and syntax errors
|
|
27
|
+
// and opaque boundaries become `unresolved-branch` outcomes. Findings from other
|
|
28
|
+
// languages are never touched.
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
const HASKELL_PARSE_PRODUCER = 'language:haskell-parse';
|
|
35
|
+
|
|
36
|
+
// Boundaries whose content the parser could not see. cpp and ffi are disclosed on
|
|
37
|
+
// the parse result but do not make the file unresolved: both CPP branches are
|
|
38
|
+
// parsed and a foreign declaration's own Haskell signature is. `th-safe-splice` and `quasiquote-inert` are matched by a
|
|
39
|
+
// closed, sound pattern (haskell-boundaries.js) and stay disclosed as assumptions; every other TH/quasi-quote boundary is opaque.
|
|
40
|
+
const OPAQUE_BOUNDARIES = new Set(['th-splice', 'th-quote', 'th-name-quote', 'th-top-level-splice', 'quasiquote', 'hsc', 'generated']);
|
|
41
|
+
|
|
42
|
+
function ensureHaskellParseProducer() {
|
|
43
|
+
if (!(0,contracts/* isRegisteredLanguageProducer */.dO)(HASKELL_PARSE_PRODUCER)) {
|
|
44
|
+
(0,contracts/* registerLanguageProducer */.OQ)({ id: HASKELL_PARSE_PRODUCER, language: 'haskell', capability: 'parse', evidenceKinds: ['source'], version: '1' });
|
|
45
|
+
}
|
|
46
|
+
return HASKELL_PARSE_PRODUCER;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{grammarSource?:Function, budgets?:object, mode?:string, cpp?:object, onParse?:(file:string, parse:object)=>void}} [opts]
|
|
51
|
+
*/
|
|
52
|
+
function createHaskellAdapter(opts = {}) {
|
|
53
|
+
ensureHaskellParseProducer();
|
|
54
|
+
return {
|
|
55
|
+
id: HASKELL_PARSE_PRODUCER,
|
|
56
|
+
language: 'haskell',
|
|
57
|
+
hasGrammar: () => (0,haskell_grammar/* loadHaskellGrammar */.UP)({ grammarSource: opts.grammarSource }).available,
|
|
58
|
+
analyze(file, content) {
|
|
59
|
+
const parse = (0,haskell_parser/* parseHaskell */.cX)(content, { file, grammarSource: opts.grammarSource, budgets: opts.budgets, mode: opts.mode, cpp: opts.cpp });
|
|
60
|
+
if (typeof opts.onParse === 'function') opts.onParse(file, parse);
|
|
61
|
+
if (parse.status === 'budget_exceeded' && parse.budget && parse.budget.name === 'deadlineMs') {
|
|
62
|
+
throw Object.assign(new Error(`Haskell parser deadline exceeded for ${file}`), { code: 'LANG_TIMEOUT' });
|
|
63
|
+
}
|
|
64
|
+
const unresolved = [];
|
|
65
|
+
if (parse.status === 'budget_exceeded') unresolved.push({ line: 1, reason: `parser budget exceeded: ${parse.budget.name}` });
|
|
66
|
+
else if (parse.status === 'failed') throw new Error(parse.errors[0] ? parse.errors[0].detail : 'Haskell parse failed');
|
|
67
|
+
else {
|
|
68
|
+
for (const e of parse.errors) unresolved.push({ line: e.span ? e.span.startLine : 1, reason: `syntax-error: ${e.kind}` });
|
|
69
|
+
for (const b of parse.boundaries) if (OPAQUE_BOUNDARIES.has(b.kind)) unresolved.push({ line: b.span.startLine, reason: `opaque-boundary: ${b.kind}` });
|
|
70
|
+
}
|
|
71
|
+
return { findings: [], unresolved, parse };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// EXTERNAL MODULE: ./src/language/haskell-cpp.js
|
|
77
|
+
var haskell_cpp = __webpack_require__(92777);
|
|
78
|
+
// EXTERNAL MODULE: ./src/language/nix-ir.js
|
|
79
|
+
var nix_ir = __webpack_require__(67772);
|
|
80
|
+
// EXTERNAL MODULE: ./src/language/nix-grammar.js
|
|
81
|
+
var nix_grammar = __webpack_require__(46910);
|
|
82
|
+
;// CONCATENATED MODULE: ./src/language/nix-adapter.js
|
|
83
|
+
// Adapter that plugs the Nix parser and config IR into the language pipeline
|
|
84
|
+
// contracts (CORE-003), so parse outcomes reach scan health instead of staying
|
|
85
|
+
// inside the parser. A missing or corrupt grammar becomes `missing_grammar`, a
|
|
86
|
+
// deadline hit becomes `timed_out`, and syntax errors, dynamic attributes,
|
|
87
|
+
// dynamic imports, lazy recursion and IR/parser budget hits become `unresolved`
|
|
88
|
+
// outcomes. Findings from other languages are never touched.
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
const NIX_PARSE_PRODUCER = 'language:nix-parse';
|
|
95
|
+
|
|
96
|
+
function ensureNixParseProducer() {
|
|
97
|
+
if (!(0,contracts/* isRegisteredLanguageProducer */.dO)(NIX_PARSE_PRODUCER)) {
|
|
98
|
+
(0,contracts/* registerLanguageProducer */.OQ)({ id: NIX_PARSE_PRODUCER, language: 'nix', capability: 'parse', evidenceKinds: ['source'], version: '1' });
|
|
99
|
+
}
|
|
100
|
+
return NIX_PARSE_PRODUCER;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {{grammarSource?:Function, budgets?:object, irBudgets?:object, onParse?:(file:string, result:object)=>void}} [opts]
|
|
105
|
+
*/
|
|
106
|
+
function createNixAdapter(opts = {}) {
|
|
107
|
+
ensureNixParseProducer();
|
|
108
|
+
return {
|
|
109
|
+
id: NIX_PARSE_PRODUCER,
|
|
110
|
+
language: 'nix',
|
|
111
|
+
hasGrammar: () => (0,nix_grammar/* loadNixGrammar */.T$)({ grammarSource: opts.grammarSource }).available,
|
|
112
|
+
analyze(file, content) {
|
|
113
|
+
const result = (0,nix_ir/* analyzeNix */.KL)(content, { file, grammarSource: opts.grammarSource, budgets: opts.budgets, irBudgets: opts.irBudgets });
|
|
114
|
+
if (typeof opts.onParse === 'function') opts.onParse(file, result);
|
|
115
|
+
const { parse, ir } = result;
|
|
116
|
+
if (parse.status === 'budget_exceeded' && parse.budget && parse.budget.name === 'deadlineMs') {
|
|
117
|
+
throw Object.assign(new Error(`Nix parser deadline exceeded for ${file}`), { code: 'LANG_TIMEOUT' });
|
|
118
|
+
}
|
|
119
|
+
if (parse.status === 'failed' && !parse.errors.length) throw new Error('Nix parse failed');
|
|
120
|
+
const unresolved = ir.unresolved.map((u) => ({ line: u.span ? u.span.startLine : 1, reason: `${u.kind}: ${u.detail}` }));
|
|
121
|
+
return { findings: [], unresolved, parse, ir };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// EXTERNAL MODULE: ./src/language/haskell-manifests.js
|
|
127
|
+
var haskell_manifests = __webpack_require__(16522);
|
|
128
|
+
// EXTERNAL MODULE: ./src/language/haskell-models.js
|
|
129
|
+
var haskell_models = __webpack_require__(54084);
|
|
130
|
+
// EXTERNAL MODULE: external "node:path"
|
|
131
|
+
var external_node_path_ = __webpack_require__(76760);
|
|
132
|
+
;// CONCATENATED MODULE: ./src/language/haskell-coverage.js
|
|
133
|
+
// Haskell coverage accounting (PRD FR 02).
|
|
134
|
+
//
|
|
135
|
+
// Answers "what actually ran" for Haskell, counted in separate stages so a
|
|
136
|
+
// recognised extension, a successful parse and a completed data-flow analysis
|
|
137
|
+
// are never conflated:
|
|
138
|
+
//
|
|
139
|
+
// read -> parse -> ruleEvaluation -> graphInclusion -> flowAnalysis
|
|
140
|
+
//
|
|
141
|
+
// Every eligible file is in the denominator whether it parsed or not. A file
|
|
142
|
+
// excluded by policy, a size cap or an excluded directory is listed with its
|
|
143
|
+
// reason and is not eligible. Nothing here reads a file, runs a tool or
|
|
144
|
+
// executes project code: it summarises results the engine already produced.
|
|
145
|
+
//
|
|
146
|
+
// Pure: inputs in, one record out.
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
const COVERAGE_SCHEMA = 'haskell-coverage/1';
|
|
152
|
+
const DEFAULT_THRESHOLDS = Object.freeze({ parseRate: 0.95, semanticRate: 0.90 });
|
|
153
|
+
|
|
154
|
+
const HS_FORM = /\.(lhs-boot|hs-boot|lhs|hs|hsig|hsc)$/i;
|
|
155
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/');
|
|
156
|
+
const formOf = (f) => { const m = HS_FORM.exec(norm(f)); return m ? m[1].toLowerCase() : null; };
|
|
157
|
+
const isPlainHs = (f) => formOf(f) === 'hs';
|
|
158
|
+
|
|
159
|
+
/** Rule families the Haskell pipeline can run. `needsDeep` ones only run under the IR layer. */
|
|
160
|
+
const RULE_FAMILIES = Object.freeze([
|
|
161
|
+
{ id: 'hs-security-rules', label: 'crypto, randomness, resource, XML and sensitive-logging rules', needsDeep: false },
|
|
162
|
+
{ id: 'hs-web', label: 'web routes and authorization', needsDeep: false },
|
|
163
|
+
{ id: 'hs-llm', label: 'LLM usage rules', needsDeep: false },
|
|
164
|
+
{ id: 'ir-taint', label: 'interprocedural taint (SQL, command, path, SSRF, ...)', needsDeep: true },
|
|
165
|
+
{ id: 'hs-sca', label: 'dependency manifests and advisories', needsDeep: false },
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
function packageIndex(files) {
|
|
169
|
+
const roots = [];
|
|
170
|
+
for (const [p, text] of Object.entries(files)) {
|
|
171
|
+
const np = norm(p);
|
|
172
|
+
const base = np.split('/').pop();
|
|
173
|
+
let r = null;
|
|
174
|
+
try {
|
|
175
|
+
if (/\.cabal$/i.test(base)) r = (0,haskell_manifests/* parseCabalFile */.r7)(text, { file: np });
|
|
176
|
+
else if (base === 'package.yaml') r = (0,haskell_manifests/* parseHpackFile */.pJ)(text, { file: np });
|
|
177
|
+
} catch { r = null; }
|
|
178
|
+
if (r) roots.push({ dir: external_node_path_.posix.dirname(np) === '.' ? '' : external_node_path_.posix.dirname(np), name: (r.package && r.package.name) || external_node_path_.posix.basename(external_node_path_.posix.dirname(np)) || '(root)', manifest: np });
|
|
179
|
+
}
|
|
180
|
+
return roots.sort((a, b) => b.dir.length - a.dir.length);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function packageOf(file, index, sourceMap) {
|
|
184
|
+
const f = norm(file);
|
|
185
|
+
if (sourceMap && sourceMap.packages) {
|
|
186
|
+
let best = null;
|
|
187
|
+
for (const [name, prefixes] of Object.entries(sourceMap.packages)) {
|
|
188
|
+
for (const pre of [].concat(prefixes)) {
|
|
189
|
+
const p = norm(pre).replace(/\/+$/, '');
|
|
190
|
+
if ((f === p || f.startsWith(p + '/')) && (!best || p.length > best.len)) best = { name, len: p.length };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (best) return { name: best.name, source: 'source-map' };
|
|
194
|
+
}
|
|
195
|
+
for (const r of index) if (r.dir === '' || f.startsWith(r.dir + '/')) return { name: r.name, source: 'manifest' };
|
|
196
|
+
return { name: '(unassigned)', source: 'none' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rate = (n, d) => (d > 0 ? n / d : null);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {object} i
|
|
203
|
+
* @param {Record<string,string>} i.files sources and manifests the scan read
|
|
204
|
+
* @param {Map<string,object>|Record<string,object>} [i.parses] file -> parseHaskell result
|
|
205
|
+
* @param {object|null} [i.ir] buildHaskellIR result, or null when no graph was built
|
|
206
|
+
* @param {boolean} i.deepEnabled
|
|
207
|
+
* @param {Record<string,{ran:boolean, failed?:string, findings?:number}>} [i.ruleRuns]
|
|
208
|
+
* @param {{file:string,bytes:number,cap:number}[]} [i.sizeSkipped]
|
|
209
|
+
* @param {string[]} [i.policyExcluded]
|
|
210
|
+
* @param {string[]} [i.excludedPaths] language-excluded build-output paths seen
|
|
211
|
+
* @param {{packages:Record<string,string|string[]>}|null} [i.sourceMap]
|
|
212
|
+
* @param {{parseRate:number, semanticRate:number}} [i.thresholds]
|
|
213
|
+
*/
|
|
214
|
+
function buildHaskellCoverage(i) {
|
|
215
|
+
const files = i.files || {};
|
|
216
|
+
const th = { ...DEFAULT_THRESHOLDS, ...(i.thresholds || {}) };
|
|
217
|
+
const parses = i.parses instanceof Map ? i.parses : new Map(Object.entries(i.parses || {}));
|
|
218
|
+
const ir = i.ir || null;
|
|
219
|
+
const index = packageIndex(files);
|
|
220
|
+
const byFile = {};
|
|
221
|
+
const skipped = [];
|
|
222
|
+
|
|
223
|
+
const sizeSkipped = new Map((i.sizeSkipped || []).filter((x) => formOf(x.file)).map((x) => [norm(x.file), x]));
|
|
224
|
+
const policy = new Set((i.policyExcluded || []).map(norm).filter((f) => formOf(f)));
|
|
225
|
+
const excl = new Set((i.excludedPaths || []).map(norm).filter((f) => formOf(f)));
|
|
226
|
+
const present = Object.keys(files).map(norm).filter((f) => formOf(f));
|
|
227
|
+
|
|
228
|
+
const add = (file, status) => { byFile[file] = { form: formOf(file), package: packageOf(file, index, i.sourceMap).name, ...status }; };
|
|
229
|
+
|
|
230
|
+
for (const f of [...sizeSkipped.keys()]) {
|
|
231
|
+
const s = sizeSkipped.get(f);
|
|
232
|
+
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)` });
|
|
233
|
+
}
|
|
234
|
+
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' }); }
|
|
235
|
+
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' }); }
|
|
236
|
+
|
|
237
|
+
const familyRan = (id) => !!(i.ruleRuns && i.ruleRuns[id] && i.ruleRuns[id].ran);
|
|
238
|
+
const staticRulesRan = familyRan('hs-security-rules') && familyRan('hs-web');
|
|
239
|
+
|
|
240
|
+
for (const f of present.sort()) {
|
|
241
|
+
if (byFile[f]) continue;
|
|
242
|
+
const form = formOf(f);
|
|
243
|
+
const p = parses.get(f);
|
|
244
|
+
const parse = !p ? 'not-attempted' : p.status; // parsed | parsed_with_errors | failed | budget_exceeded | missing_grammar
|
|
245
|
+
const parsedOk = parse === 'parsed' || parse === 'parsed_with_errors';
|
|
246
|
+
// The rule modules and the IR read `.hs` only; every other form is parse-only and is said to be.
|
|
247
|
+
const plain = form === 'hs';
|
|
248
|
+
const ruleEvaluation = !plain ? 'unsupported-form' : !parsedOk ? 'not-run' : (staticRulesRan ? 'evaluated' : 'not-run');
|
|
249
|
+
const irFile = ir && ir.perFile && ir.perFile[f];
|
|
250
|
+
const graphInclusion = !plain ? 'parse-only' : (irFile ? 'included' : 'not-in-graph');
|
|
251
|
+
let flowAnalysis;
|
|
252
|
+
let fnDiscovered = p && Array.isArray(p.functions) ? p.functions.length : 0;
|
|
253
|
+
let fnLowered = 0; let fnIncomplete = 0;
|
|
254
|
+
if (irFile) {
|
|
255
|
+
const fns = irFile.functions.filter((x) => !(x.hs && x.hs.kind === 'module-init'));
|
|
256
|
+
fnLowered = fns.length; fnIncomplete = fns.filter((x) => x.hs && x.hs.incomplete).length;
|
|
257
|
+
fnDiscovered = Math.max(fnDiscovered, fnLowered);
|
|
258
|
+
}
|
|
259
|
+
const capped = ir && (ir.diagnostics || []).some((d) => d.file === f && (d.kind === 'function-cap' || d.kind === 'ir-lowering-failure' || d.kind === 'ir-parse-failure'));
|
|
260
|
+
if (!plain) flowAnalysis = 'unsupported-form';
|
|
261
|
+
else if (!i.deepEnabled) flowAnalysis = 'not-run:deep-analysis-disabled';
|
|
262
|
+
else if (graphInclusion !== 'included') flowAnalysis = 'not-run:not-in-graph';
|
|
263
|
+
else if (capped || fnIncomplete > 0 || parse === 'parsed_with_errors') flowAnalysis = 'partial';
|
|
264
|
+
else flowAnalysis = 'completed';
|
|
265
|
+
const boundaries = p && Array.isArray(p.boundaries) ? [...new Set(p.boundaries.map((b) => b.kind))].sort() : [];
|
|
266
|
+
add(f, {
|
|
267
|
+
eligible: true, read: 'read', parse, ruleEvaluation, graphInclusion, flowAnalysis,
|
|
268
|
+
functions: { discovered: fnDiscovered, lowered: fnLowered, incomplete: fnIncomplete },
|
|
269
|
+
...(boundaries.length ? { boundaries } : {}),
|
|
270
|
+
...(p && p.errors && p.errors.length ? { parseErrors: p.errors.length } : {}),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const rows = Object.entries(byFile);
|
|
275
|
+
const eligible = rows.filter(([, r]) => r.eligible);
|
|
276
|
+
const eligibleHs = eligible.filter(([f]) => isPlainHs(f));
|
|
277
|
+
|
|
278
|
+
const byForm = {};
|
|
279
|
+
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++; }
|
|
280
|
+
|
|
281
|
+
const byPackage = {};
|
|
282
|
+
for (const [, r] of rows) {
|
|
283
|
+
const e = (byPackage[r.package] = byPackage[r.package] || { discovered: 0, eligible: 0, parsed: 0, semanticComplete: 0 });
|
|
284
|
+
e.discovered++; if (r.eligible) e.eligible++; if (r.parse === 'parsed') e.parsed++; if (r.flowAnalysis === 'completed') e.semanticComplete++;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const parsedHs = eligibleHs.filter(([, r]) => r.parse === 'parsed').length;
|
|
288
|
+
const semanticHs = eligibleHs.filter(([, r]) => r.flowAnalysis === 'completed').length;
|
|
289
|
+
const fn = { discovered: 0, lowered: 0, incomplete: 0 };
|
|
290
|
+
for (const [, r] of eligible) if (r.functions) { fn.discovered += r.functions.discovered; fn.lowered += r.functions.lowered; fn.incomplete += r.functions.incomplete; }
|
|
291
|
+
const unresolvedCalls = ir && ir.callGraph && Array.isArray(ir.callGraph.unresolved) ? ir.callGraph.unresolved.length : (ir ? 0 : null);
|
|
292
|
+
|
|
293
|
+
const families = RULE_FAMILIES.map((fam) => {
|
|
294
|
+
const run = (i.ruleRuns && i.ruleRuns[fam.id]) || null;
|
|
295
|
+
const ranDeep = fam.needsDeep ? (i.deepEnabled && !!ir) : true;
|
|
296
|
+
const ran = !!(run && run.ran) && ranDeep;
|
|
297
|
+
// A caller that did not report rule runs (a direct assurance call, not an engine scan) gets 'not-reported': unknown, not a failure.
|
|
298
|
+
if (!i.ruleRuns) return { id: fam.id, label: fam.label, ran: null, status: 'not-reported', findings: null };
|
|
299
|
+
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 };
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const reasons = [];
|
|
303
|
+
const limitations = [];
|
|
304
|
+
if (present.length + sizeSkipped.size > 0) {
|
|
305
|
+
if (!i.deepEnabled) reasons.push('deep analysis is disabled: no Haskell data-flow analysis ran');
|
|
306
|
+
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})`);
|
|
307
|
+
const pr = rate(parsedHs, eligibleHs.length);
|
|
308
|
+
const sr = rate(semanticHs, eligibleHs.length);
|
|
309
|
+
if (pr !== null && pr < th.parseRate) reasons.push(`parse success ${(pr * 100).toFixed(1)}% is below ${(th.parseRate * 100)}% of eligible .hs files`);
|
|
310
|
+
if (sr !== null && sr < th.semanticRate) reasons.push(`semantic analysis completed for ${(sr * 100).toFixed(1)}% of eligible .hs files, below ${(th.semanticRate * 100)}%`);
|
|
311
|
+
const nonHs = eligible.filter(([f]) => !isPlainHs(f)).length;
|
|
312
|
+
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`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const incompleteEntries = eligibleHs.filter(([, r]) => r.parse !== 'parsed' || r.flowAnalysis !== 'completed')
|
|
316
|
+
.map(([file, r]) => ({ file, parse: r.parse, flowAnalysis: r.flowAnalysis })).slice(0, 500);
|
|
317
|
+
const mains = [];
|
|
318
|
+
if (ir && ir.callGraph && ir.callGraph.functions) {
|
|
319
|
+
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 });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Work completed and work remaining when a limit stopped the analysis (PRD FR 02). A file is remaining when its parse hit a
|
|
323
|
+
// budget or its flow analysis did not complete. Nothing is sampled: the lists are exhaustive up to the cap, in path order, so a
|
|
324
|
+
// rerun over the same inputs names the same files. `resume` says only what the scanner can do today (assurance.js adds the
|
|
325
|
+
// effective budgets and the comparison with the previous scan's record, language/haskell-budget-state.js).
|
|
326
|
+
const remaining = eligibleHs.filter(([, r]) => r.parse === 'budget_exceeded' || (r.parse === 'parsed' && r.flowAnalysis !== 'completed' && r.flowAnalysis !== 'not-applicable'))
|
|
327
|
+
.map(([file, r]) => ({ file, parse: r.parse, flowAnalysis: r.flowAnalysis })).sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
328
|
+
const budget = {
|
|
329
|
+
exhausted: eligibleHs.some(([, r]) => r.parse === 'budget_exceeded'),
|
|
330
|
+
completedFiles: eligibleHs.length - remaining.length,
|
|
331
|
+
remainingFiles: remaining.length,
|
|
332
|
+
remaining: remaining.slice(0, 500),
|
|
333
|
+
remainingTruncated: remaining.length > 500,
|
|
334
|
+
// The Haskell pass parses every eligible file on every scan: nothing is reused from a previous run, so there is no resume.
|
|
335
|
+
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' },
|
|
336
|
+
};
|
|
337
|
+
// The full list (not capped) for the persisted record in haskell-budget-state.js; non-enumerable so the report shape is unchanged.
|
|
338
|
+
Object.defineProperty(budget, 'remainingAll', { value: remaining, enumerable: false });
|
|
339
|
+
const hasHaskell = present.length + sizeSkipped.size + policy.size + excl.size > 0;
|
|
340
|
+
return {
|
|
341
|
+
schema: COVERAGE_SCHEMA,
|
|
342
|
+
present: hasHaskell,
|
|
343
|
+
// `complete` only when every stage that was required ran to completion; never inferred from a zero-finding scan.
|
|
344
|
+
capability: { status: !hasHaskell ? 'not-applicable' : (reasons.length || sizeSkipped.size) ? 'incomplete' : 'complete', reasons, limitations },
|
|
345
|
+
totals: { discovered: rows.length, eligible: eligible.length, eligibleHs: eligibleHs.length, skipped: skipped.length },
|
|
346
|
+
byForm, byPackage,
|
|
347
|
+
stages: {
|
|
348
|
+
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) },
|
|
349
|
+
semantic: { completed: semanticHs, rate: rate(semanticHs, eligibleHs.length) },
|
|
350
|
+
},
|
|
351
|
+
functions: fn,
|
|
352
|
+
unresolvedCalls,
|
|
353
|
+
ruleFamilies: families,
|
|
354
|
+
thresholds: th,
|
|
355
|
+
meetsThresholds: hasHaskell ? ((rate(parsedHs, eligibleHs.length) ?? 1) >= th.parseRate && (rate(semanticHs, eligibleHs.length) ?? 1) >= th.semanticRate) : null,
|
|
356
|
+
incompleteEntries,
|
|
357
|
+
incompleteMains: mains,
|
|
358
|
+
budget,
|
|
359
|
+
skipped: skipped.slice(0, 500),
|
|
360
|
+
byFile,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// EXTERNAL MODULE: ./src/language/haskell-budget-state.js
|
|
365
|
+
var haskell_budget_state = __webpack_require__(80291);
|
|
366
|
+
// EXTERNAL MODULE: ./src/language/haskell-ir.js + 1 modules
|
|
367
|
+
var haskell_ir = __webpack_require__(18821);
|
|
368
|
+
;// CONCATENATED MODULE: ./src/language/assurance.js
|
|
369
|
+
// Scan health and assurance for Haskell and Nix analysis (X-007).
|
|
370
|
+
//
|
|
371
|
+
// Collects every way language analysis can be incomplete into ONE `languageCoverage` input for computeScanHealth,
|
|
372
|
+
// so the existing advisory / standard / strict policy and the "Safe to deploy" verdict apply unchanged:
|
|
373
|
+
//
|
|
374
|
+
// required capabilities parser, SAST, taint, SCA, secrets (Haskell); parser, config, taint, secrets, supply (Nix).
|
|
375
|
+
// A required capability that is disabled, unavailable or failed is a CONDITION.
|
|
376
|
+
// expected static limits an opaque boundary (Template Haskell, quasi-quotes, FFI, CPP, hsc) is a documented limit
|
|
377
|
+
// of a static analysis, not a failure. It is listed under `limitations`, never as a
|
|
378
|
+
// condition, and never hides the files it touches (they stay `unresolved` in the ledger).
|
|
379
|
+
// optional modes nix-eval, cabal-plan, hackage-live. Their state is separate: `not_selected` (no
|
|
380
|
+
// condition), `not_run` (selected but absent: a condition, never reported as run), `failed`
|
|
381
|
+
// (a condition; static findings are retained untouched), `ok`.
|
|
382
|
+
// data currency a stale or missing advisory snapshot, a malformed manifest, a partial effective NixOS
|
|
383
|
+
// configuration.
|
|
384
|
+
//
|
|
385
|
+
// Independent findings are never dropped here: this module only produces health inputs and returns the findings it
|
|
386
|
+
// was given by reference, with a count, so a caller can prove retention.
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
const ASSURANCE_VERSION = 'language-assurance/1';
|
|
400
|
+
|
|
401
|
+
const REQUIRED_CAPABILITIES = Object.freeze({
|
|
402
|
+
haskell: Object.freeze(['parser', 'sast', 'taint', 'sca', 'secrets']),
|
|
403
|
+
nix: Object.freeze(['parser', 'config', 'taint', 'secrets', 'supply']),
|
|
404
|
+
});
|
|
405
|
+
const OPTIONAL_MODES = Object.freeze(['nix-eval', 'cabal-plan', 'hackage-live']);
|
|
406
|
+
|
|
407
|
+
const MALFORMED_MANIFEST = new Set(['malformed_manifest', 'malformed_yaml', 'parser_failure', 'read_failed', 'malformed_dependency', 'malformed_lock_entry', 'malformed_constraint', 'malformed_extra_dep', 'invalid_version_range', 'empty_manifest']);
|
|
408
|
+
const LANGS = Object.keys(REQUIRED_CAPABILITIES);
|
|
409
|
+
const langOf = (f) => (/\.l?hs$|\.hs-boot$|\.hsc$/i.test(f) ? 'haskell' : /\.nix$/i.test(f) ? 'nix' : null);
|
|
410
|
+
const isManifest = (f) => /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.freeze|\.local)?|package\.yaml|stack\.yaml(?:\.lock)?)$/i.test(f);
|
|
411
|
+
|
|
412
|
+
function optionalState(name, cfg) {
|
|
413
|
+
const c = cfg && cfg[name];
|
|
414
|
+
if (!c || !c.selected) return { name, selected: false, status: 'not_selected', ran: false, reason: 'the mode is opt-in and was not selected', condition: null };
|
|
415
|
+
const r = c.result;
|
|
416
|
+
if (!r) return { name, selected: true, status: 'not_run', ran: false, reason: c.reason || 'selected, but no result was produced', condition: `optional mode "${name}" was selected but did not run` };
|
|
417
|
+
if (r.status === 'ok' && r.ran !== false) return { name, selected: true, status: 'ok', ran: true, reason: null, condition: null };
|
|
418
|
+
return { name, selected: true, status: r.status || 'failed', ran: false, reason: r.reason || null, condition: `optional mode "${name}" ${r.status === 'timed_out' ? 'timed out' : 'failed'}${r.reason ? ` (${r.reason})` : ''}; static results are retained` };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @param {object} input
|
|
423
|
+
* @param {Record<string,string>} input.files rel path -> content: sources and manifests
|
|
424
|
+
* @param {object[]} [input.adapters] override the default parse adapters (tests inject hung or corrupt ones)
|
|
425
|
+
* @param {number} [input.timeoutMs] per-file adapter deadline
|
|
426
|
+
* @param {string[]} [input.disabled] capability ids switched off, "<language>:<capability>"
|
|
427
|
+
* @param {Record<string,string[]>} [input.required] override of REQUIRED_CAPABILITIES
|
|
428
|
+
* @param {object|null} [input.advisoryDb] a Hackage AdvisoryDb (haskell-sca.js), or null when none loaded
|
|
429
|
+
* @param {object[]} [input.effectiveConfigs] NixOS effective-config reports (nixos-module-resolver.js)
|
|
430
|
+
* @param {Record<string,{selected:boolean, result?:object, reason?:string}>} [input.optional]
|
|
431
|
+
* @param {object[]} [input.findings] findings already produced; returned untouched
|
|
432
|
+
*/
|
|
433
|
+
async function assessLanguageAssurance(input = {}) {
|
|
434
|
+
const files = input.files || {};
|
|
435
|
+
const present = new Set(Object.keys(files).map(langOf).filter(Boolean));
|
|
436
|
+
const manifests = Object.entries(files).filter(([f]) => isManifest(f));
|
|
437
|
+
if (manifests.length) present.add('haskell');
|
|
438
|
+
const required = input.required || REQUIRED_CAPABILITIES;
|
|
439
|
+
const disabled = new Set(input.disabled || []);
|
|
440
|
+
const findings = Array.isArray(input.findings) ? input.findings : [];
|
|
441
|
+
const conditions = [];
|
|
442
|
+
const limitations = [];
|
|
443
|
+
|
|
444
|
+
// 1. parse adapters over every language source
|
|
445
|
+
// Boundaries that do not make a file unresolved but that a reader must be told about: CPP (every branch was parsed, the
|
|
446
|
+
// chosen one is not known) and FFI (the foreign code is not analysed).
|
|
447
|
+
const DISCLOSED_KINDS = new Set(['cpp', 'ffi', 'th-safe-splice', 'quasiquote-inert']);
|
|
448
|
+
const disclosed = new Map(); // boundary kind -> {count, files:Set}
|
|
449
|
+
const projectModules = new Set(); const importedModules = new Map(); // module -> files
|
|
450
|
+
const parsesByFile = new Map();
|
|
451
|
+
const onParse = (file, parse) => {
|
|
452
|
+
parsesByFile.set(file, parse);
|
|
453
|
+
if (parse && parse.module && parse.module.name) projectModules.add(parse.module.name);
|
|
454
|
+
for (const i of (parse && parse.imports) || []) if (i && i.module) { const e = importedModules.get(i.module) || new Set(); e.add(file); importedModules.set(i.module, e); }
|
|
455
|
+
for (const b of (parse && parse.boundaries) || []) {
|
|
456
|
+
if (!DISCLOSED_KINDS.has(b.kind)) continue;
|
|
457
|
+
const e = disclosed.get(b.kind) || { count: 0, files: new Set(), decided: 0, undecided: 0, assumptions: new Set() };
|
|
458
|
+
e.count += b.count || 1; e.files.add(file);
|
|
459
|
+
if (b.kind === 'cpp') { e.decided += b.decided || 0; e.undecided += b.undecided || 0; for (const a of b.assumptions || []) e.assumptions.add(a); }
|
|
460
|
+
disclosed.set(b.kind, e);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const adapters = input.adapters || [createHaskellAdapter({ onParse, cpp: (0,haskell_cpp/* deriveCppContext */.uP)(files), budgets: input.parseBudgets || undefined }), createNixAdapter()];
|
|
464
|
+
const run = await (0,contracts/* runLanguageAnalysis */.i5)({ files, adapters, timeoutMs: input.timeoutMs || 0 });
|
|
465
|
+
const reconcile = (0,contracts/* reconcileLanguageLedger */.SA)(run.ledger, files);
|
|
466
|
+
const base = (0,contracts/* languageHealth */.bX)({ ledger: run.ledger, outcomes: run.outcomes });
|
|
467
|
+
const byKind = { ...base.byKind };
|
|
468
|
+
|
|
469
|
+
// outcome classification: expected static limits are not conditions
|
|
470
|
+
const opaque = run.outcomes.filter((o) => o.kind === 'unresolved-branch' && /^opaque-boundary/.test(String(o.detail || '')));
|
|
471
|
+
const otherUnresolved = run.outcomes.filter((o) => o.kind === 'unresolved-branch' && !/^opaque-boundary/.test(String(o.detail || '')));
|
|
472
|
+
if (byKind['missing-grammar']) conditions.push(`${byKind['missing-grammar']} language file(s) skipped: grammar unavailable or corrupt`);
|
|
473
|
+
if (byKind['adapter-exception']) conditions.push(`${byKind['adapter-exception']} language adapter exception(s)`);
|
|
474
|
+
if (byKind['timeout']) conditions.push(`${byKind['timeout']} language file(s) timed out`);
|
|
475
|
+
if (otherUnresolved.length) conditions.push(`${otherUnresolved.length} unresolved language construct(s) (syntax errors, dynamic attributes or imports)`);
|
|
476
|
+
if (byKind['unregistered-producer']) conditions.push(`${byKind['unregistered-producer']} result(s) from an unregistered language producer were discarded`);
|
|
477
|
+
if (opaque.length) {
|
|
478
|
+
const kinds = {};
|
|
479
|
+
for (const o of opaque) { const k = String(o.detail).replace(/^opaque-boundary:\s*/, ''); kinds[k] = (kinds[k] || 0) + 1; }
|
|
480
|
+
for (const [k, n] of Object.entries(kinds).sort()) limitations.push({ kind: 'opaque-boundary', boundary: k, count: n, note: 'a documented limit of static analysis: this code is not analysed, and is not reported clean' });
|
|
481
|
+
}
|
|
482
|
+
const DISCLOSED_NOTE = {
|
|
483
|
+
cpp: 'preprocessor conditionals: a conditional the file or the project decides keeps only its live branch, every other one keeps all of its branches, and which branch is compiled is then not known',
|
|
484
|
+
ffi: 'foreign declarations: the foreign code is not analysed and its effects are not modelled',
|
|
485
|
+
'th-safe-splice': 'declaration-level Template Haskell splices of a known generator applied to names and literals only: assumed to be the upstream generator, nothing is run and the generated declarations are not modelled',
|
|
486
|
+
'quasiquote-inert': 'quasi-quotes of a known raw-string or non-interpolating quoter: read as a string literal on the assumption that the quoter is the upstream one',
|
|
487
|
+
};
|
|
488
|
+
for (const [k, e] of [...disclosed.entries()].sort()) {
|
|
489
|
+
const extra = k === 'cpp' ? { decidedConditionals: e.decided, undecidedConditionals: e.undecided, ...(e.assumptions.size ? { assumptions: [...e.assumptions].sort() } : {}) } : {};
|
|
490
|
+
const assumed = k === 'cpp' && e.assumptions.size ? `; some conditionals were decided on a stated project assumption (${[...e.assumptions].sort().join(', ')}), so a branch dead under it was not analysed` : '';
|
|
491
|
+
limitations.push({ kind: 'opaque-boundary', boundary: k, count: e.count, files: [...e.files].sort().slice(0, 20), ...extra, note: `a documented limit of static analysis (${DISCLOSED_NOTE[k]}${assumed})` });
|
|
492
|
+
}
|
|
493
|
+
// Imported modules this scan has no model for: their functions are ordinary, opaque calls (never a source, sink or
|
|
494
|
+
// sanitizer), so a taint flow through them is widened and disclosed on the finding. Listed so a reader knows how much of the
|
|
495
|
+
// import surface the models cover. A limitation, not a condition: most real projects import unmodelled modules.
|
|
496
|
+
const unmodeled = [...importedModules.keys()].filter((m) => !projectModules.has(m) && !(0,haskell_models/* packageOfModule */.KF)(m)).sort();
|
|
497
|
+
if (unmodeled.length) limitations.push({ kind: 'unmodeled-imports', count: unmodeled.length, modules: unmodeled.slice(0, 30), note: 'no security model exists for these imported modules: their functions are analysed as ordinary calls, so a flow through them is widened and disclosed on the finding' });
|
|
498
|
+
// Sources the default ignore list kept out of the scan (tests, specs, mocks, bin, build, dist, vendor): the same
|
|
499
|
+
// policy every language has, but stated here so an unscanned Haskell/Nix file is never invisible.
|
|
500
|
+
// Sources and manifests over the read cap were NOT analysed: unlike a default-ignored directory, that is a gap in the analysis,
|
|
501
|
+
// so it is a condition (the verdict cannot be complete), listed with the path, its size and the cap.
|
|
502
|
+
const sizeSkipped = Array.isArray(input.sizeSkipped) ? input.sizeSkipped : [];
|
|
503
|
+
if (sizeSkipped.length) conditions.push(`${sizeSkipped.length} Haskell/Nix source or manifest file(s) exceed the size cap and were not analysed: ${sizeSkipped.slice(0, 3).map((x) => `${x.file} (${x.bytes} bytes > ${x.cap})`).join(', ')}${sizeSkipped.length > 3 ? ', ...' : ''}`);
|
|
504
|
+
const policyExcluded = Array.isArray(input.policyExcluded) ? input.policyExcluded : [];
|
|
505
|
+
if (policyExcluded.length) limitations.push({ kind: 'default-ignore', count: policyExcluded.length, files: policyExcluded.slice(0, 20), note: 'Haskell/Nix files under a default-ignored directory (tests, specs, mocks, bin, build, dist, vendor) are not scanned; this is the project-wide default for every language' });
|
|
506
|
+
// Nix-built images and declared OCI services are not image-layer or base-image scanned: the container analyzers read
|
|
507
|
+
// Dockerfile and compose SOURCE. Stated whenever such a declaration is present, so its absence from the findings is
|
|
508
|
+
// never read as a clean image.
|
|
509
|
+
const ociFiles = Object.entries(files).filter(([f, t]) => /\.nix$/i.test(f) && typeof t === 'string' && /oci-containers|dockerTools\.(?:buildImage|buildLayeredImage|streamLayeredImage)|virtualisation\.(?:docker|podman)/.test(t)).map(([f]) => f);
|
|
510
|
+
if (ociFiles.length) limitations.push({ kind: 'container-image-scan', files: ociFiles.sort(), note: 'Nix-built images and declared OCI services are analysed as configuration only: image layers and base-image vulnerabilities are NOT scanned' });
|
|
511
|
+
if (!reconcile.ok) conditions.push(`language ledger does not reconcile with the inputs: ${reconcile.errors[0]}`);
|
|
512
|
+
|
|
513
|
+
// Buck-built Haskell: its dependency graph is not resolved by this scanner, so version-based advisory matching has nothing to match.
|
|
514
|
+
// Stated as a coverage gap (PRD FR 08) rather than left looking like "no vulnerable dependencies".
|
|
515
|
+
const buckHaskell = Object.entries(files).filter(([f, t]) => /(?:^|\/)(?:BUCK|TARGETS)(?:\.v2)?$/.test(f) && typeof t === 'string' && /\bhaskell_(?:library|binary|test|ghci)\b|\bprebuilt_haskell_library\b/.test(t)).map(([f]) => f).sort();
|
|
516
|
+
if (present.has('haskell') && buckHaskell.length) conditions.push(`${buckHaskell.length} Buck target file(s) declare Haskell dependencies this scan does not resolve (${buckHaskell.slice(0, 3).join(', ')}): dependency advisories were not matched for them`);
|
|
517
|
+
|
|
518
|
+
// Declared project models (PRD FR 03): a rejected declaration means a wrapper the project meant to model is still opaque.
|
|
519
|
+
const pm = input.projectModels;
|
|
520
|
+
if (pm && pm.present) {
|
|
521
|
+
if (pm.rejected.length) conditions.push(`${pm.rejected.length} Haskell project model(s) were rejected and have no effect: ${pm.rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join('; ')}`);
|
|
522
|
+
if (pm.accepted.length) limitations.push({ kind: 'project-models', count: pm.accepted.length, ids: pm.accepted.map((m) => m.id).sort(), note: 'wrappers modelled by reviewed project declarations (.agentic-security/haskell-models.json); each carries provenance and a paired positive and negative test' });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// 2. manifests
|
|
526
|
+
if (manifests.length) {
|
|
527
|
+
let m = null;
|
|
528
|
+
try { m = (0,haskell_manifests/* analyzeHaskellManifests */.JS)(manifests.map(([path, text]) => ({ path, text })), {}); } catch (e) { conditions.push(`Haskell manifest analysis failed: ${String((e && e.message) || e)}`); }
|
|
529
|
+
if (m) {
|
|
530
|
+
const bad = m.diagnostics.filter((d) => MALFORMED_MANIFEST.has(d.kind));
|
|
531
|
+
if (bad.length) conditions.push(`${bad.length} malformed Haskell manifest entr${bad.length === 1 ? 'y' : 'ies'}: ${[...new Set(bad.map((d) => d.kind))].join(', ')}`);
|
|
532
|
+
if (m.coverage && m.coverage.failed) conditions.push(`${m.coverage.failed} Haskell manifest(s) could not be parsed`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 3. advisory feed currency (only meaningful when Haskell dependencies are in play and SCA is required)
|
|
537
|
+
const scaRequired = (required.haskell || []).includes('sca') && !disabled.has('haskell:sca');
|
|
538
|
+
if (present.has('haskell') && scaRequired && manifests.length) {
|
|
539
|
+
const db = input.advisoryDb;
|
|
540
|
+
if (!db) conditions.push(`no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed${input.advisoryReason ? `. ${input.advisoryReason}` : ''}`);
|
|
541
|
+
else if (db.stale) conditions.push(`the Hackage advisory snapshot is stale (${db.ageDays == null ? 'age unknown' : `${Math.floor(db.ageDays)} day(s) old`}, limit ${db.maxAgeDays})`);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 3b. resolved dependency data and the Nix closure: a stale, malformed or unchecked input is stated, never silently dropped
|
|
545
|
+
for (const g of input.supplyGaps || []) {
|
|
546
|
+
if (!g || !g.kind) continue;
|
|
547
|
+
if (/unavailable|stale|malformed|failed|unverified|refused|contradicts/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
|
|
548
|
+
else limitations.push({ kind: 'supply-gap', gap: g.kind, note: String(g.detail || '').slice(0, 220) });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (input.licenseUnavailable) limitations.push({ kind: 'license-data-unavailable', count: input.licenseUnavailable, note: 'Haskell and Nix dependency records carry no license data, so no license policy was applied to them; this is not a statement that they are acceptable' });
|
|
552
|
+
|
|
553
|
+
// 4. effective NixOS configuration
|
|
554
|
+
for (const r of input.effectiveConfigs || []) {
|
|
555
|
+
if (r && r.completeness === 'partial') conditions.push(`the effective NixOS configuration for ${(r.target && r.target.name) || 'a target'} is partial: ${(r.unresolved || []).length} unresolved, ${(r.truncated || []).length} truncated`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 5. required capabilities
|
|
559
|
+
const capabilities = {};
|
|
560
|
+
for (const lang of LANGS) {
|
|
561
|
+
if (!present.has(lang)) continue;
|
|
562
|
+
capabilities[lang] = {};
|
|
563
|
+
for (const cap of required[lang] || []) {
|
|
564
|
+
const id = `${lang}:${cap}`;
|
|
565
|
+
let status = 'ran'; let detail = null;
|
|
566
|
+
if (disabled.has(id)) { status = 'disabled'; detail = 'switched off by configuration'; }
|
|
567
|
+
else if (cap === 'parser') {
|
|
568
|
+
const counts = Object.values(run.ledger.byAnalyzer).filter((a) => a.language === lang);
|
|
569
|
+
const failed = counts.reduce((s, a) => s + a.failed + a.timedOut + a.missingGrammar, 0);
|
|
570
|
+
if (failed) { status = 'failed'; detail = `${failed} file(s) not parsed`; }
|
|
571
|
+
}
|
|
572
|
+
capabilities[lang][cap] = { required: true, status, detail };
|
|
573
|
+
if (status === 'disabled') conditions.push(`required analyzer "${id}" is disabled`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// 5b. Haskell coverage accounting (FR 02): per-file stages, rates against the eligible denominator, rule families executed.
|
|
578
|
+
let haskellCoverage = null;
|
|
579
|
+
if (present.has('haskell')) {
|
|
580
|
+
try {
|
|
581
|
+
let ir = null;
|
|
582
|
+
if (input.deepEnabled !== false) {
|
|
583
|
+
try { const hs = Object.fromEntries(Object.entries(files).filter(([f]) => /\.hs$/i.test(f))); if (Object.keys(hs).length) ir = (0,haskell_ir/* buildHaskellIR */.Y)(hs); } catch { ir = null; }
|
|
584
|
+
}
|
|
585
|
+
haskellCoverage = buildHaskellCoverage({
|
|
586
|
+
files, parses: parsesByFile, ir, deepEnabled: input.deepEnabled !== false && !disabled.has('haskell:taint'),
|
|
587
|
+
ruleRuns: input.ruleRuns || null, sizeSkipped: input.sizeSkipped, policyExcluded: input.policyExcluded,
|
|
588
|
+
excludedPaths: run.ledger.excludedFiles, sourceMap: input.sourceMap || null,
|
|
589
|
+
});
|
|
590
|
+
// Effective parse budgets, operator overrides that were refused, and the persisted record of work left (haskell-budget-state.js).
|
|
591
|
+
const b = haskellCoverage.budget;
|
|
592
|
+
b.budgets = { ...haskell_parser/* DEFAULT_PARSE_BUDGETS */.$e, ...(input.parseBudgets || {}) };
|
|
593
|
+
if (Array.isArray(input.parseBudgetRejected) && input.parseBudgetRejected.length) {
|
|
594
|
+
b.rejectedOverrides = input.parseBudgetRejected;
|
|
595
|
+
conditions.push(`Haskell parse budget override refused: ${input.parseBudgetRejected.map((r) => `${r.entry} (${r.reason})`).join('; ')}`);
|
|
596
|
+
}
|
|
597
|
+
const budgetOf = (file) => { const p = parsesByFile.get(file); return p && p.budget ? { budgetName: p.budget.name } : {}; };
|
|
598
|
+
const remainingAll = (b.remainingAll || []).map((r) => ({ ...r, ...budgetOf(r.file) }));
|
|
599
|
+
const previous = (0,haskell_budget_state/* loadBudgetState */.p6)(input.scanRoot);
|
|
600
|
+
const { record, sinceLast } = (0,haskell_budget_state/* budgetRecord */.Zg)({ remaining: remainingAll, completedFiles: b.completedFiles, budgets: b.budgets, files, previous });
|
|
601
|
+
b.remaining = b.remaining.map((r) => ({ ...r, ...budgetOf(r.file) }));
|
|
602
|
+
b.sinceLast = sinceLast;
|
|
603
|
+
if (record.remainingFiles || previous) b.recorded = (0,haskell_budget_state/* saveBudgetState */.Qg)(input.scanRoot, record);
|
|
604
|
+
// Only a measured shortfall is a condition. A parse-only form is a stated limitation, not a gap in what was asked.
|
|
605
|
+
for (const r of haskellCoverage.capability.reasons) if (!/deep analysis is disabled/.test(r)) conditions.push(`Haskell coverage: ${r}`);
|
|
606
|
+
for (const l of haskellCoverage.capability.limitations) limitations.push({ kind: 'haskell-parse-only', note: l });
|
|
607
|
+
} catch (e) { conditions.push(`Haskell coverage accounting failed: ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// 6. optional modes
|
|
611
|
+
const optionalModes = {};
|
|
612
|
+
for (const name of OPTIONAL_MODES) {
|
|
613
|
+
const st = optionalState(name, input.optional);
|
|
614
|
+
optionalModes[name] = { selected: st.selected, status: st.status, ran: st.ran, reason: st.reason };
|
|
615
|
+
if (st.condition) conditions.push(st.condition);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
version: ASSURANCE_VERSION,
|
|
620
|
+
languageCoverage: { totals: base.totals, byKind, conditions, limitations, capabilities, optionalModes, ...(haskellCoverage ? { haskell: haskellCoverage } : {}) },
|
|
621
|
+
ledger: { reconciled: reconcile.ok, errors: reconcile.errors, byAnalyzer: run.ledger.byAnalyzer },
|
|
622
|
+
outcomes: run.outcomes,
|
|
623
|
+
retained: { findings: findings.length, findingsRef: findings },
|
|
624
|
+
conditions,
|
|
625
|
+
limitations,
|
|
626
|
+
optionalModes,
|
|
627
|
+
capabilities,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
/***/ })
|
|
633
|
+
|
|
634
|
+
};
|