@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.
Files changed (136) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/bin/agentic-security.js +131 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/1919.index.js +445 -0
  6. package/dist/2862.index.js +325 -0
  7. package/dist/3499.index.js +5 -1
  8. package/dist/3531.index.js +261 -0
  9. package/dist/3596.index.js +103 -0
  10. package/dist/4113.index.js +15 -3
  11. package/dist/4171.index.js +319 -0
  12. package/dist/5637.index.js +10 -4
  13. package/dist/6359.index.js +433 -0
  14. package/dist/7838.index.js +15 -3
  15. package/dist/8551.index.js +1192 -0
  16. package/dist/8587.index.js +410 -0
  17. package/dist/8714.index.js +98 -0
  18. package/dist/8798.index.js +218 -0
  19. package/dist/9220.index.js +35 -3
  20. package/dist/9560.index.js +26 -3
  21. package/dist/agentic-security.mjs +51 -51
  22. package/dist/agentic-security.mjs.sha256 +1 -1
  23. package/package.json +20 -3
  24. package/src/dataflow/CLAUDE.md +9 -0
  25. package/src/dataflow/catalog-haskell.js +37 -0
  26. package/src/dataflow/catalog.js +4 -0
  27. package/src/dataflow/engine.js +33 -3
  28. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  29. package/src/dataflow/privacy-catalog.js +5 -1
  30. package/src/discovery/CLAUDE.md +6 -0
  31. package/src/discovery/index.js +12 -3
  32. package/src/discovery/lenses.js +4 -1
  33. package/src/discovery/partition.js +28 -0
  34. package/src/egress/redact.js +8 -0
  35. package/src/engine.js +125 -14
  36. package/src/ir/index.js +39 -2
  37. package/src/language/CLAUDE.md +74 -0
  38. package/src/language/accuracy.js +90 -0
  39. package/src/language/aibom.js +307 -0
  40. package/src/language/assurance.js +193 -0
  41. package/src/language/bom-validate.js +227 -0
  42. package/src/language/bom.js +236 -0
  43. package/src/language/bridges.js +392 -0
  44. package/src/language/compliance-map.js +61 -0
  45. package/src/language/context.js +199 -0
  46. package/src/language/contracts.js +339 -0
  47. package/src/language/discovery.js +359 -0
  48. package/src/language/engine-pass.js +120 -0
  49. package/src/language/fix-lifecycle.js +120 -0
  50. package/src/language/haskell-adapter.js +51 -0
  51. package/src/language/haskell-disclosure.js +170 -0
  52. package/src/language/haskell-findings.js +44 -0
  53. package/src/language/haskell-fix.js +409 -0
  54. package/src/language/haskell-grammar.js +78 -0
  55. package/src/language/haskell-guards.js +382 -0
  56. package/src/language/haskell-ir.js +1377 -0
  57. package/src/language/haskell-llm.js +128 -0
  58. package/src/language/haskell-manifests.js +1290 -0
  59. package/src/language/haskell-models.js +278 -0
  60. package/src/language/haskell-parser.js +1073 -0
  61. package/src/language/haskell-resolved-graph.js +414 -0
  62. package/src/language/haskell-sca.js +387 -0
  63. package/src/language/haskell-security-rules.js +332 -0
  64. package/src/language/haskell-supply.js +140 -0
  65. package/src/language/haskell-sweep.js +85 -0
  66. package/src/language/haskell-syntax.js +895 -0
  67. package/src/language/haskell-web.js +749 -0
  68. package/src/language/nix-adapter.js +42 -0
  69. package/src/language/nix-agents.js +155 -0
  70. package/src/language/nix-build-trust.js +790 -0
  71. package/src/language/nix-closure.js +331 -0
  72. package/src/language/nix-eval-isolation.js +274 -0
  73. package/src/language/nix-fix.js +268 -0
  74. package/src/language/nix-grammar.js +97 -0
  75. package/src/language/nix-inventory.js +379 -0
  76. package/src/language/nix-ir.js +642 -0
  77. package/src/language/nix-parser.js +908 -0
  78. package/src/language/nix-privacy.js +114 -0
  79. package/src/language/nix-sca.js +358 -0
  80. package/src/language/nix-script-taint.js +885 -0
  81. package/src/language/nix-secrets.js +396 -0
  82. package/src/language/nixos-hardening.js +564 -0
  83. package/src/language/nixos-module-resolver.js +595 -0
  84. package/src/language/nixos-option-catalog.js +96 -0
  85. package/src/language/pragma.js +150 -0
  86. package/src/language/resolved-pass.js +174 -0
  87. package/src/language/secrets.js +323 -0
  88. package/src/language/state-artifacts.js +78 -0
  89. package/src/language/support-registry.js +289 -0
  90. package/src/language/trusted-inputs.js +38 -0
  91. package/src/language/witness.js +185 -0
  92. package/src/lineage/CLAUDE.md +8 -0
  93. package/src/lineage/coverage.js +2 -0
  94. package/src/lineage/cross-repo-link.js +22 -0
  95. package/src/lineage/graph-builder.js +5 -1
  96. package/src/lineage/haskell-view.js +319 -0
  97. package/src/lineage/index.js +8 -1
  98. package/src/lineage/nix-view.js +198 -0
  99. package/src/lineage/sink-registry.js +10 -1
  100. package/src/lineage/source-registry.js +3 -0
  101. package/src/lineage/transform-catalog.js +12 -0
  102. package/src/llm-validator/index.js +16 -2
  103. package/src/lsp/server.js +69 -9
  104. package/src/mcp/CLAUDE.md +7 -0
  105. package/src/mcp/tools.js +26 -3
  106. package/src/pipeline/coverage-ledger.js +0 -0
  107. package/src/pipeline/scan-health.js +19 -1
  108. package/src/posture/accuracy-scorecard.js +51 -0
  109. package/src/posture/aibom.js +61 -3
  110. package/src/posture/artifact-registry.js +12 -0
  111. package/src/posture/auditor-walkthrough.js +41 -0
  112. package/src/posture/clustering.js +2 -2
  113. package/src/posture/evidence-bundle.js +7 -1
  114. package/src/posture/execution-proof.js +15 -0
  115. package/src/posture/falsification.js +7 -0
  116. package/src/posture/family-resolve.js +7 -0
  117. package/src/posture/fix-history.js +40 -0
  118. package/src/posture/fix-verify.js +15 -3
  119. package/src/posture/provenance/git-evidence.js +24 -0
  120. package/src/posture/provenance/origin-resolver.js +14 -2
  121. package/src/posture/provenance/predicate-replay.js +11 -2
  122. package/src/posture/provenance/sca-origin.js +20 -3
  123. package/src/posture/sbom-diff.js +7 -6
  124. package/src/posture/sbom.js +85 -32
  125. package/src/posture/sca-policy.js +10 -1
  126. package/src/posture/sca-verdict.js +8 -1
  127. package/src/posture/secret-history.js +62 -18
  128. package/src/posture/stable-id.js +21 -2
  129. package/src/posture/state-dir.js +21 -0
  130. package/src/posture/threat-model-auto.js +4 -4
  131. package/src/posture/triage.js +18 -1
  132. package/src/posture/watch-mode.js +34 -3
  133. package/src/pr-delta.js +9 -4
  134. package/src/report/index.js +35 -9
  135. package/src/runScan.js +56 -5
  136. 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
