@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
@@ -0,0 +1,96 @@
1
+ // Revision-pinned NixOS / Home Manager option catalog (NIX-002).
2
+ //
3
+ // This is NOT a copy of the option set. It lists the options the scanner reasons
4
+ // about, with a type, a default PER RELEASE, and the renames that must be followed
5
+ // so an old spelling cannot bypass a hardening rule that is written against the new
6
+ // one. Anything absent from here is "uncataloged" and its default is unknown, never
7
+ // guessed. A default is only "proven" when it is recorded for the release in force
8
+ // (or recorded identically for every release in the catalog).
9
+ //
10
+ // The catalog is pinned by NixOS release (the CI-tested set in CATALOG_RELEASES) and
11
+ // by a content hash (`catalogRevision`), so a report can say exactly which table it
12
+ // was judged against. Refresh it, and bump CATALOG_SCHEMA when the shape changes, by
13
+ // editing the tables below: the revision hash changes with them.
14
+
15
+ import { createHash } from 'node:crypto';
16
+
17
+ export const CATALOG_SCHEMA = 1;
18
+ export const CATALOG_RELEASES = Object.freeze(['24.11', '25.05', '25.11']);
19
+
20
+ const same = (v) => Object.freeze(Object.fromEntries(CATALOG_RELEASES.map((r) => [r, v])));
21
+
22
+ /** option path -> { type, default?: {release: value} }. A missing `default` means "no static default" (unknown). */
23
+ const NIXOS_OPTIONS = {
24
+ 'services.openssh.enable': { type: 'bool', default: same(false) },
25
+ 'services.openssh.openFirewall': { type: 'bool', default: same(true) },
26
+ 'services.openssh.ports': { type: 'listOf', default: same([22]) },
27
+ 'services.openssh.settings.PermitRootLogin': { type: 'enum', default: same('prohibit-password') },
28
+ 'services.openssh.settings.PasswordAuthentication': { type: 'bool', default: same(true) },
29
+ 'services.openssh.settings.KbdInteractiveAuthentication': { type: 'bool', default: same(true) },
30
+ 'services.openssh.settings.X11Forwarding': { type: 'bool', default: same(false) },
31
+ 'networking.firewall.enable': { type: 'bool', default: same(true) },
32
+ 'networking.firewall.allowedTCPPorts': { type: 'listOf', default: same([]) },
33
+ 'networking.firewall.allowedUDPPorts': { type: 'listOf', default: same([]) },
34
+ 'networking.hostName': { type: 'str', default: same('nixos') },
35
+ 'security.sudo.enable': { type: 'bool', default: same(true) },
36
+ 'security.sudo.wheelNeedsPassword': { type: 'bool', default: same(true) },
37
+ 'security.sudo.extraConfig': { type: 'lines', default: same('') },
38
+ 'security.sudo.execWheelOnly': { type: 'bool', default: same(false) },
39
+ 'networking.firewall.allowedTCPPortRanges': { type: 'listOf', default: same([]) },
40
+ 'users.mutableUsers': { type: 'bool', default: same(true) },
41
+ 'nix.settings.sandbox': { type: 'bool', default: same(true) },
42
+ 'nix.settings.trusted-users': { type: 'listOf', default: same(['root']) },
43
+ 'services.nginx.enable': { type: 'bool', default: same(false) },
44
+ 'services.postgresql.enable': { type: 'bool', default: same(false) },
45
+ 'services.postgresql.enableTCPIP': { type: 'bool', default: same(false) },
46
+ 'services.xserver.enable': { type: 'bool', default: same(false) },
47
+ 'system.stateVersion': { type: 'str' },
48
+ };
49
+
50
+ const HM_OPTIONS = {
51
+ 'programs.git.enable': { type: 'bool', default: same(false) },
52
+ 'programs.bash.enable': { type: 'bool', default: same(false) },
53
+ 'services.gpg-agent.enable': { type: 'bool', default: same(false) },
54
+ 'home.stateVersion': { type: 'str' },
55
+ };
56
+
57
+ /** old spelling -> current spelling. A definition at the old path is evaluated as the new path. */
58
+ const NIXOS_RENAMES = {
59
+ 'services.openssh.permitRootLogin': 'services.openssh.settings.PermitRootLogin',
60
+ 'services.openssh.passwordAuthentication': 'services.openssh.settings.PasswordAuthentication',
61
+ 'services.openssh.kbdInteractiveAuthentication': 'services.openssh.settings.KbdInteractiveAuthentication',
62
+ 'services.openssh.challengeResponseAuthentication': 'services.openssh.settings.KbdInteractiveAuthentication',
63
+ 'services.openssh.forwardX11': 'services.openssh.settings.X11Forwarding',
64
+ 'nix.useSandbox': 'nix.settings.sandbox',
65
+ 'nix.trustedUsers': 'nix.settings.trusted-users',
66
+ };
67
+
68
+ /** Prefixes the catalog is authoritative for: an option under one that is neither listed nor renamed is "unknown", not "fine". */
69
+ const AUTHORITATIVE = ['services.openssh', 'networking.firewall', 'security.sudo'];
70
+ /** Freeform attribute sets: any key is valid, but only the listed ones have a known default. */
71
+ const FREEFORM = ['services.openssh.settings', 'nix.settings', 'networking.firewall.interfaces'];
72
+
73
+ const CATALOG = {
74
+ schema: CATALOG_SCHEMA,
75
+ releases: [...CATALOG_RELEASES],
76
+ nixos: { options: NIXOS_OPTIONS, renames: NIXOS_RENAMES, authoritative: AUTHORITATIVE, freeform: FREEFORM },
77
+ homeManager: { options: HM_OPTIONS, renames: {}, authoritative: [], freeform: [] },
78
+ };
79
+
80
+ export function computeCatalogRevision(catalog) {
81
+ const { revision, ...content } = catalog;
82
+ void revision;
83
+ return `nixos-option-catalog/${catalog.schema}+${createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 12)}`;
84
+ }
85
+
86
+ export const CATALOG_REVISION = computeCatalogRevision(CATALOG);
87
+
88
+ export function defaultCatalog() {
89
+ return { ...CATALOG, revision: CATALOG_REVISION };
90
+ }
91
+
92
+ /** `nixos-24.11`, `release-25.05`, `24.11` -> '24.11'; anything else null. */
93
+ export function normalizeRelease(text) {
94
+ const m = /(?:^|[^0-9])(\d{2}\.(?:05|11))(?![0-9])/.exec(String(text || ''));
95
+ return m ? m[1] : null;
96
+ }
@@ -0,0 +1,150 @@
1
+ // Inline suppression pragmas for Haskell and Nix (X-013).
2
+ //
3
+ // Haskell `-- agentic-security-ignore: rule-a, rule-b` and `{- agentic-security-ignore: rule-a -}`
4
+ // Nix `# agentic-security-ignore: rule-a` and `/* agentic-security-ignore: rule-a */`
5
+ //
6
+ // A pragma is recognised only INSIDE A COMMENT, so the same words in a string literal, an identifier or an operator
7
+ // (`-->` is an operator in Haskell, not a comment) never suppress anything. Comment boundaries come from a small
8
+ // scanner that knows each language's strings, nesting and interpolation, not from a regular expression over the line.
9
+ //
10
+ // Scope: LINE. A pragma applies to findings reported on the line it sits on, exactly like the pragmas of the other
11
+ // languages. With rule ids it suppresses only a finding whose rule, id, family, CWE or title slug EQUALS one of them
12
+ // (exact token match, never a substring: `cmd` must not silence `cmdi`). A bare pragma suppresses every finding on its
13
+ // line. A pragma whose rule list is malformed (anything other than ids separated by commas or spaces) suppresses
14
+ // nothing and is reported back as `malformed`, so it cannot silence an unrelated finding by accident.
15
+
16
+ const MARK = /agentic-security-ignore(?![A-Za-z0-9_-])/;
17
+ const RULE_ID = /^[A-Za-z0-9_.:/\-]+$/;
18
+
19
+ /**
20
+ * Comment spans of a Haskell source: [{start, end, text}] in source offsets.
21
+ * Strings ("..." with escapes, string gaps) and character literals are skipped; `--` starts a line comment only when
22
+ * it is not part of a longer operator symbol; `{- -}` nests.
23
+ */
24
+ export function haskellComments(src) {
25
+ const out = []; const n = src.length; let i = 0;
26
+ const SYM = /[!#$%&*+./<=>?@\\^|~:-]/;
27
+ while (i < n) {
28
+ const c = src[i]; const d = src[i + 1];
29
+ if (c === '"') { i++; while (i < n && src[i] !== '"') { if (src[i] === '\\') i++; if (src[i] === '\n') break; i++; } i++; continue; }
30
+ if (c === "'" && /^'(?:[^'\\\n]|\\[^\n]{1,8}?)'/.test(src.slice(i, i + 12)) && !/[A-Za-z0-9_']/.test(src[i - 1] || '')) { const m = /^'(?:[^'\\\n]|\\[^\n]{1,8}?)'/.exec(src.slice(i, i + 12)); i += m[0].length; continue; }
31
+ if (c === '{' && d === '-' && src[i + 2] !== '#') {
32
+ let depth = 1; let j = i + 2;
33
+ while (j < n && depth > 0) { if (src[j] === '{' && src[j + 1] === '-') { depth++; j += 2; } else if (src[j] === '-' && src[j + 1] === '}') { depth--; j += 2; } else j++; }
34
+ out.push({ start: i, end: j, text: src.slice(i, j) }); i = j; continue;
35
+ }
36
+ if (c === '-' && d === '-') {
37
+ let j = i; while (src[j] === '-') j++;
38
+ const before = src[i - 1]; const after = src[j];
39
+ if (!(before && SYM.test(before)) && !(after && SYM.test(after)) ) { const e = src.indexOf('\n', i); const end = e < 0 ? n : e; out.push({ start: i, end, text: src.slice(i, end) }); i = end; continue; }
40
+ i = j; continue;
41
+ }
42
+ i++;
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Comment spans of a Nix source: `#` to end of line, and slash-star block comments. Strings `"..."` and indented strings `''...''` are
49
+ * skipped, including their `${ ... }` interpolations, whose inner code may itself contain comments and strings.
50
+ */
51
+ export function nixComments(src) {
52
+ const out = []; const n = src.length;
53
+ function scan(i, stopAtBrace) {
54
+ let depth = 0;
55
+ while (i < n) {
56
+ const c = src[i]; const d = src[i + 1];
57
+ if (stopAtBrace) { if (c === '{') depth++; else if (c === '}') { if (depth === 0) return i + 1; depth--; } }
58
+ if (c === '#') { const e = src.indexOf('\n', i); const end = e < 0 ? n : e; out.push({ start: i, end, text: src.slice(i, end) }); i = end; continue; }
59
+ if (c === '/' && d === '*') { const e = src.indexOf('*/', i + 2); const end = e < 0 ? n : e + 2; out.push({ start: i, end, text: src.slice(i, end) }); i = end; continue; }
60
+ if (c === '"') { i = skipString(i + 1, '"'); continue; }
61
+ if (c === "'" && d === "'") { i = skipString(i + 2, "''"); continue; }
62
+ i++;
63
+ }
64
+ return i;
65
+ }
66
+ function skipString(i, close) {
67
+ while (i < n) {
68
+ const c = src[i]; const d = src[i + 1];
69
+ if (close === '"') { if (c === '\\') { i += 2; continue; } if (c === '"') return i + 1; }
70
+ else {
71
+ if (c === "'" && d === "'") { const e = src[i + 2]; if (e === "'" || e === '$') { i += 3; continue; } if (e === '\\') { i += 4; continue; } return i + 2; }
72
+ }
73
+ if (c === '$' && d === '{') { i = scan(i + 2, true); continue; }
74
+ i++;
75
+ }
76
+ return i;
77
+ }
78
+ scan(0, false);
79
+ return out;
80
+ }
81
+
82
+ const lineStarts = (src) => { const a = [0]; for (let i = 0; i < src.length; i++) if (src[i] === '\n') a.push(i + 1); return a; };
83
+ const lineOfOffset = (starts, off) => { let lo = 0; let hi = starts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= off) lo = mid; else hi = mid - 1; } return lo + 1; };
84
+
85
+ /** Strips the comment delimiters from a comment's text. */
86
+ function commentBody(text) {
87
+ if (text.startsWith('{-')) return text.replace(/^\{-+/, '').replace(/-+\}$/, '');
88
+ if (text.startsWith('/*')) return text.replace(/^\/\*+/, '').replace(/\*+\/$/, '');
89
+ if (text.startsWith('#')) return text.replace(/^#+/, '');
90
+ return text.replace(/^--+/, '');
91
+ }
92
+
93
+ /**
94
+ * Parses the pragma inside ONE comment body. Returns null when there is none, else
95
+ * {bare:boolean, rules:string[], malformed:string|null}.
96
+ */
97
+ export function parsePragma(body) {
98
+ const m = MARK.exec(body);
99
+ if (!m) return null;
100
+ let rest = body.slice(m.index + m[0].length);
101
+ // only the first line of a multi-line comment body carries the list
102
+ rest = rest.split('\n')[0];
103
+ // a trailing block-comment closer is not part of the list
104
+ rest = rest.replace(/(?:-\}|\*\/)\s*$/, '');
105
+ const hasColon = /^\s*:/.test(rest);
106
+ rest = rest.replace(/^\s*:?/, '').trim();
107
+ if (!rest) return hasColon ? { bare: false, rules: [], malformed: 'a pragma with a colon needs at least one rule id' } : { bare: true, rules: [], malformed: null };
108
+ if (!hasColon && /^[A-Za-z0-9_.:/-]/.test(rest) && !/^[,\s]/.test(rest)) return { bare: false, rules: [], malformed: 'rule ids follow a colon: "agentic-security-ignore: <rule>"' };
109
+ const toks = rest.split(/[\s,]+/).filter(Boolean);
110
+ const bad = toks.find((t) => !RULE_ID.test(t));
111
+ if (bad) return { bare: false, rules: [], malformed: `"${bad}" is not a rule id` };
112
+ return { bare: false, rules: toks, malformed: null };
113
+ }
114
+
115
+ /**
116
+ * The pragma a source carries on `line`, or null. `file` selects the comment grammar (.hs/.lhs or .nix); any other
117
+ * file type returns null (the generic pragma handles those).
118
+ */
119
+ export function languagePragmaOnLine(file, src, line) {
120
+ if (typeof src !== 'string' || !Number.isInteger(line) || line < 1) return null;
121
+ const isHs = /\.l?hs$|\.hs-boot$|\.hsc$/i.test(file || '');
122
+ const isNix = /\.nix$/i.test(file || '');
123
+ if (!isHs && !isNix) return null;
124
+ const comments = isHs ? haskellComments(src) : nixComments(src);
125
+ const starts = lineStarts(src);
126
+ const found = [];
127
+ for (const c of comments) {
128
+ if (!MARK.test(c.text)) continue;
129
+ // the line the words sit on (a block comment may span several lines)
130
+ const off = c.start + c.text.search(MARK);
131
+ if (lineOfOffset(starts, off) !== line) continue;
132
+ const p = parsePragma(commentBody(c.text));
133
+ if (p) found.push(p);
134
+ }
135
+ if (!found.length) return null;
136
+ const rules = []; let bare = false; let malformed = null;
137
+ for (const p of found) { if (p.bare) bare = true; rules.push(...p.rules); if (p.malformed) malformed = p.malformed; }
138
+ return { bare, rules, malformed, form: isHs ? 'haskell' : 'nix' };
139
+ }
140
+
141
+ const slug = (s) => String(s || '').replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
142
+
143
+ /** Whether a parsed language pragma suppresses finding `f`: bare, or an EXACT match on one of its identifiers. */
144
+ export function languagePragmaSuppresses(p, f) {
145
+ if (!p || !f) return false;
146
+ if (p.malformed && !p.bare && !p.rules.length) return false;
147
+ if (p.bare) return true;
148
+ const ids = new Set([f.rule, f.id, f.family, f.cwe, f.ruleId, slug(f.vuln), typeof f.id === 'string' ? f.id.split(':')[0] : null].filter(Boolean).map((x) => String(x).toLowerCase()));
149
+ return p.rules.some((r) => ids.has(String(r).toLowerCase()));
150
+ }
@@ -0,0 +1,174 @@
1
+ // Resolved dependency data in the scan path (QA-005.AC04): explicit exports a user or tool wrote on purpose become part of a normal scan,
2
+ // instead of being reachable only from a unit test.
3
+ //
4
+ // Haskell dist-newstyle/cache/plan.json and .stack-work/dependencies.json -> a resolved graph (transitive packages, exact
5
+ // versions, scopes) joined to the declared manifests; stale data is used for nothing and disclosed.
6
+ // Nix nix-export.json (and .direnv/nix-export.json) -> an imported closure, matched against an advisory
7
+ // snapshot with upstream-identity and patch-evidence rules; no feed means "unknown", never "clean".
8
+ // Eval AGENTIC_SECURITY_NIX_EVAL=1 + AGENTIC_SECURITY_NIX_TARGET=<attr> -> an isolated, opt-in evaluation whose state is its
9
+ // own scan-health field. A scan that did not select it never runs a Nix evaluator.
10
+ //
11
+ // Nothing here fetches, builds, evaluates or runs a project's code unless the evaluation is selected AND the sandbox proves its isolation.
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { createHash } from 'node:crypto';
15
+ import { resolveOperatorSnapshot, IGNORED_NOTE } from './trusted-inputs.js';
16
+ import { analyzeHaskellManifests } from './haskell-manifests.js';
17
+ import { buildResolvedGraph } from './haskell-resolved-graph.js';
18
+ import { importNixClosure } from './nix-closure.js';
19
+ import { NixAdvisoryData, matchNixVulnerabilities, overlayEvidence } from './nix-sca.js';
20
+ import { configuredAdvisoryDb, collectUsage, manifestFiles } from './haskell-supply.js';
21
+ import { runIsolatedEval, mergeEvaluationHealth, _which } from './nix-eval-isolation.js';
22
+
23
+ export const RESOLVED_PASS_VERSION = 'resolved-pass/1';
24
+
25
+ const PLAN = /(?:^|\/)dist-newstyle\/cache\/plan\.json$/;
26
+ const STACK_EXPORT = /(?:^|\/)\.stack-work\/dependencies\.json$/;
27
+ const NIX_EXPORT = /(?:^|\/)nix-export\.json$/;
28
+ const ACTIONABLE = new Set(['affected', 'possibly-affected', 'candidate', 'backported-verified']);
29
+
30
+ const memo = new WeakMap();
31
+ const memoized = (files, key, make) => { let m = memo.get(files); if (!m) { m = new Map(); memo.set(files, m); } if (!m.has(key)) m.set(key, make()); return m.get(key); };
32
+
33
+ function parseJson(text) { try { return { ok: true, value: JSON.parse(text) }; } catch (e) { return { ok: false, reason: String((e && e.message) || e).slice(0, 120) }; } }
34
+
35
+ /** The resolved Haskell graph for a file set, or null when no explicit export is present. Cached per file set. */
36
+ export function resolvedHaskellGraph(files) {
37
+ return memoized(files, 'hs-graph', () => {
38
+ const planFile = Object.keys(files).find((p) => PLAN.test(p));
39
+ const stackFile = !planFile ? Object.keys(files).find((p) => STACK_EXPORT.test(p)) : null;
40
+ const src = planFile || stackFile;
41
+ if (!src) return null;
42
+ const mf = manifestFiles(files);
43
+ const manifests = mf.length ? analyzeHaskellManifests(mf) : null;
44
+ const parsed = parseJson(files[src]);
45
+ if (!parsed.ok) return { file: src, graph: { source: planFile ? 'cabal-plan' : 'stack-export', graphAvailable: false, units: null, edges: null, freshness: { status: 'unverified', reasons: [] }, closure: { complete: false, reason: 'malformed_resolved_data' }, gaps: [{ kind: planFile ? 'malformed_plan' : 'malformed_stack_export', detail: `${src} is not valid JSON: ${parsed.reason}` }], declared: [] }, manifests };
46
+ const graph = buildResolvedGraph({ manifests, ...(planFile ? { plan: parsed.value } : { stackExport: parsed.value }), expected: {} });
47
+ return { file: src, graph, manifests };
48
+ });
49
+ }
50
+
51
+ /**
52
+ * Components for the resolved graph: every Hackage unit with an exact version, its scope and where it came from. Only a graph that is
53
+ * not stale contributes; a stale one is returned as a gap (never as components), and a partial one says it is partial.
54
+ */
55
+ export function resolvedHackageComponents(files) {
56
+ const r = resolvedHaskellGraph(files);
57
+ if (!r) return { components: [], gaps: [], summary: null };
58
+ const g = r.graph;
59
+ const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
60
+ const summary = { file: r.file, source: g.source, graphAvailable: g.graphAvailable, freshness: g.freshness, closure: g.closure, units: g.units ? g.units.length : null, edges: g.edges ? g.edges.length : null };
61
+ if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
62
+ // The plan is a file in the scanned project. Where the project's own freeze file also pins a package, the two must agree: a plan that
63
+ // contradicts the freeze is stale or forged, and contributes no versions.
64
+ const frozen = new Map(((r.manifests && r.manifests.lockedPackages) || []).filter((l) => l.name && l.version).map((l) => [l.name, l.version]));
65
+ const clashes = (g.units || []).filter((u) => u.name && u.version && frozen.has(u.name) && frozen.get(u.name) !== u.version).map((u) => `${u.name} ${u.version} (plan) vs ${frozen.get(u.name)} (freeze)`);
66
+ if (clashes.length) { gaps.push({ kind: 'resolved-stale-plan-contradicts-freeze', detail: `the plan contradicts the freeze file (${clashes.slice(0, 3).join('; ')}): it is stale or forged, so no version was taken from it`, file: r.file }); return { components: [], gaps, summary }; }
67
+ gaps.push({ kind: 'resolved-project-supplied-plan', detail: 'the plan is a file in the scanned project: it is checked against the declared bounds and the freeze file, but whoever controls the project controls its contents', file: r.file });
68
+ const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
69
+ const direct = new Set((g.declared || []).map((d) => d.name));
70
+ const components = [];
71
+ for (const u of g.units) {
72
+ if (u.origin === 'local' || u.origin === 'compiler' || !u.name || !u.version) continue; // the project itself and boot libraries are not dependencies to match
73
+ const scopes = Array.isArray(u.scopes) && u.scopes.length ? u.scopes : ['runtime'];
74
+ const scope = scopes.includes('runtime') ? 'runtime' : scopes[0];
75
+ components.push({ ecosystem: 'hackage', name: u.name, version: u.version, declaredRange: null, resolution: 'plan', scope, engineScope: SCOPE[scope] || 'required', target: null, componentKind: u.componentKind || null, manifest: r.file, line: null, ghcComponent: false, direct: direct.has(u.name), unitId: u.id, origin: u.origin, versionSource: { file: r.file, line: null } });
76
+ }
77
+ return { components, gaps, summary };
78
+ }
79
+
80
+ // ── Nix closure ──────────────────────────────────────────────────────────────────
81
+ function loadNixExports(files, env = process.env) {
82
+ const exports = []; const sources = []; const problems = []; const trust = new Set();
83
+ const take = (label, text, origin) => {
84
+ sources.push(label);
85
+ const parsed = parseJson(text);
86
+ if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: label, detail: parsed.reason }); return; }
87
+ const j = parsed.value;
88
+ trust.add(origin);
89
+ // NOTE: an `expected` block inside the file is IGNORED. The file is the thing under test, so it cannot also say what it should be
90
+ // checked against; what an export must match is derived by the scanner (below) or comes from the operator.
91
+ if (j && Array.isArray(j.exports)) exports.push(...j.exports.filter((e) => e && typeof e === 'object'));
92
+ else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
93
+ else exports.push({ text });
94
+ };
95
+ if (env.AGENTIC_SECURITY_NIX_EXPORT) {
96
+ try { take(env.AGENTIC_SECURITY_NIX_EXPORT, readFileSync(env.AGENTIC_SECURITY_NIX_EXPORT, 'utf8'), 'operator'); }
97
+ catch (e) { sources.push(env.AGENTIC_SECURITY_NIX_EXPORT); problems.push({ kind: 'malformed-export', file: env.AGENTIC_SECURITY_NIX_EXPORT, detail: `unreadable: ${e.code || e.message}` }); }
98
+ }
99
+ for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) take(p, files[p], 'project-supplied');
100
+ // What the export must have been made against: the real flake.lock of this project (a forged export cannot also forge that file's hash
101
+ // without the lock it claims to describe being the lock that is here).
102
+ const expected = {};
103
+ const lockPath = Object.keys(files).filter((p) => /(^|\/)flake\.lock$/.test(p)).sort((a, b) => a.length - b.length)[0];
104
+ if (lockPath) expected.flakeLockSha256 = createHash('sha256').update(files[lockPath]).digest('hex');
105
+ else if (sources.length) problems.push({ kind: 'export-unverified', detail: 'there is no flake.lock to bind the export to, so it cannot be shown to describe this project' });
106
+ if (env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY) { try { expected.publicKeyPem = readFileSync(env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, 'utf8'); expected.requireSigned = true; } catch { problems.push({ kind: 'malformed-export', detail: 'the configured export public key is unreadable' }); } }
107
+ return { exports, sources, problems, expected, trust: [...trust] };
108
+ }
109
+
110
+ /** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
111
+ export function nixClosureOf(files, { now = Date.now() } = {}) {
112
+ return memoized(files, 'nix-closure', () => {
113
+ const { exports, sources, problems, expected, trust } = loadNixExports(files);
114
+ if (!sources.length) return null;
115
+ const closure = importNixClosure({ exports, expected, now });
116
+ const drvEnv = {};
117
+ for (const ex of exports) {
118
+ if (ex.schema !== 'nix-derivation-show-json') continue;
119
+ let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
120
+ for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
121
+ }
122
+ // A disclosure that says the export does not describe THIS project (another lock, another target, a bad or missing required
123
+ // signature) means its contents are not evidence about this build: it is refused, not merely labelled.
124
+ const REFUSE = new Set(['stale-export', 'foreign-target', 'foreign-revision', 'invalid-signature', 'unsigned']);
125
+ const refused = (closure.disclosures || []).filter((d) => REFUSE.has(d.kind));
126
+ return { closure, drvEnv, sources, problems, trust, refused };
127
+ });
128
+ }
129
+
130
+ /** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
131
+ export function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
132
+ const sel = resolveOperatorSnapshot({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
133
+ if (!sel.path) return { data: null, reason: `no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES, or place nix-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? IGNORED_NOTE('nix-advisories.json') : ''}` };
134
+ let snap; try { snap = JSON.parse(readFileSync(sel.path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
135
+ const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
136
+ return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
137
+ }
138
+
139
+ /** Closure statuses and findings, with every disclosure the import and the feed produced. */
140
+ export function analyzeNixClosure(files, opts = {}) {
141
+ return memoized(files, 'nix-analysis', () => _analyzeNixClosure(files, opts));
142
+ }
143
+ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = Date.now() } = {}) {
144
+ const c = nixClosureOf(files, { now });
145
+ if (!c) return null;
146
+ const hackage = configuredAdvisoryDb(scanRoot, env).db;
147
+ const adv = configuredNixAdvisories(scanRoot, env, hackage);
148
+ let usage = null; try { usage = collectUsage(files); } catch { usage = null; }
149
+ let matched = null;
150
+ if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
151
+ const gaps = [];
152
+ if (c.trust.includes('project-supplied') && !c.trust.includes('operator')) gaps.push({ kind: 'closure-project-supplied', detail: 'the closure export is a file in the scanned project, bound to its flake.lock but not signed: whoever controls the project controls its contents, so the ABSENCE of a finding for a component it lists is not evidence the real build is free of it (provide the export through AGENTIC_SECURITY_NIX_EXPORT, signed with AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, to have it treated as operator evidence)' });
153
+ for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
154
+ for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
155
+ for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
156
+ if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
157
+ else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
158
+ const findings = ((matched && matched.findings) || []).filter((f) => ACTIONABLE.has(f.status)).map((f) => ({ ...f, file: c.sources[0], line: 1 }));
159
+ return { status: c.closure.status, claims: c.closure.claims, sources: c.sources, summary: matched ? matched.summary : null, feed: matched ? matched.feed : null, statuses: matched ? matched.statuses : [], findings, gaps, licenses: matched ? matched.licenses : null, closure: c.closure };
160
+ }
161
+
162
+ // ── isolated evaluation (opt in) ─────────────────────────────────────────────────
163
+ /** Runs the isolated evaluation when AGENTIC_SECURITY_NIX_EVAL=1; otherwise null. Never throws; a failure is a recorded state. */
164
+ export async function runSelectedNixEval(root, env = process.env, overrides = {}) {
165
+ if (env.AGENTIC_SECURITY_NIX_EVAL !== '1') return null;
166
+ const attribute = env.AGENTIC_SECURITY_NIX_TARGET || '';
167
+ const nixBin = overrides.nix || (_which ? _which('nix') : null);
168
+ if (!attribute) return { status: 'blocked', reason: 'no evaluation target: set AGENTIC_SECURITY_NIX_TARGET to a flake output attribute (for example nixosConfigurations.host.config.system.build.toplevel.drvPath)', projectCodeEvaluated: false };
169
+ if (!nixBin) return { status: 'unsupported', reason: 'the nix binary was not found on PATH; the project\'s Nix code was NOT evaluated', projectCodeEvaluated: false };
170
+ try { return await runIsolatedEval({ nix: typeof nixBin === 'string' ? { file: nixBin } : nixBin, root, attribute, system: env.AGENTIC_SECURITY_NIX_SYSTEM || null, ...overrides }); }
171
+ catch (e) { return { status: 'failed', reason: `the evaluation harness failed: ${String((e && e.message) || e).slice(0, 160)}`, projectCodeEvaluated: false }; }
172
+ }
173
+
174
+ export { mergeEvaluationHealth };