@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
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { execFileSync } from 'node:child_process';
|
|
15
15
|
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
16
|
+
import { providerInfo, scanLanguageSecretConcat, scanDependencyUrlCredentials } from '../language/secrets.js';
|
|
16
17
|
|
|
17
18
|
// Pull the post-image (added) lines out of a unified diff: lines starting with
|
|
18
19
|
// a single '+' (not the '+++' file header). Returns reconstructed text.
|
|
@@ -24,25 +25,42 @@ export function extractAddedLines(diffText) {
|
|
|
24
25
|
return out.join('\n');
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// Split a unified diff into per-file sections with the POST-IMAGE line number of every added line, so a
|
|
29
|
+
// finding can name the file and line it was committed at (the combined text alone loses both).
|
|
30
|
+
export function splitDiffByFile(diffText) {
|
|
31
|
+
const files = [];
|
|
32
|
+
let cur = null; let next = 0;
|
|
33
|
+
for (const line of String(diffText || '').split('\n')) {
|
|
34
|
+
const h = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
35
|
+
if (h) { cur = { path: h[2], added: [] }; files.push(cur); next = 0; continue; }
|
|
36
|
+
if (!cur) continue;
|
|
37
|
+
const hh = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
38
|
+
if (hh) { next = parseInt(hh[1], 10); continue; }
|
|
39
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
40
|
+
if (line.startsWith('+')) { cur.added.push({ text: line.slice(1), line: next }); next++; }
|
|
41
|
+
else if (!line.startsWith('-') && !line.startsWith('\\')) next++;
|
|
42
|
+
}
|
|
43
|
+
return files;
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
// Pure: run the injected credential detector over a commit's added lines.
|
|
28
|
-
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[].
|
|
29
|
-
|
|
47
|
+
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[]. `extra` are additional detectors
|
|
48
|
+
// ((path, text) => Finding[]) run against each file's added lines WITH the real path, so extension-gated
|
|
49
|
+
// detectors (Haskell/Nix split-secret) apply to history too.
|
|
50
|
+
export function scanHistoryDiff(diffText, commit, detectFn, extra = []) {
|
|
30
51
|
if (typeof detectFn !== 'function') return [];
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
...f,
|
|
52
|
+
const sections = splitDiffByFile(diffText);
|
|
53
|
+
const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
|
|
54
|
+
const wrap = (f, sec, addedLines) => {
|
|
55
|
+
const hit = Number.isInteger(f.line) && f.line >= 1 ? addedLines[f.line - 1] : null;
|
|
56
|
+
const prov = f.rotation ? {} : providerInfo(f._rawProviderValue || '');
|
|
57
|
+
const out = {
|
|
58
|
+
...f, ...prov,
|
|
39
59
|
id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
|
|
40
|
-
file: `git-history@${commit}`,
|
|
41
|
-
line:
|
|
42
|
-
commit,
|
|
43
|
-
_historical: true,
|
|
60
|
+
file: `git-history@${commit}`, line: 0, commit, _historical: true,
|
|
61
|
+
...(sec ? { sourceFile: sec.path, sourceLine: hit ? hit.line : null } : {}),
|
|
44
62
|
vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
|
|
45
|
-
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
63
|
+
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}${sec ? ` (${sec.path}${hit ? `:${hit.line}` : ''})` : ''}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
46
64
|
remediation,
|
|
47
65
|
// report/index.js's _remediationOf checks `.fix` before `.remediation`
|
|
48
66
|
// — the underlying detector already set `.fix` to a generic "remove
|
|
@@ -51,15 +69,41 @@ export function scanHistoryDiff(diffText, commit, detectFn) {
|
|
|
51
69
|
// insufficient") in every report format.
|
|
52
70
|
fix: remediation,
|
|
53
71
|
};
|
|
54
|
-
|
|
72
|
+
delete out._rawProviderValue;
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
75
|
+
const results = [];
|
|
76
|
+
if (!sections.length) {
|
|
77
|
+
const added = extractAddedLines(diffText);
|
|
78
|
+
if (!added.trim()) return [];
|
|
79
|
+
let findings = [];
|
|
80
|
+
try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
|
|
81
|
+
return findings.map((f) => wrap(f, null, []));
|
|
82
|
+
}
|
|
83
|
+
for (const sec of sections) {
|
|
84
|
+
if (!sec.added.length) continue;
|
|
85
|
+
const text = sec.added.map((a) => a.text).join('\n');
|
|
86
|
+
if (!text.trim()) continue;
|
|
87
|
+
let findings = [];
|
|
88
|
+
try { findings = detectFn(`git-history@${commit}`, text) || []; } catch { findings = []; }
|
|
89
|
+
for (const f of findings) results.push(wrap(f, sec, sec.added));
|
|
90
|
+
for (const ex of extra || []) {
|
|
91
|
+
let more = [];
|
|
92
|
+
try { more = ex(sec.path, text) || []; } catch { more = []; }
|
|
93
|
+
for (const f of more) results.push(wrap(f, sec, sec.added));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
55
97
|
}
|
|
56
98
|
|
|
99
|
+
const defaultExtra = [scanLanguageSecretConcat, scanDependencyUrlCredentials];
|
|
100
|
+
|
|
57
101
|
/**
|
|
58
102
|
* Sweep up to `maxCommits` of recent history for secrets. Best-effort: returns
|
|
59
103
|
* [] when `scanRoot` is not a git repo or git is unavailable. Dedups a secret
|
|
60
104
|
* that recurs across commits to its earliest sighting.
|
|
61
105
|
*/
|
|
62
|
-
export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000 } = {}) {
|
|
106
|
+
export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000, extraDetectors = null } = {}) {
|
|
63
107
|
if (!scanRoot || typeof detectFn !== 'function') return [];
|
|
64
108
|
let out;
|
|
65
109
|
try {
|
|
@@ -78,7 +122,7 @@ export function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs
|
|
|
78
122
|
const seen = new Set();
|
|
79
123
|
for (let i = 1; i < parts.length; i += 2) {
|
|
80
124
|
const sha = (parts[i] || '').slice(0, 12);
|
|
81
|
-
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn)) {
|
|
125
|
+
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn, extraDetectors || defaultExtra)) {
|
|
82
126
|
const key = `${f.vuln}:${(f.snippet || f.match || '').slice(0, 40)}`;
|
|
83
127
|
if (seen.has(key)) continue;
|
|
84
128
|
seen.add(key);
|
package/src/posture/stable-id.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// that need stability use `stableId`.
|
|
11
11
|
|
|
12
12
|
import * as crypto from 'node:crypto';
|
|
13
|
+
import { haskellComments, nixComments } from '../language/pragma.js';
|
|
13
14
|
|
|
14
15
|
function normalizeSnippet(s) {
|
|
15
16
|
if (!s || typeof s !== 'string') return '';
|
|
@@ -53,12 +54,30 @@ function ruleId(f) {
|
|
|
53
54
|
return `vuln:${(f.vuln || '').slice(0, 40)}`;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// Haskell and Nix findings are identified by their code, not by how it is laid out: comments, blank lines,
|
|
58
|
+
// indentation and spacing between tokens never rotate an id. (The generic normaliser keeps spacing next to
|
|
59
|
+
// punctuation, so `f (x)` and `f ( x )` would differ.) Only whitespace and comments are removed; every token stays.
|
|
60
|
+
function normalizeLanguageSnippet(file, s) {
|
|
61
|
+
if (!s || typeof s !== 'string') return '';
|
|
62
|
+
const isNix = /\.nix$/i.test(String(file || ''));
|
|
63
|
+
const spans = isNix ? nixComments(s) : haskellComments(s);
|
|
64
|
+
let out = ''; let at = 0;
|
|
65
|
+
for (const c of spans) { out += s.slice(at, c.start); at = c.end; }
|
|
66
|
+
out += s.slice(at);
|
|
67
|
+
return out.toLowerCase().replace(/"(?:[^"\\]|\\.)*"/g, '"_S_"').replace(/\s+/g, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
export function computeStableId(f) {
|
|
57
71
|
const rid = ruleId(f);
|
|
58
|
-
const
|
|
72
|
+
const isLang = /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(String(f.file || f.sink?.file || ''));
|
|
73
|
+
const sink = isLang
|
|
74
|
+
? normalizeLanguageSnippet(f.file || f.sink?.file, f.sink?.snippet || f.snippet || '').slice(0, 200)
|
|
75
|
+
: normalizeSnippet(f.sink?.snippet || f.snippet || '').slice(0, 200);
|
|
59
76
|
const shape = pathShape(f);
|
|
60
77
|
const fileHint = basenameLike(f.file || f.sink?.file);
|
|
61
|
-
|
|
78
|
+
// Haskell taint findings carry a site key (a fingerprint of the sink argument), so two sinks of one rule
|
|
79
|
+
// on one line keep distinct ids. Absent for every other finding, so their ids are unchanged.
|
|
80
|
+
const material = f.siteKey ? `${rid}|${sink}|${shape}|${fileHint}|${f.siteKey}` : `${rid}|${sink}|${shape}|${fileHint}`;
|
|
62
81
|
return crypto.createHash('sha256').update(material).digest('hex').slice(0, 16);
|
|
63
82
|
}
|
|
64
83
|
|
package/src/posture/state-dir.js
CHANGED
|
@@ -33,8 +33,11 @@ const PROJECT_MARKERS = [
|
|
|
33
33
|
'build.gradle',
|
|
34
34
|
'composer.json',
|
|
35
35
|
'Gemfile',
|
|
36
|
+
// Haskell and Nix projects: without these a Cabal, Stack or flake project never got a state directory at all.
|
|
37
|
+
'cabal.project', 'stack.yaml', 'package.yaml', 'flake.nix', 'configuration.nix', 'default.nix', 'shell.nix',
|
|
36
38
|
'.agentic-security',
|
|
37
39
|
];
|
|
40
|
+
const PROJECT_MARKER_SUFFIX = /\.cabal$/;
|
|
38
41
|
|
|
39
42
|
function _findProjectRoot(startDir) {
|
|
40
43
|
let dir = path.resolve(startDir);
|
|
@@ -81,8 +84,23 @@ export function statePath(scanRoot, ...parts) {
|
|
|
81
84
|
// Safety check: refuse to create .agentic-security/ unless the parent
|
|
82
85
|
// directory has at least one project marker. Prevents littering when
|
|
83
86
|
// resolution falls through to a non-project directory.
|
|
87
|
+
// A state directory that is a SYMLINK is never safe: a hostile repository could point `.agentic-security` at a directory elsewhere
|
|
88
|
+
// and have every evidence file written (or overwritten) through it. The state root, and any state directory below it, must be a real
|
|
89
|
+
// directory (or not exist yet); a symlinked file inside it is refused by safeWriteState below.
|
|
90
|
+
function _viaSymlink(dir) {
|
|
91
|
+
const segments = path.resolve(dir).split(path.sep);
|
|
92
|
+
const idx = segments.lastIndexOf(STATE_DIR_NAME);
|
|
93
|
+
if (idx < 0) return false;
|
|
94
|
+
for (let i = idx; i < segments.length; i++) {
|
|
95
|
+
const prefix = segments.slice(0, i + 1).join(path.sep) || path.sep;
|
|
96
|
+
try { if (fs.lstatSync(prefix).isSymbolicLink()) return true; } catch { return false; /* does not exist yet: nothing to follow */ }
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
export function isSafeStateDir(dir) {
|
|
85
102
|
if (!dir) return false;
|
|
103
|
+
if (_viaSymlink(dir)) return false;
|
|
86
104
|
const parent = path.dirname(dir);
|
|
87
105
|
for (const m of PROJECT_MARKERS) {
|
|
88
106
|
if (m === '.agentic-security') continue; // would be circular
|
|
@@ -90,6 +108,7 @@ export function isSafeStateDir(dir) {
|
|
|
90
108
|
if (fs.existsSync(path.join(parent, m))) return true;
|
|
91
109
|
} catch { /* ignore */ }
|
|
92
110
|
}
|
|
111
|
+
try { if (fs.readdirSync(parent).some((n) => PROJECT_MARKER_SUFFIX.test(n))) return true; } catch { /* ignore */ }
|
|
93
112
|
// A directory NESTED inside an already-valid state root is safe too.
|
|
94
113
|
//
|
|
95
114
|
// Without this the check only ever accepted `<project>/.agentic-security`
|
|
@@ -234,6 +253,8 @@ export function safeWriteState(filePath, content, { category } = {}) {
|
|
|
234
253
|
}
|
|
235
254
|
try {
|
|
236
255
|
fs.mkdirSync(dir, { recursive: true });
|
|
256
|
+
// never write THROUGH a symlink: an attacker-placed link at the file's own path would redirect the write elsewhere
|
|
257
|
+
try { if (fs.lstatSync(filePath).isSymbolicLink()) return false; } catch { /* not there yet */ }
|
|
237
258
|
fs.writeFileSync(filePath, content);
|
|
238
259
|
return true;
|
|
239
260
|
} catch {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// .agentic-security/threat-model.md (human-readable).
|
|
24
24
|
|
|
25
25
|
import * as fs from 'node:fs';
|
|
26
|
-
import { stateDir, stateWritesEnabled } from './state-dir.js';
|
|
26
|
+
import { stateDir, stateWritesEnabled, safeWriteState } from './state-dir.js';
|
|
27
27
|
import * as path from 'node:path';
|
|
28
28
|
|
|
29
29
|
// STRIDE category descriptors
|
|
@@ -215,9 +215,9 @@ export function persistThreatModel(scanRoot, model) {
|
|
|
215
215
|
// NON_MUTATING_SCAN_PRD S1 — a scan must not modify the tree it scans.
|
|
216
216
|
if (!stateWritesEnabled()) return;
|
|
217
217
|
const dir = stateDir(scanRoot);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
// through the state seam: a symlinked state directory or file is refused, never written through
|
|
219
|
+
safeWriteState(path.join(dir, 'threat-model.json'), JSON.stringify(model, null, 2));
|
|
220
|
+
safeWriteState(path.join(dir, 'threat-model.md'), renderMarkdown(model));
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
function renderMarkdown(model) {
|
package/src/posture/triage.js
CHANGED
|
@@ -29,13 +29,24 @@ function _save(scanRoot, data) {
|
|
|
29
29
|
|
|
30
30
|
// Sync the triage store with the latest scan: new findings become 'open',
|
|
31
31
|
// findings no longer surfaced become 'fixed' (with a transition entry).
|
|
32
|
-
export function syncWithScan(scanRoot, findings) {
|
|
32
|
+
export function syncWithScan(scanRoot, findings, opts = {}) {
|
|
33
33
|
const data = loadTriage(scanRoot);
|
|
34
34
|
const now = new Date().toISOString();
|
|
35
35
|
const seen = new Set();
|
|
36
|
+
// A finding that is no longer reported is only "fixed" when the scan that stopped reporting it was COMPLETE for the
|
|
37
|
+
// code it lives in. If its analysis was partial (a parser failed, a required analyzer was off), its absence is
|
|
38
|
+
// unverified, so it stays open and is marked `unverified` rather than silently closed.
|
|
39
|
+
const health = opts && opts.scanHealth;
|
|
40
|
+
const incomplete = !!(health && health.status && health.status !== 'complete');
|
|
36
41
|
for (const f of findings) {
|
|
37
42
|
const id = f.id || `${f.file}:${f.line}:${f.vuln}`;
|
|
38
43
|
seen.add(id);
|
|
44
|
+
const existing = data.findings[id];
|
|
45
|
+
// A finding that was closed and is reported again is a regression: reopen it, never leave it 'fixed'.
|
|
46
|
+
if (existing && existing.state === 'fixed') {
|
|
47
|
+
existing.state = 'open'; existing.reopened_at = now; delete existing.fixed_at; delete existing.unverified;
|
|
48
|
+
data.transitions.push({ id, from: 'fixed', to: 'open', at: now, automatic: true, reason: 'reported again by a later scan' });
|
|
49
|
+
} else if (existing && existing.unverified) { delete existing.unverified; }
|
|
39
50
|
if (!data.findings[id]) {
|
|
40
51
|
data.findings[id] = {
|
|
41
52
|
id,
|
|
@@ -56,6 +67,8 @@ export function syncWithScan(scanRoot, findings) {
|
|
|
56
67
|
ecosystem: f.ecosystem || null,
|
|
57
68
|
osvId: f.osvId || null,
|
|
58
69
|
cveAliases: Array.isArray(f.cveAliases) ? f.cveAliases : [],
|
|
70
|
+
language: f.language || null,
|
|
71
|
+
rule: f.rule || null,
|
|
59
72
|
};
|
|
60
73
|
data.transitions.push({ id, from: null, to: 'open', at: now });
|
|
61
74
|
}
|
|
@@ -65,6 +78,10 @@ export function syncWithScan(scanRoot, findings) {
|
|
|
65
78
|
const cur = data.findings[id];
|
|
66
79
|
if (seen.has(id)) continue;
|
|
67
80
|
if (cur.state === 'fixed' || cur.state === 'wont-fix' || cur.state === 'false-positive') continue;
|
|
81
|
+
if (incomplete) {
|
|
82
|
+
if (!cur.unverified) { cur.unverified = { since: now, reason: 'not reported, but the scan was not complete' }; data.transitions.push({ id, from: cur.state, to: cur.state, at: now, automatic: true, unverified: true, reason: 'not reported by an incomplete scan' }); }
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
68
85
|
cur.state = 'fixed';
|
|
69
86
|
cur.fixed_at = now;
|
|
70
87
|
data.transitions.push({ id, from: 'open', to: 'fixed', at: now, automatic: true });
|
|
@@ -24,6 +24,7 @@ import * as fsSync from 'node:fs';
|
|
|
24
24
|
import * as path from 'node:path';
|
|
25
25
|
|
|
26
26
|
import { stateDir, statePath, stateWritesEnabled } from './state-dir.js';
|
|
27
|
+
import { isLanguageSource, isLanguageManifest, isLanguageExcludedPath, isExplicitExport, impactedFiles } from '../language/discovery.js';
|
|
27
28
|
const STATUS_MD = 'watch-status.md';
|
|
28
29
|
const STATUS_JSON = 'watch-status.json';
|
|
29
30
|
const DEBOUNCE_MS = 350;
|
|
@@ -33,10 +34,35 @@ const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp
|
|
|
33
34
|
const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
|
|
34
35
|
|
|
35
36
|
function _isScanable(rel) {
|
|
36
|
-
if (!rel
|
|
37
|
+
if (!rel) return false;
|
|
38
|
+
// An explicit export is evidence even though it lives in a build directory.
|
|
39
|
+
if (isExplicitExport(rel)) return true;
|
|
40
|
+
if (IGNORE_DIR_RE.test(rel)) return false;
|
|
41
|
+
if (isLanguageSource(rel) || isLanguageManifest(rel)) return !isLanguageExcludedPath(rel);
|
|
37
42
|
return SCAN_EXT_RE.test(rel);
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Widen a batch of changed absolute paths to every file whose results the
|
|
47
|
+
* change can stale: dependents through imports, and all Haskell/Nix sources for
|
|
48
|
+
* a manifest, lockfile or export change. Batches with no Haskell/Nix path are
|
|
49
|
+
* returned untouched, so other languages never pay for a tree read.
|
|
50
|
+
*/
|
|
51
|
+
async function expandChangedBatch(scanRoot, batch) {
|
|
52
|
+
const rels = batch.map((p) => path.relative(scanRoot, p).split(path.sep).join('/'));
|
|
53
|
+
if (!rels.some((r) => isLanguageSource(r) || isLanguageManifest(r) || isExplicitExport(r))) return batch;
|
|
54
|
+
try {
|
|
55
|
+
const { readTree } = await import('../runScan.js');
|
|
56
|
+
const { fileContents, depFileContents } = await readTree(scanRoot);
|
|
57
|
+
const hit = impactedFiles(fileContents, depFileContents, rels);
|
|
58
|
+
const out = new Set(batch);
|
|
59
|
+
for (const r of hit) out.add(path.join(scanRoot, r));
|
|
60
|
+
return [...out];
|
|
61
|
+
} catch {
|
|
62
|
+
return batch;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
function _readJsonSafe(fp) {
|
|
41
67
|
try { return JSON.parse(fsSync.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
42
68
|
}
|
|
@@ -144,7 +170,12 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
|
144
170
|
if (pending.size > MAX_BURST) { pending.clear(); return; }
|
|
145
171
|
const batch = Array.from(pending);
|
|
146
172
|
pending.clear();
|
|
147
|
-
|
|
173
|
+
const needsExpansion = batch.some((p) => {
|
|
174
|
+
const r = path.relative(scanRoot, p).split(path.sep).join('/');
|
|
175
|
+
return isLanguageSource(r) || isLanguageManifest(r) || isExplicitExport(r);
|
|
176
|
+
});
|
|
177
|
+
if (!needsExpansion) { try { onChange(batch); } catch {} return; }
|
|
178
|
+
expandChangedBatch(scanRoot, batch).then((b) => { try { onChange(b); } catch {} });
|
|
148
179
|
};
|
|
149
180
|
let stopped = false;
|
|
150
181
|
(async () => {
|
|
@@ -169,4 +200,4 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
|
|
|
169
200
|
};
|
|
170
201
|
}
|
|
171
202
|
|
|
172
|
-
export const _internals = { _isScanable, SCAN_EXT_RE, IGNORE_DIR_RE };
|
|
203
|
+
export const _internals = { _isScanable, expandChangedBatch, SCAN_EXT_RE, IGNORE_DIR_RE };
|
package/src/pr-delta.js
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
28
28
|
import { runFullScan } from './engine.js';
|
|
29
|
+
import { isLanguageManifest, isLanguageExcludedPath } from './language/discovery.js';
|
|
29
30
|
|
|
30
|
-
const FILE_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|java|cs|kt|go|rb|php|sol|swift|rs|tf|yml|yaml|json|toml|md)$/i;
|
|
31
|
+
const FILE_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|java|cs|kt|go|rb|php|sol|swift|rs|tf|yml|yaml|json|toml|md|hs|lhs|hsc|hs-boot|nix|cabal)$/i;
|
|
31
32
|
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
32
33
|
|
|
33
34
|
// `root` is the PR's repository — a scan target, not this project's own
|
|
@@ -54,23 +55,27 @@ function _listFilesAtRef(root, ref) {
|
|
|
54
55
|
return r.stdout.trim().split('\n').filter(p => {
|
|
55
56
|
if (!p) return false;
|
|
56
57
|
if (p.includes('/node_modules/') || p.includes('/.venv/')) return false;
|
|
57
|
-
|
|
58
|
+
if (isLanguageExcludedPath(p)) return false;
|
|
59
|
+
return FILE_EXT_RE.test(p) || isLanguageManifest(p);
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
async function _scanAtRef(root, ref) {
|
|
62
64
|
const files = _listFilesAtRef(root, ref);
|
|
63
65
|
const fileContents = {};
|
|
66
|
+
const depFileContents = {};
|
|
64
67
|
for (const f of files) {
|
|
65
68
|
const c = _readFileAtRef(root, ref, f);
|
|
66
|
-
if (c
|
|
69
|
+
if (c == null) continue;
|
|
70
|
+
// Haskell/Nix manifests and lock files are dependency inputs, not code (the same split a working-tree scan makes).
|
|
71
|
+
if (isLanguageManifest(f)) depFileContents[f] = c; else fileContents[f] = c;
|
|
67
72
|
}
|
|
68
73
|
// `provenance:false` — a base-ref snapshot, not the current working state.
|
|
69
74
|
// Beyond the wasted git walks, updateLifecycle marks every open stableId
|
|
70
75
|
// absent from the finding set it is handed as `remediated`; running the PR
|
|
71
76
|
// delta gate would silently rewrite the project's lifecycle store from the
|
|
72
77
|
// base ref's findings.
|
|
73
|
-
return runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
|
|
78
|
+
return runFullScan({ fileContents, depFileContents, scanRoot: root, provenance: false }, () => {});
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
function _summary(findings) {
|
package/src/report/index.js
CHANGED
|
@@ -110,6 +110,20 @@ function explainParts(f, { verbose = false } = {}) {
|
|
|
110
110
|
// <confidence>". Nothing here throws on a partially-populated object; every
|
|
111
111
|
// field access is optional-chained or defaulted, because a provenance record
|
|
112
112
|
// that survived a failure path is exactly the input this has to render.
|
|
113
|
+
// Additive Haskell/Nix finding metadata (src/language/contracts.js). Only keys the finding actually carries.
|
|
114
|
+
const _LANGUAGE_KEYS = ['language', 'capability', 'analysisKind', 'evidenceKind', 'originalLocation', 'generatedLocation', 'scope', 'uncertainty', 'resolutionStatus', 'modelVersion', 'trustBoundary', 'route', 'modelNote', 'controls', 'guardNote', 'rule', 'exposure', 'destination', 'source', 'origins', 'sink', 'protection', 'attrPath', 'bridge', 'applicationFlow', 'secretValue', 'environmentVariable', 'provider', 'rotation', 'llmRole', 'llmContext', 'owaspLlm', 'end2endProof', 'chainNote', 'declaredCapability', 'exercised', 'service', 'subject', 'option', 'optionEvidence', 'locationKind', 'entry', 'ignorePragmaIgnored'];
|
|
115
|
+
// Keys that other detectors already use with a different meaning; passed through only for Haskell/Nix findings.
|
|
116
|
+
const _LANGUAGE_ONLY_KEYS = new Set(['rule', 'source', 'sink', 'destination', 'origins', 'protection', 'exposure', 'service']);
|
|
117
|
+
function languageFields(f) {
|
|
118
|
+
const out = {};
|
|
119
|
+
const lang = f.language === 'haskell' || f.language === 'nix';
|
|
120
|
+
for (const k of _LANGUAGE_KEYS) {
|
|
121
|
+
if (_LANGUAGE_ONLY_KEYS.has(k) && !lang) continue;
|
|
122
|
+
if (f[k] !== undefined && f[k] !== null) out[k] = f[k];
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
export function explainProvenance(f) {
|
|
114
128
|
const fp = f && f.findingProvenance;
|
|
115
129
|
if (!fp) return null;
|
|
@@ -274,6 +288,8 @@ export function normalizeFindings(scan){
|
|
|
274
288
|
unvalidated: f.unvalidated === true,
|
|
275
289
|
cross_language: f.cross_language === true,
|
|
276
290
|
family: f.family || null,
|
|
291
|
+
// Haskell/Nix language contract (additive; absent for every other finding, so existing output is unchanged).
|
|
292
|
+
...languageFields(f),
|
|
277
293
|
// Premortem #8: surface the parser field so downstream consumers
|
|
278
294
|
// (UI, SARIF, calibration) see the value the engine backfilled.
|
|
279
295
|
parser: f.parser || null,
|
|
@@ -527,7 +543,8 @@ export function normalizeFindings(scan){
|
|
|
527
543
|
cveAliases: sc.cveAliases || [],
|
|
528
544
|
osvId: sc.osvId || null,
|
|
529
545
|
advisory: sc.advisory || sc.description || '',
|
|
530
|
-
fixedIn: sc.range || null,
|
|
546
|
+
fixedIn: sc.range || (Array.isArray(sc.fixedIn) && sc.fixedIn.length ? sc.fixedIn : null),
|
|
547
|
+
...(Number.isInteger(sc.line) && sc.line > 0 ? { declaringLine: sc.line, declaringFile: sc.file || null } : {}),
|
|
531
548
|
// Feat-9: real-world risk signals
|
|
532
549
|
epssScore: sc.epssScore ?? null,
|
|
533
550
|
epssPercentile: sc.epssPercentile ?? null,
|
|
@@ -1034,15 +1051,24 @@ export function toSARIF(scan, meta={}){
|
|
|
1034
1051
|
const chain = Array.isArray(f.chain) ? f.chain : [];
|
|
1035
1052
|
const codeFlows = chain.length >= 2 ? [{
|
|
1036
1053
|
threadFlows: [{
|
|
1037
|
-
locations: chain.map((hop, idx) =>
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1054
|
+
locations: chain.map((hop, idx) => {
|
|
1055
|
+
// A hop inside a GENERATED script (a shell script a Nix string builds) has coordinates in the script
|
|
1056
|
+
// text, not in the file. It is reported at the nearest ORIGINAL span (the interpolation that feeds it)
|
|
1057
|
+
// and carries its generated position and origin in properties, so no reader mistakes script line 1 for
|
|
1058
|
+
// line 1 of the Nix file.
|
|
1059
|
+
const generated = hop.generated === true;
|
|
1060
|
+
const orig = generated && f.originalLocation && Number.isInteger(f.originalLocation.startLine) ? f.originalLocation : null;
|
|
1061
|
+
const region = orig
|
|
1062
|
+
? { startLine: Math.max(1, orig.startLine), ...(Number.isInteger(orig.startColumn) ? { startColumn: orig.startColumn + 1 } : {}), ...(Number.isInteger(orig.endLine) ? { endLine: orig.endLine } : {}), ...(Number.isInteger(orig.endColumn) ? { endColumn: orig.endColumn + 1 } : {}) }
|
|
1063
|
+
: { startLine: Math.max(1, hop.line || 1), ...(Number.isInteger(hop.column) ? { startColumn: hop.column + 1 } : {}), ...(Number.isInteger(hop.endLine) ? { endLine: hop.endLine } : {}), ...(Number.isInteger(hop.endColumn) ? { endColumn: hop.endColumn + 1 } : {}) };
|
|
1064
|
+
return {
|
|
1065
|
+
location: {
|
|
1066
|
+
physicalLocation: { artifactLocation: { uri: hop.file || f.file }, region },
|
|
1067
|
+
message: { text: `${hop.label || (idx === 0 ? 'source' : idx === chain.length - 1 ? 'sink' : 'propagation')}${generated ? ' [generated script]' : ''}` },
|
|
1042
1068
|
},
|
|
1043
|
-
|
|
1044
|
-
}
|
|
1045
|
-
})
|
|
1069
|
+
...(generated ? { kinds: ['generated'], properties: { generated: true, generatedLocation: { line: hop.line, ...(Number.isInteger(hop.column) ? { startColumn: hop.column } : {}), ...(Number.isInteger(hop.endLine) ? { endLine: hop.endLine } : {}), ...(Number.isInteger(hop.endColumn) ? { endColumn: hop.endColumn } : {}) }, ...(f.attrPath ? { generatedBy: f.attrPath } : {}) } } : {}),
|
|
1070
|
+
};
|
|
1071
|
+
}),
|
|
1046
1072
|
}],
|
|
1047
1073
|
}] : undefined;
|
|
1048
1074
|
const fixes = f.remediation ? [{
|
package/src/runScan.js
CHANGED
|
@@ -9,6 +9,7 @@ import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate
|
|
|
9
9
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
10
10
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
11
11
|
import { stampScan } from './posture/ruleset-version.js';
|
|
12
|
+
import { isLanguageManifest, isLanguageExcludedPath, isLanguageSource, readExplicitExports, WALK_IGNORE_GLOBS, impactedFiles, buildImportGraph, importClosure } from './language/discovery.js';
|
|
12
13
|
|
|
13
14
|
const DEP_FILE_NAMES = new Set([
|
|
14
15
|
'package.json','package-lock.json','yarn.lock','pnpm-lock.yaml',
|
|
@@ -40,6 +41,9 @@ export function isDepFile(rel) {
|
|
|
40
41
|
if (DEP_FILE_NAMES.has(base)) return true;
|
|
41
42
|
if (REQUIREMENTS_FILE.test(base)) return true;
|
|
42
43
|
if (REQUIREMENTS_DIR_FILE.test(rel.split(path.sep).join('/'))) return true;
|
|
44
|
+
// Haskell/Nix project manifests (cabal, stack, flake, lockfiles): a change to
|
|
45
|
+
// any of them moves the global checkpoint key via depFileContents.
|
|
46
|
+
if (isLanguageManifest(rel) && !isLanguageExcludedPath(rel)) return true;
|
|
43
47
|
return false;
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -68,18 +72,27 @@ const DEFAULT_IGNORE = [
|
|
|
68
72
|
'**/dist/**','**/build/**','**/.next/**','**/venv/**','**/env/**','**/.venv/**',
|
|
69
73
|
'**/target/**','**/bin/**','**/obj/**','**/.cache/**','**/coverage/**',
|
|
70
74
|
'**/bower_components/**','**/tests/**','**/test/**','**/__tests__/**','**/spec/**','**/mocks/**',
|
|
75
|
+
// Haskell/Nix build output and store closures; explicit exports inside them
|
|
76
|
+
// are read by exact path below, never by walking.
|
|
77
|
+
...WALK_IGNORE_GLOBS,
|
|
71
78
|
];
|
|
72
79
|
|
|
73
80
|
export async function readTree(root, { ignore = [] } = {}) {
|
|
74
81
|
const entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
|
|
75
82
|
const fileContents = {};
|
|
76
83
|
const depFileContents = {};
|
|
84
|
+
const oversize = [];
|
|
77
85
|
for (const rel of entries) {
|
|
78
86
|
const abs = path.join(root, rel);
|
|
79
87
|
let stat;
|
|
80
88
|
try { stat = await fs.stat(abs); } catch { continue; }
|
|
81
89
|
const dep = isDepFile(rel);
|
|
82
|
-
if (stat.size > (dep ? MAX_DEP_BYTES : MAX_CODE_BYTES))
|
|
90
|
+
if (stat.size > (dep ? MAX_DEP_BYTES : MAX_CODE_BYTES)) {
|
|
91
|
+
// A Haskell/Nix source or manifest over the read cap is not analysed. That is a coverage gap a reader must be told about,
|
|
92
|
+
// not a quiet omission: it is listed (path, size, cap) and reaches scan health as a condition.
|
|
93
|
+
if (isLanguageSource(rel) || isLanguageManifest(rel)) oversize.push({ file: rel, bytes: stat.size, cap: dep ? MAX_DEP_BYTES : MAX_CODE_BYTES });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
83
96
|
let content;
|
|
84
97
|
try { content = await fs.readFile(abs, 'utf8'); } catch { continue; }
|
|
85
98
|
const base = path.basename(rel);
|
|
@@ -107,7 +120,14 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
107
120
|
// project index parses key=value lines for cross-file lookup.
|
|
108
121
|
else if (/\.properties$/i.test(rel)) fileContents[rel] = content;
|
|
109
122
|
}
|
|
110
|
-
|
|
123
|
+
// Explicit Cabal plan / Stack / Nix exports live inside directories the walk
|
|
124
|
+
// prunes. Read them by exact path next to each project manifest.
|
|
125
|
+
try {
|
|
126
|
+
const manifests = Object.keys(depFileContents).concat(Object.keys(fileContents)).filter(isLanguageManifest);
|
|
127
|
+
const { contents } = readExplicitExports(root, manifests);
|
|
128
|
+
for (const [rel, text] of Object.entries(contents)) depFileContents[rel] = text;
|
|
129
|
+
} catch { /* exports are best-effort evidence */ }
|
|
130
|
+
return { fileContents, depFileContents, oversize };
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
// Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
|
|
@@ -146,6 +166,14 @@ export function changedSince(root, gitRef) {
|
|
|
146
166
|
}
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
const POLICY_ONLY_HEAVY = ['**/node_modules/**', '**/.git/**', '**/venv/**', '**/.venv/**', '**/__pycache__/**', ...WALK_IGNORE_GLOBS];
|
|
170
|
+
/** Haskell/Nix sources the default ignore list kept out of the scan (relative paths, sorted, capped). */
|
|
171
|
+
export async function languageFilesIgnoredByPolicy(root, fileContents, depFileContents) {
|
|
172
|
+
const all = await listFiles(root, { ignore: POLICY_ONLY_HEAVY });
|
|
173
|
+
const seen = new Set([...Object.keys(fileContents), ...Object.keys(depFileContents)]);
|
|
174
|
+
return all.filter((rel) => (isLanguageSource(rel) || isLanguageManifest(rel)) && !isLanguageExcludedPath(rel) && !seen.has(rel)).sort().slice(0, 500);
|
|
175
|
+
}
|
|
176
|
+
|
|
149
177
|
export async function runScan(rootDir, opts = {}) {
|
|
150
178
|
const root = path.resolve(rootDir);
|
|
151
179
|
const startedAt = new Date().toISOString();
|
|
@@ -173,6 +201,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
173
201
|
// path added later must opt OUT explicitly rather than silently inherit a
|
|
174
202
|
// false claim of coverage.
|
|
175
203
|
let completeScan = true;
|
|
204
|
+
let sizeSkipped = [];
|
|
176
205
|
if (opts.fileContents) {
|
|
177
206
|
// Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
|
|
178
207
|
// construction a subset of the tree, not a scan of it.
|
|
@@ -180,16 +209,38 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
180
209
|
depFileContents = opts.depFileContents || {};
|
|
181
210
|
completeScan = false;
|
|
182
211
|
} else {
|
|
183
|
-
({ fileContents, depFileContents } = await readTree(root, opts));
|
|
212
|
+
({ fileContents, depFileContents, oversize: sizeSkipped } = await readTree(root, opts));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Haskell/Nix sources under a default-ignored directory (tests, specs, mocks, bin, build, dist, vendor) are not
|
|
216
|
+
// scanned, exactly as for every other language. That policy is kept, but it is never silent: the files it kept
|
|
217
|
+
// out are listed and reach scan health as a stated limitation.
|
|
218
|
+
let policyExcluded = [];
|
|
219
|
+
if (!opts.fileContents && Object.keys(fileContents).some(isLanguageSource)) {
|
|
220
|
+
try { policyExcluded = await languageFilesIgnoredByPolicy(root, fileContents, depFileContents); } catch { policyExcluded = []; }
|
|
184
221
|
}
|
|
185
222
|
|
|
186
223
|
// Feat-10: incremental mode — restrict the scan to files changed since a git ref
|
|
187
224
|
if (opts.changedSince) {
|
|
188
225
|
const changed = changedSince(root, opts.changedSince);
|
|
189
226
|
if (changed) {
|
|
227
|
+
const keep = new Set(changed);
|
|
228
|
+
// Haskell and Nix results depend on more than the file that changed: a module that imports a changed module
|
|
229
|
+
// may now be (or stop being) vulnerable, an effective NixOS value is decided by the modules it imports, and a
|
|
230
|
+
// lock file, Cabal flags or an explicit export can change every file's verdict. So the scan set is the changed
|
|
231
|
+
// files, everything that transitively imports one (all language sources for a manifest change), and the import
|
|
232
|
+
// closure of those as context. A deleted language file or manifest widens to every language source, because
|
|
233
|
+
// its importers can no longer be found from the tree on disk.
|
|
234
|
+
const langSources = Object.keys(fileContents).filter(isLanguageSource);
|
|
235
|
+
if (langSources.length) {
|
|
236
|
+
const deletedLang = [...changed].some((c) => (isLanguageSource(c) || isLanguageManifest(c)) && !(c in fileContents) && !(c in depFileContents));
|
|
237
|
+
const impacted = deletedLang ? langSources : impactedFiles(fileContents, depFileContents, [...changed]);
|
|
238
|
+
const { graph } = buildImportGraph(fileContents);
|
|
239
|
+
for (const f of impacted) { keep.add(f); for (const d of importClosure(graph, f).files) keep.add(d); }
|
|
240
|
+
}
|
|
190
241
|
const filtered = {};
|
|
191
242
|
for (const f of Object.keys(fileContents)) {
|
|
192
|
-
if (
|
|
243
|
+
if (keep.has(f)) filtered[f] = fileContents[f];
|
|
193
244
|
}
|
|
194
245
|
fileContents = filtered;
|
|
195
246
|
// Only when the filter actually applied. A `changedSince` that resolved
|
|
@@ -203,7 +254,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
203
254
|
|
|
204
255
|
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
205
256
|
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
206
|
-
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan }, opts.onProgress || (()=>{}));
|
|
257
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped }, opts.onProgress || (()=>{}));
|
|
207
258
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
208
259
|
// notify if the operator pinned a different version than what's installed.
|
|
209
260
|
try { stampScan(root, scan); } catch {}
|