- export function scanHistoryDiff(diffText, commit, detectFn) {
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 added = extractAddedLines(diffText);
32
- if (!added.trim()) return [];
33
- let findings = [];
34
- try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
35
- return findings.map((f) => {
36
- 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.';
37
- return {
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: 0,
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);
@@ -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 sink = normalizeSnippet(f.sink?.snippet || f.snippet || '').slice(0, 200);
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
- const material = `${rid}|${sink}|${shape}|${fileHint}`;
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
 
@@ -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
- try { fs.mkdirSync(dir, { recursive: true }); } catch {}
219
- try { fs.writeFileSync(path.join(dir, 'threat-model.json'), JSON.stringify(model, null, 2)); } catch {}
220
- try { fs.writeFileSync(path.join(dir, 'threat-model.md'), renderMarkdown(model)); } catch {}
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) {
@@ -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 || IGNORE_DIR_RE.test(rel)) return false;
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
- try { onChange(batch); } catch {}
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
- return FILE_EXT_RE.test(p);
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 != null) fileContents[f] = 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) {
@@ -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
- location: {
1039
- physicalLocation: {
1040
- artifactLocation: { uri: hop.file || f.file },
1041
- region: { startLine: Math.max(1, hop.line || 1) },
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
- message: { text: hop.label || (idx === 0 ? 'source' : idx === chain.length - 1 ? 'sink' : 'propagation') },
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)) continue;
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
- return { fileContents, depFileContents };
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 (changed.has(f)) filtered[f] = fileContents[f];
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 {}