@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/bin/agentic-security.js +131 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1192 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +20 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +125 -14
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +174 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +289 -0
- package/src/language/trusted-inputs.js +38 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Shared discovery registry for Haskell and Nix inputs (CORE-002).
|
|
2
|
+
//
|
|
3
|
+
// One place answers "is this path a Haskell/Nix source, a manifest, an explicit
|
|
4
|
+
// export, or build output to stay out of?", so the scan walker (runScan.js), the
|
|
5
|
+
// per-file filter (engine.js shouldScan), the watch filter (posture/watch-mode.js)
|
|
6
|
+
// and the incremental-invalidation key all agree. Before this module the answer
|
|
7
|
+
// was copied into each of those and would have drifted.
|
|
8
|
+
//
|
|
9
|
+
// Everything here is static: nothing is executed and nothing is read outside the
|
|
10
|
+
// scan root. Paths are '/'-separated and relative to the root.
|
|
11
|
+
//
|
|
12
|
+
// Three deliberate rules:
|
|
13
|
+
//
|
|
14
|
+
// * Build output is out of ordinary traversal. `dist-newstyle/`, `.stack-work/`,
|
|
15
|
+
// `.direnv/` and anything under `nix/store/` hold generated sources, caches
|
|
16
|
+
// and a store closure that is neither the project's code nor bounded in size.
|
|
17
|
+
// * An EXPLICIT export inside such a directory is the one exception, and it is
|
|
18
|
+
// read by exact path (`readExplicitExports`), never by recursing into the
|
|
19
|
+
// directory. A tool-written plan file is evidence; the directory around it is
|
|
20
|
+
// not.
|
|
21
|
+
// * Every graph walk is bounded (nodes, depth, imports per file) and every
|
|
22
|
+
// resolved path must stay inside the scan root. A bound hit is reported as
|
|
23
|
+
// `truncated`, never silently dropped.
|
|
24
|
+
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import * as crypto from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
const SOURCE_RE = /\.(lhs-boot|hs-boot|lhs|hs|hsig|hsc|nix)$/i;
|
|
30
|
+
const NIX_RE = /\.nix$/i;
|
|
31
|
+
|
|
32
|
+
const MANIFEST_NAMES = new Set([
|
|
33
|
+
'cabal.project', 'cabal.project.freeze', 'cabal.project.local', 'cabal.config',
|
|
34
|
+
'stack.yaml', 'stack.yaml.lock', 'package.yaml', 'hie.yaml',
|
|
35
|
+
'flake.nix', 'flake.lock', 'default.nix', 'shell.nix',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Directory names that hold build output or a store closure. Matched per path
|
|
39
|
+
// segment, plus the two-segment `nix/store`.
|
|
40
|
+
const EXCLUDED_DIRS = new Set(['dist-newstyle', '.stack-work', '.cabal-sandbox', '.direnv']);
|
|
41
|
+
|
|
42
|
+
// Exact project-relative paths a user or tool writes on purpose. Resolved per
|
|
43
|
+
// project directory, never by walking the excluded directory that holds them.
|
|
44
|
+
export const EXPLICIT_EXPORTS = Object.freeze([
|
|
45
|
+
'dist-newstyle/cache/plan.json',
|
|
46
|
+
'.stack-work/dependencies.json',
|
|
47
|
+
'.direnv/nix-export.json',
|
|
48
|
+
'nix-export.json',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_BUDGETS = Object.freeze({
|
|
52
|
+
maxNodes: 5000,
|
|
53
|
+
maxDepth: 32,
|
|
54
|
+
maxImportsPerFile: 256,
|
|
55
|
+
maxExportBytes: 10_000_000,
|
|
56
|
+
maxProjectDirs: 256,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/');
|
|
60
|
+
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
|
|
61
|
+
|
|
62
|
+
export function isLanguageSource(rel) {
|
|
63
|
+
return SOURCE_RE.test(norm(rel));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isLanguageManifest(rel) {
|
|
67
|
+
const base = norm(rel).split('/').pop() || '';
|
|
68
|
+
return MANIFEST_NAMES.has(base) || /\.cabal$/i.test(base);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function languageOf(rel) {
|
|
72
|
+
const p = norm(rel);
|
|
73
|
+
if (NIX_RE.test(p)) return 'nix';
|
|
74
|
+
if (SOURCE_RE.test(p)) return 'haskell';
|
|
75
|
+
const base = p.split('/').pop() || '';
|
|
76
|
+
if (/\.cabal$/i.test(base) || /^(?:cabal\.|stack\.yaml|package\.yaml|hie\.yaml)/.test(base)) return 'haskell';
|
|
77
|
+
if (base === 'flake.lock') return 'nix';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isLanguageExcludedPath(rel) {
|
|
82
|
+
const p = norm(rel);
|
|
83
|
+
const segs = p.split('/');
|
|
84
|
+
for (let i = 0; i < segs.length; i++) {
|
|
85
|
+
if (EXCLUDED_DIRS.has(segs[i])) return true;
|
|
86
|
+
if (segs[i] === 'nix' && segs[i + 1] === 'store') return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isExplicitExport(rel) {
|
|
92
|
+
const p = norm(rel);
|
|
93
|
+
return EXPLICIT_EXPORTS.some((e) => p === e || p.endsWith('/' + e));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Glob patterns for the walker's ignore list. Pruning only; correctness comes
|
|
97
|
+
// from the per-file predicates above.
|
|
98
|
+
export const WALK_IGNORE_GLOBS = Object.freeze([
|
|
99
|
+
'**/dist-newstyle/**', '**/.stack-work/**', '**/.cabal-sandbox/**', '**/.direnv/**', '**/nix/store/**',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
function within(root, abs) {
|
|
103
|
+
const r = path.resolve(root);
|
|
104
|
+
const a = path.resolve(abs);
|
|
105
|
+
return a === r || a.startsWith(r + path.sep);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Read the explicit exports for every project directory (root plus the
|
|
110
|
+
* directory of each manifest in `manifestRels`). Exact-path reads only: lstat
|
|
111
|
+
* rejects symlinks, realpath must stay inside `root`, and the size is capped.
|
|
112
|
+
* Returns { contents: {rel: text}, skipped: [{path, reason}] }.
|
|
113
|
+
*/
|
|
114
|
+
export function readExplicitExports(root, manifestRels = [], budgets = {}) {
|
|
115
|
+
const b = { ...DEFAULT_BUDGETS, ...budgets };
|
|
116
|
+
const contents = {};
|
|
117
|
+
const skipped = [];
|
|
118
|
+
let realRoot;
|
|
119
|
+
try { realRoot = fs.realpathSync(root); } catch { return { contents, skipped }; }
|
|
120
|
+
const dirs = new Set(['']);
|
|
121
|
+
for (const m of manifestRels) {
|
|
122
|
+
if (dirs.size >= b.maxProjectDirs) { skipped.push({ path: m, reason: 'project-dir-budget' }); break; }
|
|
123
|
+
const d = path.posix.dirname(norm(m));
|
|
124
|
+
if (!isLanguageExcludedPath(d)) dirs.add(d === '.' ? '' : d);
|
|
125
|
+
}
|
|
126
|
+
for (const d of [...dirs].sort()) {
|
|
127
|
+
for (const e of EXPLICIT_EXPORTS) {
|
|
128
|
+
const rel = d ? `${d}/${e}` : e;
|
|
129
|
+
const abs = path.join(root, rel);
|
|
130
|
+
let st;
|
|
131
|
+
try { st = fs.lstatSync(abs); } catch { continue; }
|
|
132
|
+
if (st.isSymbolicLink()) { skipped.push({ path: rel, reason: 'symlink' }); continue; }
|
|
133
|
+
if (!st.isFile()) continue;
|
|
134
|
+
if (st.size > b.maxExportBytes) { skipped.push({ path: rel, reason: 'too-large' }); continue; }
|
|
135
|
+
let real;
|
|
136
|
+
try { real = fs.realpathSync(abs); } catch { continue; }
|
|
137
|
+
if (!within(realRoot, real)) { skipped.push({ path: rel, reason: 'escapes-root' }); continue; }
|
|
138
|
+
try { contents[rel] = fs.readFileSync(abs, 'utf8'); } catch { /* unreadable */ }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { contents, skipped };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Describe a source file's declared scope and keep its source locations.
|
|
146
|
+
* `code` has exactly the same number of lines as `content` and every code
|
|
147
|
+
* character stays at its original column, so a position found in `code` is the
|
|
148
|
+
* same position in the file on disk.
|
|
149
|
+
* .lhs / .lhs-boot bird-track ("> ") and \begin{code} blocks are code
|
|
150
|
+
* .hs-boot scope 'boot-interface'
|
|
151
|
+
* .hsig scope 'signature'
|
|
152
|
+
* .hsc '#' preprocessor lines are blanked, scope 'preprocessed'
|
|
153
|
+
*/
|
|
154
|
+
export function describeSource(rel, content) {
|
|
155
|
+
const p = norm(rel);
|
|
156
|
+
const lang = languageOf(p);
|
|
157
|
+
const text = String(content ?? '');
|
|
158
|
+
const lower = p.toLowerCase();
|
|
159
|
+
let kind = 'plain';
|
|
160
|
+
let scope = lang === 'nix' ? 'nix-expression' : 'module';
|
|
161
|
+
if (/\.lhs-boot$/.test(lower)) { kind = 'literate-boot'; scope = 'boot-interface'; }
|
|
162
|
+
else if (/\.hs-boot$/.test(lower)) { kind = 'boot'; scope = 'boot-interface'; }
|
|
163
|
+
else if (/\.lhs$/.test(lower)) kind = 'literate';
|
|
164
|
+
else if (/\.hsig$/.test(lower)) { kind = 'signature'; scope = 'signature'; }
|
|
165
|
+
else if (/\.hsc$/.test(lower)) { kind = 'hsc'; scope = 'preprocessed'; }
|
|
166
|
+
|
|
167
|
+
const lines = text.split('\n');
|
|
168
|
+
let outLines = lines;
|
|
169
|
+
if (kind === 'literate' || kind === 'literate-boot') {
|
|
170
|
+
let inBlock = false;
|
|
171
|
+
outLines = lines.map((l) => {
|
|
172
|
+
if (/^\s*\\begin\{code\}/.test(l)) { inBlock = true; return ''; }
|
|
173
|
+
if (/^\s*\\end\{code\}/.test(l)) { inBlock = false; return ''; }
|
|
174
|
+
if (inBlock) return l;
|
|
175
|
+
if (l.startsWith('>')) return ' ' + l.slice(1);
|
|
176
|
+
return '';
|
|
177
|
+
});
|
|
178
|
+
} else if (kind === 'hsc') {
|
|
179
|
+
outLines = lines.map((l) => (/^\s*#/.test(l) ? '' : l));
|
|
180
|
+
}
|
|
181
|
+
const code = outLines.join('\n');
|
|
182
|
+
const m = lang === 'haskell' ? /^\s*(?:module|signature)\s+([A-Z][\w.']*)/m.exec(code) : null;
|
|
183
|
+
return {
|
|
184
|
+
language: lang, kind, scope,
|
|
185
|
+
declaredModule: m ? m[1] : null,
|
|
186
|
+
code,
|
|
187
|
+
lineCount: lines.length,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const HS_IMPORT_RE = /^\s*import\s+(?:\{-#\s*SOURCE\s*#-\}\s+)?(?:safe\s+)?(?:qualified\s+)?(?:"[^"]*"\s+)?([A-Z][\w.']*)/gm;
|
|
192
|
+
const NIX_PATH_RE = /(?<![\w.\/~-])(\.{1,2}\/[\w.\/+@-]+)/g;
|
|
193
|
+
|
|
194
|
+
// Map module name -> file keys, from the paths themselves. `src/Foo/Bar.hs`
|
|
195
|
+
// registers `Foo.Bar` and `Bar`; lower-case directories end the module path.
|
|
196
|
+
function haskellModuleIndex(keys) {
|
|
197
|
+
const idx = new Map();
|
|
198
|
+
for (const k of keys) {
|
|
199
|
+
if (!/\.(?:lhs-boot|hs-boot|lhs|hs|hsig|hsc)$/i.test(k)) continue;
|
|
200
|
+
const segs = k.replace(/\.(?:lhs-boot|hs-boot|lhs|hs|hsig|hsc)$/i, '').split('/');
|
|
201
|
+
const parts = [];
|
|
202
|
+
for (let i = segs.length - 1; i >= 0 && /^[A-Z]/.test(segs[i]); i--) {
|
|
203
|
+
parts.unshift(segs[i]);
|
|
204
|
+
const name = parts.join('.');
|
|
205
|
+
if (!idx.has(name)) idx.set(name, []);
|
|
206
|
+
idx.get(name).push(k);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return idx;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function commonPrefixLen(a, b) {
|
|
213
|
+
const x = a.split('/'); const y = b.split('/');
|
|
214
|
+
let n = 0;
|
|
215
|
+
while (n < x.length - 1 && n < y.length - 1 && x[n] === y[n]) n++;
|
|
216
|
+
return n;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Local import edges within `fileContents` (the already root-confined scan
|
|
221
|
+
* set). Returns { graph: Map<rel, string[]>, escaped: [{file, spec}] } where
|
|
222
|
+
* `escaped` lists Nix path literals that resolve outside the root. Unresolved
|
|
223
|
+
* imports (packages, store paths) add no edge.
|
|
224
|
+
*/
|
|
225
|
+
export function buildImportGraph(fileContents, budgets = {}) {
|
|
226
|
+
const b = { ...DEFAULT_BUDGETS, ...budgets };
|
|
227
|
+
const keys = Object.keys(fileContents).map(norm).filter(isLanguageSource).sort();
|
|
228
|
+
const keySet = new Set(keys);
|
|
229
|
+
const modIdx = haskellModuleIndex(keys);
|
|
230
|
+
const graph = new Map();
|
|
231
|
+
const escaped = [];
|
|
232
|
+
for (const k of keys) {
|
|
233
|
+
const deps = new Set();
|
|
234
|
+
const text = String(fileContents[k] ?? '');
|
|
235
|
+
if (languageOf(k) === 'haskell') {
|
|
236
|
+
const code = describeSource(k, text).code;
|
|
237
|
+
let n = 0; let m;
|
|
238
|
+
HS_IMPORT_RE.lastIndex = 0;
|
|
239
|
+
while ((m = HS_IMPORT_RE.exec(code)) && n++ < b.maxImportsPerFile) {
|
|
240
|
+
const cands = (modIdx.get(m[1]) || []).filter((c) => c !== k);
|
|
241
|
+
if (!cands.length) continue;
|
|
242
|
+
cands.sort((x, y) => commonPrefixLen(k, y) - commonPrefixLen(k, x) || (x < y ? -1 : 1));
|
|
243
|
+
deps.add(cands[0]);
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
let n = 0; let m;
|
|
247
|
+
NIX_PATH_RE.lastIndex = 0;
|
|
248
|
+
while ((m = NIX_PATH_RE.exec(text)) && n++ < b.maxImportsPerFile) {
|
|
249
|
+
const spec = m[1].replace(/[.,;]+$/, '');
|
|
250
|
+
const joined = path.posix.normalize(path.posix.join(path.posix.dirname(k), spec));
|
|
251
|
+
if (joined.startsWith('../') || joined === '..' || path.posix.isAbsolute(joined)) { escaped.push({ file: k, spec }); continue; }
|
|
252
|
+
for (const c of [joined, `${joined}.nix`, `${joined}/default.nix`]) {
|
|
253
|
+
if (keySet.has(c) && c !== k) { deps.add(c); break; }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
graph.set(k, [...deps].sort());
|
|
258
|
+
}
|
|
259
|
+
return { graph, escaped };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Transitive closure of `start` over `graph`, cycle-safe and bounded. */
|
|
263
|
+
export function importClosure(graph, start, budgets = {}) {
|
|
264
|
+
const b = { ...DEFAULT_BUDGETS, ...budgets };
|
|
265
|
+
const seen = new Set([start]);
|
|
266
|
+
let frontier = [start];
|
|
267
|
+
let truncated = false;
|
|
268
|
+
for (let depth = 0; frontier.length; depth++) {
|
|
269
|
+
if (depth >= b.maxDepth) { truncated = true; break; }
|
|
270
|
+
const next = [];
|
|
271
|
+
for (const f of frontier) {
|
|
272
|
+
for (const d of graph.get(f) || []) {
|
|
273
|
+
if (seen.has(d)) continue;
|
|
274
|
+
if (seen.size >= b.maxNodes) { truncated = true; break; }
|
|
275
|
+
seen.add(d); next.push(d);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
frontier = next;
|
|
279
|
+
}
|
|
280
|
+
seen.delete(start);
|
|
281
|
+
return { files: [...seen].sort(), truncated };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Per-file invalidation digests. A file's digest covers its own content, the
|
|
286
|
+
* content of every module it transitively imports, every language manifest
|
|
287
|
+
* (lockfile, project flags, target/configuration), every explicit export, and
|
|
288
|
+
* the library-model inputs. Change any of them and every file whose digest
|
|
289
|
+
* depended on it changes.
|
|
290
|
+
*/
|
|
291
|
+
export function computeLanguageDigests(fileContents, { manifests = {}, models = {}, budgets = {} } = {}) {
|
|
292
|
+
const { graph, escaped } = buildImportGraph(fileContents, budgets);
|
|
293
|
+
const project = crypto.createHash('sha256');
|
|
294
|
+
for (const [label, map] of [['m', manifests], ['l', models]]) {
|
|
295
|
+
for (const k of Object.keys(map).sort()) {
|
|
296
|
+
project.update(label + k + '\0' + sha(String(map[k] ?? '')) + '\n');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const projectDigest = project.digest('hex');
|
|
300
|
+
const digests = new Map();
|
|
301
|
+
let truncated = false;
|
|
302
|
+
for (const k of graph.keys()) {
|
|
303
|
+
const cl = importClosure(graph, k, budgets);
|
|
304
|
+
if (cl.truncated) truncated = true;
|
|
305
|
+
const h = crypto.createHash('sha256');
|
|
306
|
+
h.update(projectDigest + '\n' + k + '\0' + sha(String(fileContents[k] ?? '')) + '\n');
|
|
307
|
+
for (const d of cl.files) h.update(d + '\0' + sha(String(fileContents[d] ?? '')) + '\n');
|
|
308
|
+
digests.set(k, h.digest('hex'));
|
|
309
|
+
}
|
|
310
|
+
return { digests, graph, escaped, truncated, projectDigest };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function collectManifests(fileContents, depFileContents) {
|
|
314
|
+
const out = {};
|
|
315
|
+
for (const map of [fileContents, depFileContents]) {
|
|
316
|
+
for (const k of Object.keys(map || {})) {
|
|
317
|
+
if (isLanguageManifest(k) || isExplicitExport(k)) out[norm(k)] = map[k];
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A copy of `fileContents` whose Haskell/Nix sources carry their import-closure
|
|
325
|
+
* digest, for use as the checkpoint's per-file hash input. Other entries keep
|
|
326
|
+
* their exact value, and with no Haskell/Nix source the SAME object is returned,
|
|
327
|
+
* so scans of other languages are untouched.
|
|
328
|
+
*/
|
|
329
|
+
export function withLanguageClosure(fileContents, depFileContents = {}, opts = {}) {
|
|
330
|
+
const hasLang = Object.keys(fileContents || {}).some(isLanguageSource);
|
|
331
|
+
if (!hasLang) return fileContents;
|
|
332
|
+
const { digests } = computeLanguageDigests(fileContents, { manifests: collectManifests(fileContents, depFileContents), models: opts.models || {}, budgets: opts.budgets });
|
|
333
|
+
const out = { ...fileContents };
|
|
334
|
+
for (const [k, d] of digests) out[k] = `${fileContents[k]}\n\0closure:${d}`;
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Files whose cached results are stale after a change: the changed files, every
|
|
340
|
+
* file that transitively imports one, and (for a manifest, export or model
|
|
341
|
+
* change) every Haskell/Nix source.
|
|
342
|
+
*/
|
|
343
|
+
export function impactedFiles(fileContents, depFileContents, changedRels, opts = {}) {
|
|
344
|
+
const changed = new Set([...changedRels].map(norm));
|
|
345
|
+
const { graph } = buildImportGraph(fileContents, opts.budgets);
|
|
346
|
+
const all = [...graph.keys()];
|
|
347
|
+
const broad = [...changed].some((c) => isLanguageManifest(c) || isExplicitExport(c) || (opts.modelRels || []).includes(c));
|
|
348
|
+
if (broad) return all;
|
|
349
|
+
const out = new Set([...changed].filter((c) => graph.has(c)));
|
|
350
|
+
const rev = new Map();
|
|
351
|
+
for (const [k, ds] of graph) for (const d of ds) { if (!rev.has(d)) rev.set(d, []); rev.get(d).push(k); }
|
|
352
|
+
let frontier = [...out];
|
|
353
|
+
while (frontier.length) {
|
|
354
|
+
const next = [];
|
|
355
|
+
for (const f of frontier) for (const r of rev.get(f) || []) if (!out.has(r)) { out.add(r); next.push(r); }
|
|
356
|
+
frontier = next;
|
|
357
|
+
}
|
|
358
|
+
return [...out].sort();
|
|
359
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Language supply-chain pass for the scan engine. Nix fetch/build/cache trust findings are
|
|
2
|
+
// supply-chain entries, so they join the ordinary `supplyChain` bucket (and from there SCA
|
|
3
|
+
// policy, scan health and every report) instead of a side channel. Static only: nothing here
|
|
4
|
+
// evaluates Nix, fetches a URL or runs a builder.
|
|
5
|
+
|
|
6
|
+
import { resolvedHackageComponents, analyzeNixClosure } from './resolved-pass.js';
|
|
7
|
+
import { analyzeNixBuildTrust } from './nix-build-trust.js';
|
|
8
|
+
import { isLanguageExcludedPath, buildImportGraph } from './discovery.js';
|
|
9
|
+
import { analyzeHaskellSupply } from './haskell-supply.js';
|
|
10
|
+
import { analyzeNixScripts } from './nix-script-taint.js';
|
|
11
|
+
import { analyzeNixSecrets } from './nix-secrets.js';
|
|
12
|
+
import { analyzeNixAgents } from './nix-agents.js';
|
|
13
|
+
import { analyzeNixosHardening } from './nixos-hardening.js';
|
|
14
|
+
|
|
15
|
+
const isNixSource = (p) => /\.nix$/i.test(p);
|
|
16
|
+
const isFlakeLock = (p) => /(^|\/)flake\.lock$/i.test(p);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Record<string,string>} files rel path -> content (sources and manifests as the engine read them)
|
|
20
|
+
* @returns {{supplyChain: object[], gaps: object[], analyzed: number}}
|
|
21
|
+
*/
|
|
22
|
+
const NIXOS_OPTION = /(?:^|[\s;{])(?:services|networking|security|systemd|users|environment|programs|boot|virtualisation|nix)\.[A-Za-z_]|(?:^|[\s;{])(?:services|networking|security|systemd|users|environment|programs|boot|virtualisation)\s*=\s*\{/m;
|
|
23
|
+
|
|
24
|
+
/** NixOS entry points of a Nix file set: every configuration.nix, else every module nothing imports (capped). */
|
|
25
|
+
export function nixosEntries(nix, { max = 15 } = {}) {
|
|
26
|
+
const conf = Object.keys(nix).filter((p) => /(?:^|\/)configuration\.nix$/i.test(p)).sort().slice(0, 5);
|
|
27
|
+
const imported = new Set();
|
|
28
|
+
try { const { graph } = buildImportGraph(nix); for (const deps of graph.values()) for (const d of deps) imported.add(d); } catch { /* no import graph */ }
|
|
29
|
+
const standalone = Object.keys(nix).filter((p) => !conf.includes(p) && !imported.has(p) && !/(?:^|\/)flake\.nix$/i.test(p) && NIXOS_OPTION.test(nix[p])).sort().slice(0, max);
|
|
30
|
+
return [...conf, ...standalone];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function _baseSupplyChain(files = {}, opts = {}) {
|
|
34
|
+
const nix = {};
|
|
35
|
+
for (const [p, text] of Object.entries(files)) {
|
|
36
|
+
if (typeof text !== 'string') continue;
|
|
37
|
+
if (!(isNixSource(p) || isFlakeLock(p))) continue;
|
|
38
|
+
if (isLanguageExcludedPath(p)) continue;
|
|
39
|
+
nix[p] = text;
|
|
40
|
+
}
|
|
41
|
+
// Haskell: Hackage advisories and source/name policy (manifests, a freeze or a plan, and the .hs imports).
|
|
42
|
+
const hs = analyzeHaskellSupply(files, { root: opts.scanRoot || null, resolved: opts.resolved || null });
|
|
43
|
+
const sources = Object.keys(nix).filter(isNixSource);
|
|
44
|
+
if (!sources.length) {
|
|
45
|
+
if (!hs.components.length) return { supplyChain: [], gaps: [], analyzed: 0 };
|
|
46
|
+
return { supplyChain: hs.supplyChain, gaps: hs.gaps, analyzed: hs.components.length, haskell: hs };
|
|
47
|
+
}
|
|
48
|
+
// The effective configuration is decided from an ENTRY: configuration.nix when there is one, otherwise each NixOS module that
|
|
49
|
+
// nothing imports (a flake's host file under any name). With no entry at all only the file-local rules run.
|
|
50
|
+
const entries = nixosEntries(nix);
|
|
51
|
+
const reports = entries.length ? entries.map((entry) => analyzeNixBuildTrust({ files: nix, entry })) : [analyzeNixBuildTrust({ files: nix })];
|
|
52
|
+
const seenIds = new Set(); const merged = []; const gaps = [];
|
|
53
|
+
for (const r of reports) {
|
|
54
|
+
for (const f of r.findings) { const k = f.id || `${f.rule}|${f.file}|${f.line}`; if (!seenIds.has(k)) { seenIds.add(k); merged.push(f); } }
|
|
55
|
+
for (const g of r.gaps) gaps.push(g);
|
|
56
|
+
}
|
|
57
|
+
return { supplyChain: [...hs.supplyChain, ...merged], gaps: [...hs.gaps, ...gaps], analyzed: sources.length + hs.components.length, haskell: hs };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Findings (not supply-chain entries) from Nix: embedded-shell taint (NIX-003). Returns the findings plus the
|
|
62
|
+
* coverage gaps (unsupported shells, scripts built by expressions) so scan health can report them.
|
|
63
|
+
*/
|
|
64
|
+
export function analyzeLanguageFindings(files = {}) {
|
|
65
|
+
const nix = {};
|
|
66
|
+
for (const [p, text] of Object.entries(files)) {
|
|
67
|
+
if (typeof text !== 'string' || !isNixSource(p) || isLanguageExcludedPath(p)) continue;
|
|
68
|
+
nix[p] = text;
|
|
69
|
+
}
|
|
70
|
+
if (!Object.keys(nix).length) return { findings: [], gaps: [], analyzed: 0 };
|
|
71
|
+
const r = analyzeNixScripts({ files: nix });
|
|
72
|
+
// secret placement sees referenced data files (sopsFile / agenix file) as well as the Nix sources
|
|
73
|
+
const sec = analyzeNixSecrets({ files: { ...files, ...nix } });
|
|
74
|
+
let agents = { findings: [] };
|
|
75
|
+
try { agents = analyzeNixAgents(files); } catch { /* agent/MCP rules are best effort */ }
|
|
76
|
+
// NIX-004: effective-configuration hardening, judged per NixOS entry point (configuration.nix). A finding with no
|
|
77
|
+
// option source (a catalog default decided it) is anchored to the entry file at line 1 and says so, so it can be
|
|
78
|
+
// reported, attributed and suppressed instead of being lineless. The files that decided the value travel with it.
|
|
79
|
+
const hardening = [];
|
|
80
|
+
const effective = [];
|
|
81
|
+
// Entry points: every configuration.nix, plus NixOS modules NOTHING imports (a standalone module, a flake's host file under any
|
|
82
|
+
// name): its options are the only place their values are decided, and a file name is not what makes a configuration real.
|
|
83
|
+
const confEntries = Object.keys(nix).filter((p) => /(?:^|\/)configuration\.nix$/i.test(p)).sort().slice(0, 5);
|
|
84
|
+
const entryList = nixosEntries(nix);
|
|
85
|
+
for (const entry of entryList) {
|
|
86
|
+
try {
|
|
87
|
+
const h = analyzeNixosHardening({ entry, files: nix });
|
|
88
|
+
effective.push({ entry, completeness: h.reconciliation && h.reconciliation.completeness, unresolved: (h.reconciliation && h.reconciliation.unanalyzed) || [], truncated: [], target: h.target });
|
|
89
|
+
for (const f of h.findings) {
|
|
90
|
+
const ctl = [...new Set((f.evidence || []).flatMap((e) => (e.sources || []).map((x) => x.file)).filter(Boolean))];
|
|
91
|
+
const anchored = Number.isInteger(f.line) && f.file;
|
|
92
|
+
hardening.push({ ...f, file: anchored ? f.file : entry, line: anchored ? f.line : 1, locationKind: anchored ? 'option-source' : 'target-level', controlFiles: ctl, entry, entryKind: confEntries.includes(entry) ? 'configuration' : 'standalone-module', optionEvidence: f.evidence, option: f.evidence && f.evidence[0] ? f.evidence[0].option : null });
|
|
93
|
+
}
|
|
94
|
+
} catch (e) { r.gaps.push({ kind: 'hardening-failed', file: entry, reason: String((e && e.message) || e) }); }
|
|
95
|
+
}
|
|
96
|
+
// Two different rules of one family can land on the same line (a one-line configuration, or several defaults that
|
|
97
|
+
// anchor at the entry). The engine's per-(file, line, family) de-duplication must not merge them into one finding, so
|
|
98
|
+
// each carries a site key built from what makes it a different finding. The key also feeds the stable id.
|
|
99
|
+
const keyed = (list, parts) => list.map((f) => (f && f.siteKey ? f : { ...f, siteKey: parts(f).filter((x) => x !== null && x !== undefined && x !== '').join('|') }));
|
|
100
|
+
const out = [
|
|
101
|
+
...keyed(r.findings, (f) => [f.rule, f.attrPath, f.sink && f.sink.command, f.generatedLocation && `${f.generatedLocation.line}:${f.generatedLocation.startColumn}`]),
|
|
102
|
+
...keyed(sec.findings, (f) => [f.rule, f.source && f.source.detail, f.destination && f.destination.label]),
|
|
103
|
+
...agents.findings,
|
|
104
|
+
...keyed(hardening, (f) => [f.rule, f.subject]),
|
|
105
|
+
];
|
|
106
|
+
return { findings: out, gaps: [...r.gaps, ...sec.gaps], analyzed: r.analyzed, effectiveConfigs: effective };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The language supply chain: declared and resolved Hackage dependencies, Nix inputs and build trust, and (when the project exported
|
|
111
|
+
* one) the Nix closure matched against an advisory snapshot. The resolved data comes from EXPLICIT export files only.
|
|
112
|
+
*/
|
|
113
|
+
export function analyzeLanguageSupplyChain(files = {}, opts = {}) {
|
|
114
|
+
let resolved = null; let closure = null;
|
|
115
|
+
try { resolved = resolvedHackageComponents(files); } catch { resolved = null; }
|
|
116
|
+
try { closure = analyzeNixClosure(files, { scanRoot: opts.scanRoot || null }); } catch (e) { closure = { findings: [], gaps: [{ kind: 'closure-analysis-failed', detail: String((e && e.message) || e).slice(0, 160) }], statuses: [], sources: [], status: 'failed' }; }
|
|
117
|
+
const base = _baseSupplyChain(files, { ...opts, resolved });
|
|
118
|
+
if (!closure) return resolved && resolved.summary ? { ...base, resolved: resolved.summary } : base;
|
|
119
|
+
return { ...base, supplyChain: [...base.supplyChain, ...closure.findings], gaps: [...(base.gaps || []), ...closure.gaps], analyzed: (base.analyzed || 0) + closure.statuses.length, resolved: resolved && resolved.summary, nixClosure: closure };
|
|
120
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The verified-remediation lifecycle shared by the language fixers (Haskell, Nix).
|
|
2
|
+
//
|
|
3
|
+
// A fix is APPLIED only after every gate passes:
|
|
4
|
+
// syntax the patched file parses with no more errors than before
|
|
5
|
+
// behavior an optional caller-supplied check that nothing unrelated changed
|
|
6
|
+
// rescan the original finding is gone from a rescan of the patched tree AND nothing at medium or above
|
|
7
|
+
// is new (a syntactically correct edit that the effective configuration shadows leaves the
|
|
8
|
+
// finding in place, so it is blocked rather than called fixed)
|
|
9
|
+
// compile strictly opt-in; the default path never starts a compiler or evaluator
|
|
10
|
+
// Preview never writes. Apply writes a backup first and refuses a file that changed since planning.
|
|
11
|
+
// Undo restores the backup byte for byte.
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { statePath } from '../posture/state-dir.js';
|
|
16
|
+
import { maybeEncryptForWrite, maybeDecryptForRead } from '../posture/encryption-provider.js';
|
|
17
|
+
import { recordExternalFix, markRevertedByBackup } from '../posture/fix-history.js';
|
|
18
|
+
|
|
19
|
+
export const RANK = Object.freeze({ critical: 4, high: 3, medium: 2, low: 1, info: 0 });
|
|
20
|
+
const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);
|
|
21
|
+
|
|
22
|
+
export function unifiedDiff(file, a, b) {
|
|
23
|
+
const x = a.split('\n'), y = b.split('\n'); const out = [`--- a/${file}`, `+++ b/${file}`];
|
|
24
|
+
let i = 0; while (i < x.length && i < y.length && x[i] === y[i]) i++;
|
|
25
|
+
let ea = x.length, eb = y.length; while (ea > i && eb > i && x[ea - 1] === y[eb - 1]) { ea--; eb--; }
|
|
26
|
+
out.push(`@@ -${i + 1},${ea - i} +${i + 1},${eb - i} @@`);
|
|
27
|
+
for (let k = i; k < ea; k++) out.push(`-${x[k]}`);
|
|
28
|
+
for (let k = i; k < eb; k++) out.push(`+${y[k]}`);
|
|
29
|
+
return out.join('\n');
|
|
30
|
+
}
|
|
31
|
+
const safeRel = (root, rel) => { const p = join(root, rel); if (!p.startsWith(join(root) + '/')) throw new Error(`path escapes the root: ${rel}`); return p; };
|
|
32
|
+
|
|
33
|
+
export function writeWithBackup(root, rel, before, after) {
|
|
34
|
+
const target = safeRel(root, rel);
|
|
35
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
36
|
+
const dir = statePath(root, 'fix-backups', id);
|
|
37
|
+
const disk = existsSync(target) ? readFileSync(target, 'utf8') : null;
|
|
38
|
+
if (disk !== before) throw new Error('the file changed on disk since the fix was planned; refusing to overwrite');
|
|
39
|
+
// The pre-image is customer source: it follows the project's encryption policy for confidential state, and a policy
|
|
40
|
+
// that REQUIRES encryption with no working provider refuses the fix before anything is written.
|
|
41
|
+
const enc = maybeEncryptForWrite(root, 'fix-backups', before);
|
|
42
|
+
if (!enc.ok) throw new Error(enc.reason);
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
writeFileSync(join(dir, 'original'), enc.content);
|
|
45
|
+
writeFileSync(join(dir, 'meta.json'), JSON.stringify({ file: rel, appliedAt: new Date().toISOString(), sizeBefore: before.length, sizeAfter: after.length }));
|
|
46
|
+
writeFileSync(target, after);
|
|
47
|
+
return { id, dir };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function undoFix(root, backupId) {
|
|
51
|
+
const _afterUndo = () => { try { markRevertedByBackup(root, backupId); } catch { /* history is best effort */ } };
|
|
52
|
+
const dir = statePath(root, 'fix-backups', backupId);
|
|
53
|
+
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
|
|
54
|
+
const target = safeRel(root, meta.file);
|
|
55
|
+
writeFileSync(target, maybeDecryptForRead(readFileSync(join(dir, 'original'), 'utf8')));
|
|
56
|
+
_afterUndo();
|
|
57
|
+
return { ok: true, file: meta.file };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {{plan: {file:string, before:string, after:string, label?:string}, files: Record<string,string>, matchKey: (f:object)=>string,
|
|
62
|
+
* finding: object, rescan: (files:object)=>Promise<object[]>, syntax: (plan)=>{ok:boolean, detail?:string},
|
|
63
|
+
* behaviorCheck?: Function, compile?: (patched)=>{ran:boolean, ok:boolean|null, detail:string}, requireCompile?: boolean,
|
|
64
|
+
* apply?: boolean, root?: string, extraWrites?: Array<{file:string,before:string,after:string}>}} o
|
|
65
|
+
*/
|
|
66
|
+
/** A repository-relative path that stays inside the root: not absolute, no `..` segment, no NUL, not empty. */
|
|
67
|
+
export function isRootRelativePath(rel) {
|
|
68
|
+
if (typeof rel !== 'string' || !rel || rel.includes('\0')) return false;
|
|
69
|
+
const norm = rel.replace(/\\/g, '/');
|
|
70
|
+
if (norm.startsWith('/') || /^[A-Za-z]:\//.test(norm)) return false;
|
|
71
|
+
return !norm.split('/').some((seg) => seg === '..');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runFixLifecycle(o) {
|
|
75
|
+
const { plan, files, finding } = o;
|
|
76
|
+
const gates = {};
|
|
77
|
+
// A patch may only touch a file inside the project: a target that escapes the root is refused before anything else runs.
|
|
78
|
+
gates.path = { ok: isRootRelativePath(plan.file), detail: plan.file };
|
|
79
|
+
if (!gates.path.ok) return { status: 'blocked', applied: false, reason: `path-escape: ${String(plan.file).slice(0, 120)} is outside the project root`, plan, gates, label: plan.label };
|
|
80
|
+
gates.syntax = o.syntax(plan);
|
|
81
|
+
const patched = { ...files, [plan.file]: plan.after };
|
|
82
|
+
const blocked = (why) => ({ status: 'blocked', applied: false, reason: why, plan, gates, label: plan.label });
|
|
83
|
+
if (!gates.syntax.ok) return blocked(`syntax: ${gates.syntax.detail}`);
|
|
84
|
+
if (o.behaviorCheck) { gates.behavior = o.behaviorCheck(plan); if (gates.behavior && gates.behavior.ok === false) return blocked(`behavior: ${gates.behavior.detail || 'unrelated behavior change'}`); }
|
|
85
|
+
let before, after;
|
|
86
|
+
try { before = await o.rescan(files); after = await o.rescan(patched); } catch (e) { return blocked(`rescan failed: ${e.message}`); }
|
|
87
|
+
const key = o.matchKey;
|
|
88
|
+
const b = new Map(), a = new Map();
|
|
89
|
+
for (const f of before) bump(b, key(f));
|
|
90
|
+
for (const f of after) bump(a, key(f));
|
|
91
|
+
const k = key(finding);
|
|
92
|
+
const gone = (a.get(k) || 0) < (b.get(k) || 0);
|
|
93
|
+
const added = after.filter((f) => RANK[f.severity] >= RANK.medium && (a.get(key(f)) || 0) > (b.get(key(f)) || 0));
|
|
94
|
+
gates.rescan = { ok: gone && added.length === 0, originalGone: gone, newMediumOrHigher: [...new Set(added.map((f) => `${f.cwe || f.vuln}@${f.file}:${f.line}`))] };
|
|
95
|
+
if (!gone) return blocked('the original finding is still reported after the patch');
|
|
96
|
+
if (added.length) return blocked(`the patch introduces ${added.length} new medium-or-higher finding(s)`);
|
|
97
|
+
// Optional controlled-witness gate (X-009): the hook returns judgeFixWitness(before, after). A patch that STILL
|
|
98
|
+
// reproduces the effect is blocked; a witness that could not be judged (unsupported, invalid, unavailable) verifies
|
|
99
|
+
// nothing and, unless `requireWitness` is set, does not block (it is recorded as not-verified).
|
|
100
|
+
if (o.witness) {
|
|
101
|
+
gates.witness = o.witness(plan, files, patched) || { ok: false, verified: false, reason: 'no witness result' };
|
|
102
|
+
if (gates.witness.stillReproduces) return blocked(`witness: ${gates.witness.reason}`);
|
|
103
|
+
if (o.requireWitness && !gates.witness.verified) return blocked(`witness verification required but ${gates.witness.reason}`);
|
|
104
|
+
}
|
|
105
|
+
gates.compile = o.compile ? o.compile(patched) : { ran: false, ok: null, detail: 'not requested (default static scans never start a compiler or evaluator)' };
|
|
106
|
+
if (gates.compile.ran && !gates.compile.ok) return blocked(`compile: ${gates.compile.detail}`);
|
|
107
|
+
if (o.requireCompile && !gates.compile.ran) return blocked(`compile verification required but ${gates.compile.detail}`);
|
|
108
|
+
const verified = { syntax: true, rescan: true, compile: gates.compile.ran ? 'passed' : 'not-run', witness: gates.witness ? (gates.witness.verified ? 'passed' : 'not-verified') : 'not-run' };
|
|
109
|
+
const res = { status: 'verified', applied: false, plan, gates, label: plan.label, verified, preview: unifiedDiff(plan.file, plan.before, plan.after) };
|
|
110
|
+
if (o.apply) {
|
|
111
|
+
if (!o.root) return blocked('apply requires a root directory');
|
|
112
|
+
res.backup = writeWithBackup(o.root, plan.file, plan.before, plan.after);
|
|
113
|
+
res.applied = true; res.status = 'applied';
|
|
114
|
+
// One ledger: the same fix-history `agentic-security undo` reads, with the tier and the gates that passed.
|
|
115
|
+
try {
|
|
116
|
+
res.history = await recordExternalFix({ scanRoot: o.root, file: plan.file, originalContent: plan.before, newContent: plan.after, findingId: finding.id || `${finding.file}:${finding.line}`, ruleId: finding.rule || null, vuln: finding.vuln || null, stableId: finding.stableId || null, fixLabel: plan.label || null, verification: verified, backup: res.backup, findingProvenance: finding.findingProvenance || null });
|
|
117
|
+
} catch { res.history = null; }
|
|
118
|
+
}
|
|
119
|
+
return res;
|
|
120
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Adapter that plugs the Haskell parser into the language pipeline contracts
|
|
2
|
+
// (CORE-003), so parse outcomes reach scan health instead of staying inside the
|
|
3
|
+
// parser. A missing or corrupt grammar becomes `missing_grammar`, a budget bound
|
|
4
|
+
// becomes `timed_out` (deadline) or `unresolved` (size/depth), and syntax errors
|
|
5
|
+
// and opaque boundaries become `unresolved-branch` outcomes. Findings from other
|
|
6
|
+
// languages are never touched.
|
|
7
|
+
|
|
8
|
+
import { isRegisteredLanguageProducer, registerLanguageProducer } from './contracts.js';
|
|
9
|
+
import { parseHaskell } from './haskell-parser.js';
|
|
10
|
+
import { loadHaskellGrammar } from './haskell-grammar.js';
|
|
11
|
+
|
|
12
|
+
export const HASKELL_PARSE_PRODUCER = 'language:haskell-parse';
|
|
13
|
+
|
|
14
|
+
// Boundaries whose content the parser could not see. cpp and ffi are disclosed on
|
|
15
|
+
// the parse result but do not make the file unresolved: both CPP branches are
|
|
16
|
+
// parsed and a foreign declaration's own Haskell signature is.
|
|
17
|
+
const OPAQUE_BOUNDARIES = new Set(['th-splice', 'th-quote', 'th-name-quote', 'th-top-level-splice', 'quasiquote', 'hsc', 'generated']);
|
|
18
|
+
|
|
19
|
+
export function ensureHaskellParseProducer() {
|
|
20
|
+
if (!isRegisteredLanguageProducer(HASKELL_PARSE_PRODUCER)) {
|
|
21
|
+
registerLanguageProducer({ id: HASKELL_PARSE_PRODUCER, language: 'haskell', capability: 'parse', evidenceKinds: ['source'], version: '1' });
|
|
22
|
+
}
|
|
23
|
+
return HASKELL_PARSE_PRODUCER;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{grammarSource?:Function, budgets?:object, mode?:string, onParse?:(file:string, parse:object)=>void}} [opts]
|
|
28
|
+
*/
|
|
29
|
+
export function createHaskellAdapter(opts = {}) {
|
|
30
|
+
ensureHaskellParseProducer();
|
|
31
|
+
return {
|
|
32
|
+
id: HASKELL_PARSE_PRODUCER,
|
|
33
|
+
language: 'haskell',
|
|
34
|
+
hasGrammar: () => loadHaskellGrammar({ grammarSource: opts.grammarSource }).available,
|
|
35
|
+
analyze(file, content) {
|
|
36
|
+
const parse = parseHaskell(content, { file, grammarSource: opts.grammarSource, budgets: opts.budgets, mode: opts.mode });
|
|
37
|
+
if (typeof opts.onParse === 'function') opts.onParse(file, parse);
|
|
38
|
+
if (parse.status === 'budget_exceeded' && parse.budget && parse.budget.name === 'deadlineMs') {
|
|
39
|
+
throw Object.assign(new Error(`Haskell parser deadline exceeded for ${file}`), { code: 'LANG_TIMEOUT' });
|
|
40
|
+
}
|
|
41
|
+
const unresolved = [];
|
|
42
|
+
if (parse.status === 'budget_exceeded') unresolved.push({ line: 1, reason: `parser budget exceeded: ${parse.budget.name}` });
|
|
43
|
+
else if (parse.status === 'failed') throw new Error(parse.errors[0] ? parse.errors[0].detail : 'Haskell parse failed');
|
|
44
|
+
else {
|
|
45
|
+
for (const e of parse.errors) unresolved.push({ line: e.span ? e.span.startLine : 1, reason: `syntax-error: ${e.kind}` });
|
|
46
|
+
for (const b of parse.boundaries) if (OPAQUE_BOUNDARIES.has(b.kind)) unresolved.push({ line: b.span.startLine, reason: `opaque-boundary: ${b.kind}` });
|
|
47
|
+
}
|
|
48
|
+
return { findings: [], unresolved, parse };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|