@clear-capabilities/agentic-security-scanner 0.157.2 → 0.159.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 (46) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/1919.index.js +5 -2
  3. package/dist/2862.index.js +29 -9
  4. package/dist/4171.index.js +2 -2
  5. package/dist/{8551.index.js → 7033.index.js} +430 -22
  6. package/dist/9560.index.js +3 -1
  7. package/dist/agentic-security.mjs +15 -15
  8. package/dist/agentic-security.mjs.sha256 +1 -1
  9. package/package.json +7 -5
  10. package/src/dataflow/engine.js +23 -0
  11. package/src/engine.js +7 -1
  12. package/src/language/CLAUDE.md +53 -1
  13. package/src/language/assurance.js +21 -4
  14. package/src/language/context.js +2 -0
  15. package/src/language/cvss.js +91 -0
  16. package/src/language/fix-lifecycle.js +114 -21
  17. package/src/language/haskell-adapter.js +4 -3
  18. package/src/language/haskell-advisory-feed.js +194 -0
  19. package/src/language/haskell-boundaries.js +220 -0
  20. package/src/language/haskell-cpp.js +460 -0
  21. package/src/language/haskell-ir.js +6 -3
  22. package/src/language/haskell-models.js +12 -5
  23. package/src/language/haskell-parser.js +122 -38
  24. package/src/language/haskell-sca.js +28 -10
  25. package/src/language/haskell-security-rules.js +1 -1
  26. package/src/language/haskell-supply.js +70 -8
  27. package/src/language/haskell-web.js +143 -10
  28. package/src/language/nix-advisory-feed.js +275 -0
  29. package/src/language/nix-fix.js +162 -24
  30. package/src/language/nix-ir.js +16 -12
  31. package/src/language/nix-sca.js +80 -15
  32. package/src/language/nixos-eval-forms.js +162 -0
  33. package/src/language/nixos-hardening.js +37 -2
  34. package/src/language/nixos-module-resolver.js +359 -39
  35. package/src/language/nixos-option-catalog.js +1 -0
  36. package/src/language/resolved-pass.js +65 -3
  37. package/src/lsp/server.js +5 -2
  38. package/src/mcp/tools.js +3 -1
  39. package/src/posture/fix-history.js +30 -3
  40. package/src/report/index.js +4 -1
  41. package/dist/1310.index.js +0 -3161
  42. package/dist/2376.index.js +0 -3151
  43. package/dist/3526.index.js +0 -1019
  44. package/dist/7310.index.js +0 -520
  45. package/dist/8520.index.js +0 -13
  46. package/dist/9091.index.js +0 -3135
@@ -0,0 +1,162 @@
1
+ // Pure pieces of the bounded NixOS option evaluator (NIX-002): value classes, equality, and the
2
+ // closed table of library functions the evaluator is willing to run.
3
+ //
4
+ // The evaluator (nixos-module-resolver.js) is a SOUNDNESS-first subset: a value is either fully known
5
+ // or unknown, and any unknown part makes the whole value unknown. This file is the allow-list. A
6
+ // function that is not in these tables is never run, so `import`, `fetch*`, `readFile`, `toFile`,
7
+ // `currentSystem`, `getEnv`, derivations and every other effectful or environment-dependent primitive
8
+ // are unknown by construction (there is no deny-list to forget an entry in).
9
+
10
+ /** Thrown by a table function (and caught by the resolver) when its arguments are not statically usable. */
11
+ export const NOT_STATIC = Object.freeze({ notStatic: true });
12
+
13
+ /** A lambda captured with its definition environment. Never a final option value. */
14
+ export class Closure {
15
+ constructor(param, body, env) { this.param = param; this.body = body; this.env = env; }
16
+ }
17
+
18
+ /** A table function, possibly partially applied. Never a final option value. */
19
+ export class Builtin {
20
+ constructor(name, def, args = []) { this.name = name; this.def = def; this.args = args; }
21
+ }
22
+
23
+ /** A namespace value (`lib`, `builtins`, `lib.strings`, `lib.lists`). Never a final option value. */
24
+ export class Ns {
25
+ constructor(kind) { this.kind = kind; }
26
+ }
27
+
28
+ export const isAttrs = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && Object.getPrototypeOf(v) === Object.prototype;
29
+ export const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
30
+ const isInt = (v) => typeof v === 'number' && Number.isSafeInteger(v);
31
+
32
+ /** Own-property assignment that is safe for the attribute name `__proto__`. */
33
+ export function setAttr(obj, key, value) {
34
+ Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true });
35
+ }
36
+
37
+ /** True when a value contains only data (no closure, partial application or namespace). Bounded. */
38
+ export function isPlain(v, budget = { n: 20_000 }, depth = 0) {
39
+ if (--budget.n < 0 || depth > 48) return false;
40
+ if (v === null || typeof v === 'string' || typeof v === 'boolean' || typeof v === 'number') return true;
41
+ if (Array.isArray(v)) return v.every((x) => isPlain(x, budget, depth + 1));
42
+ if (isAttrs(v)) return Object.keys(v).every((k) => isPlain(v[k], budget, depth + 1));
43
+ return false;
44
+ }
45
+
46
+ /** Nix `==`: true, false, or null when it cannot be decided statically (functions are never compared). */
47
+ export function deepEq(a, b, depth = 0) {
48
+ if (depth > 48) return null;
49
+ const fnA = a instanceof Closure || a instanceof Builtin || a instanceof Ns;
50
+ const fnB = b instanceof Closure || b instanceof Builtin || b instanceof Ns;
51
+ if (fnA || fnB) return null;
52
+ if (Array.isArray(a) || Array.isArray(b)) {
53
+ if (!Array.isArray(a) || !Array.isArray(b)) return false;
54
+ if (a.length !== b.length) return false;
55
+ let unknown = false;
56
+ for (let i = 0; i < a.length; i++) { const r = deepEq(a[i], b[i], depth + 1); if (r === false) return false; if (r === null) unknown = true; }
57
+ return unknown ? null : true;
58
+ }
59
+ if (isAttrs(a) || isAttrs(b)) {
60
+ if (!isAttrs(a) || !isAttrs(b)) return false;
61
+ const ka = Object.keys(a); const kb = Object.keys(b);
62
+ if (ka.length !== kb.length || !ka.every((k) => hasOwn(b, k))) return false;
63
+ let unknown = false;
64
+ for (const k of ka) { const r = deepEq(a[k], b[k], depth + 1); if (r === false) return false; if (r === null) unknown = true; }
65
+ return unknown ? null : true;
66
+ }
67
+ return a === b;
68
+ }
69
+
70
+ const need = (cond) => { if (!cond) throw NOT_STATIC; };
71
+ const MAX_STRING = 65_536;
72
+
73
+ function toStr(v) {
74
+ if (typeof v === 'string') return v;
75
+ if (v === null || v === false) return '';
76
+ if (v === true) return '1';
77
+ if (isInt(v)) return String(v);
78
+ throw NOT_STATIC;
79
+ }
80
+
81
+ // Each entry: arity and a function over the evaluated arguments. `c.apply(f, x)` runs a closure or table
82
+ // function and throws NOT_STATIC if it is not statically known; `c.size(n)` enforces the size cap.
83
+ const DEFS = {
84
+ map: { arity: 2, call: ([f, xs], c) => { need(Array.isArray(xs)); c.size(xs.length); return xs.map((x) => c.apply(f, x)); } },
85
+ filter: {
86
+ arity: 2,
87
+ call: ([f, xs], c) => { need(Array.isArray(xs)); c.size(xs.length); return xs.filter((x) => { const r = c.apply(f, x); need(typeof r === 'boolean'); return r; }); },
88
+ },
89
+ elem: {
90
+ arity: 2,
91
+ call: ([x, xs]) => {
92
+ need(Array.isArray(xs));
93
+ let unknown = false;
94
+ for (const y of xs) { const r = deepEq(x, y); if (r === true) return true; if (r === null) unknown = true; }
95
+ need(!unknown);
96
+ return false;
97
+ },
98
+ },
99
+ length: { arity: 1, call: ([xs]) => { need(Array.isArray(xs)); return xs.length; } },
100
+ concatLists: {
101
+ arity: 1,
102
+ call: ([xss], c) => { need(Array.isArray(xss) && xss.every(Array.isArray)); const out = [].concat(...xss); c.size(out.length); return out; },
103
+ },
104
+ concatMap: {
105
+ arity: 2,
106
+ call: ([f, xs], c) => {
107
+ need(Array.isArray(xs)); c.size(xs.length);
108
+ const parts = xs.map((x) => { const r = c.apply(f, x); need(Array.isArray(r)); return r; });
109
+ const out = [].concat(...parts); c.size(out.length); return out;
110
+ },
111
+ },
112
+ hasAttr: { arity: 2, call: ([name, set]) => { need(typeof name === 'string' && isAttrs(set)); return hasOwn(set, name); } },
113
+ toString: { arity: 1, call: ([v]) => toStr(v) },
114
+ optionals: { arity: 2, call: ([cond, xs]) => { need(typeof cond === 'boolean' && Array.isArray(xs)); return cond ? xs : []; } },
115
+ optional: { arity: 2, call: ([cond, x]) => { need(typeof cond === 'boolean'); return cond ? [x] : []; } },
116
+ optionalAttrs: { arity: 2, call: ([cond, set]) => { need(typeof cond === 'boolean' && isAttrs(set)); return cond ? set : {}; } },
117
+ optionalString: { arity: 2, call: ([cond, s]) => { need(typeof cond === 'boolean' && typeof s === 'string'); return cond ? s : ''; } },
118
+ concatStringsSep: {
119
+ arity: 2,
120
+ call: ([sep, xs], c) => {
121
+ need(typeof sep === 'string' && Array.isArray(xs) && xs.every((x) => typeof x === 'string')); c.size(xs.length);
122
+ const out = xs.join(sep); need(out.length <= MAX_STRING); return out;
123
+ },
124
+ },
125
+ concatMapStringsSep: {
126
+ arity: 3,
127
+ call: ([sep, f, xs], c) => {
128
+ need(typeof sep === 'string' && Array.isArray(xs)); c.size(xs.length);
129
+ const parts = xs.map((x) => { const r = c.apply(f, x); need(typeof r === 'string'); return r; });
130
+ const out = parts.join(sep); need(out.length <= MAX_STRING); return out;
131
+ },
132
+ },
133
+ hasPrefix: { arity: 2, call: ([p, s]) => { need(typeof p === 'string' && typeof s === 'string'); return s.startsWith(p); } },
134
+ hasSuffix: { arity: 2, call: ([p, s]) => { need(typeof p === 'string' && typeof s === 'string'); return s.endsWith(p); } },
135
+ boolToString: { arity: 1, call: ([b]) => { need(typeof b === 'boolean'); return b ? 'true' : 'false'; } },
136
+ // Only a merge of plain lists is a plain list; any other element (an attribute set, a priority or condition
137
+ // wrapper, an unknown) cannot be merged without the option's type, so it is not static.
138
+ mkMerge: { arity: 1, call: ([xss], c) => { need(Array.isArray(xss) && xss.every(Array.isArray)); const out = [].concat(...xss); c.size(out.length); return out; } },
139
+ };
140
+
141
+ const LIB_NAMES = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'optionals', 'optional', 'optionalAttrs', 'optionalString',
142
+ 'concatStringsSep', 'concatMapStringsSep', 'hasPrefix', 'hasSuffix', 'boolToString', 'mkMerge']);
143
+ const LIB_STRINGS = new Set(['concatStringsSep', 'concatMapStringsSep', 'hasPrefix', 'hasSuffix', 'optionalString']);
144
+ const LIB_LISTS = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'optionals', 'optional']);
145
+ const BUILTIN_NAMES = new Set(['map', 'filter', 'elem', 'length', 'concatLists', 'concatMap', 'hasAttr', 'toString']);
146
+ /** Names a Nix program has without any import (the ones we model). */
147
+ const GLOBAL_NAMES = new Set(['map', 'toString']);
148
+
149
+ const SETS = { lib: LIB_NAMES, builtins: BUILTIN_NAMES, 'lib.strings': LIB_STRINGS, 'lib.lists': LIB_LISTS };
150
+
151
+ /** Resolves `<ns>.<name>`: a table function, a sub-namespace, or undefined (not modelled). */
152
+ export function nsLookup(kind, name) {
153
+ if (kind === 'lib' && name === 'strings') return new Ns('lib.strings');
154
+ if (kind === 'lib' && name === 'lists') return new Ns('lib.lists');
155
+ const set = SETS[kind];
156
+ if (!set || !set.has(name)) return undefined;
157
+ return new Builtin(name, DEFS[name]);
158
+ }
159
+
160
+ export const isGlobalName = (name) => GLOBAL_NAMES.has(name);
161
+ export const globalValue = (name) => (name === 'builtins' ? new Ns('builtins') : nsLookup('builtins', name));
162
+ export const MODELLED = Object.freeze({ lib: [...LIB_NAMES].sort(), builtins: [...BUILTIN_NAMES].sort() });
@@ -37,6 +37,7 @@ export const HARDENING_RULES = Object.freeze({
37
37
  'ssh-empty-passwords': { family: 'ssh-access', cwe: 'CWE-258', severity: 'high', ruleVersion: 1, vuln: 'SSH permits empty passwords', why: 'An account with an empty password becomes a login with no secret at all.', fix: 'Remove services.openssh.settings.PermitEmptyPasswords or set it to false.' },
38
38
  'firewall-disabled': { family: 'firewall-exposure', cwe: 'CWE-284', severity: 'high', ruleVersion: 1, vuln: 'Host firewall is disabled', why: 'With the NixOS firewall off every listening service is open on every interface, and the only barrier left is whatever network sits in front of the host.', fix: 'Remove networking.firewall.enable = false and open only the ports each service needs.' },
39
39
  'firewall-sensitive-port': { family: 'firewall-exposure', cwe: 'CWE-668', severity: 'high', ruleVersion: 1, vuln: 'Firewall opens a database or administration port on every interface', why: 'Database, cache and container-control ports are not designed to face untrusted networks; a global allow rule exposes them wherever the host is routable.', fix: 'Remove the port from networking.firewall.allowedTCPPorts/allowedUDPPorts, or scope it with networking.firewall.interfaces.<name>.allowedTCPPorts to a private interface.' },
40
+ 'firewall-wide-port-range': { family: 'firewall-exposure', cwe: 'CWE-668', severity: 'high', ruleVersion: 1, vuln: 'Firewall opens a very wide port range on every interface', why: 'A range that spans every port (or ten thousand or more) is a firewall that is off in everything but name: whatever listens inside it, now or after the next package change, is reachable.', fix: 'Replace the range with the specific ports the services need in networking.firewall.allowedTCPPorts, or scope a genuinely needed range with networking.firewall.interfaces.<name>.allowedTCPPortRanges to a private interface.' },
40
41
  'listener-all-interfaces': { family: 'firewall-exposure', cwe: 'CWE-668', severity: 'medium', ruleVersion: 1, vuln: 'Service is configured to listen on every interface', why: 'Binding all interfaces widens the set of networks that can reach the service beyond the host itself.', fix: 'Leave the listener on localhost, or restrict listen addresses and the firewall to the interface that needs access.' },
41
42
  'listener-trust-auth': { family: 'firewall-exposure', cwe: 'CWE-306', severity: 'high', ruleVersion: 1, vuln: 'Database authentication is "trust" for a network-wide address range', why: 'A trust rule for 0.0.0.0/0 lets any client that reaches the port in as any user without a password.', fix: 'Use scram-sha-256 in services.postgresql.authentication and limit the address range.' },
42
43
  'service-runs-as-root': { family: 'service-identity', cwe: 'CWE-250', severity: 'medium', ruleVersion: 1, vuln: 'systemd service runs as root', why: 'A compromise of a root service is a compromise of the host; a dedicated or dynamic user confines it.', fix: 'Set serviceConfig.DynamicUser = true or serviceConfig.User to a dedicated account, and add NoNewPrivileges and ProtectSystem.' },
@@ -189,14 +190,31 @@ export function analyzeNixosHardening(opts, extra = {}) {
189
190
  const allowedUdp = view(note(nixos('networking.firewall.allowedUDPPorts'), 'firewall'));
190
191
  const globalPorts = (v) => (v.state === 'known' ? asList(v.value) : v.state === 'conditional' ? asList(v.definiteItems) : []);
191
192
  const allowedAll = new Set([...globalPorts(allowedTcp), ...globalPorts(allowedUdp)]);
192
- const allowedKnown = allowedTcp.state === 'known' && allowedUdp.state === 'known';
193
+ // Port RANGES open ports exactly like single ports do. A range option that cannot be evaluated makes every firewall conclusion
194
+ // `unknown` (never "closed"), and a range that cannot be read as {from, to} integers is disclosed, not skipped.
195
+ const tcpRangesRes = note(nixos('networking.firewall.allowedTCPPortRanges'), 'firewall');
196
+ const udpRangesRes = note(nixos('networking.firewall.allowedUDPPortRanges'), 'firewall');
197
+ const readRanges = (res) => {
198
+ const v = view(res);
199
+ if (v.state !== 'known') return { state: v.state === 'conditional' ? 'conditional' : 'unknown', ranges: [] };
200
+ const ranges = [];
201
+ for (const it of asList(v.value)) {
202
+ if (!it || !Number.isInteger(it.from) || !Number.isInteger(it.to) || it.to < it.from) return { state: 'unknown', ranges: [] };
203
+ ranges.push({ from: it.from, to: it.to });
204
+ }
205
+ return { state: 'known', ranges, definite: v.definite };
206
+ };
207
+ const tcpRanges = readRanges(tcpRangesRes);
208
+ const udpRanges = readRanges(udpRangesRes);
209
+ const inTcpRange = (port) => tcpRanges.ranges.some((r) => port >= r.from && port <= r.to);
210
+ const allowedKnown = allowedTcp.state === 'known' && allowedUdp.state === 'known' && tcpRanges.state === 'known' && udpRanges.state === 'known';
193
211
  const interfaceScoped = cfg.options.filter((o) => o.namespace === 'nixos' && /^networking\.firewall\.interfaces\.[^.]+\.allowedTCPPorts$/.test(o.path));
194
212
 
195
213
  /** Host-firewall ingress for a TCP port; internet reachability itself is never decided. */
196
214
  function ingressFor(port, { openFirewallRes } = {}) {
197
215
  if (fwOff === 'yes') return 'firewall-disabled';
198
216
  if (fwOn !== 'yes') return 'unknown';
199
- if (allowedAll.has(port)) return 'open-in-firewall';
217
+ if (allowedAll.has(port) || inTcpRange(port)) return 'open-in-firewall';
200
218
  if (openFirewallRes) {
201
219
  const o = judge(openFirewallRes, (x) => x === true);
202
220
  if (o === 'yes') return 'open-in-firewall';
@@ -412,6 +430,23 @@ export function analyzeNixosHardening(opts, extra = {}) {
412
430
  addFinding('firewall-sensitive-port', { anchor: anchorOf(res), scope: `${res.path}:${port}`, state: v.state === 'known' ? 'active' : 'conditional', uncertain: !v.definite, evidence: [evidenceOf(res)], extra: { port, portService: SENSITIVE_PORTS.get(port), exposure: { enabled: 'unknown', listening: 'unknown', ingress: fwOff === 'yes' ? 'firewall-disabled' : 'open-in-firewall', reachable: 'unknown' } } });
413
431
  }
414
432
  }
433
+ // Port ranges opened on every interface.
434
+ for (const [res, rg, proto] of [[tcpRangesRes, tcpRanges, 'tcp'], [udpRangesRes, udpRanges, 'udp']]) {
435
+ if (!res) continue;
436
+ if (rg.state !== 'known') {
437
+ if (res.status === 'set' || res.status === 'conditional') gaps.push({ kind: 'firewall-port-ranges-unevaluated', option: res.path, purpose: 'firewall', reason: `the ${proto} port ranges could not be evaluated to {from, to} integers, so no conclusion about them is drawn (this is not a clean result)` });
438
+ continue;
439
+ }
440
+ for (const r of rg.ranges) {
441
+ const span = r.to - r.from + 1;
442
+ const full = r.from <= 1 && r.to >= 65535;
443
+ if (full || span >= 10000) {
444
+ addFinding('firewall-wide-port-range', { severity: full ? 'high' : 'medium', anchor: anchorOf(res), scope: `${res.path}:${r.from}-${r.to}`, state: 'active', uncertain: !rg.definite, evidence: [evidenceOf(res)], extra: { portRange: { from: r.from, to: r.to, span, protocol: proto, everyPort: full }, exposure: { enabled: 'unknown', listening: 'unknown', ingress: fwOff === 'yes' ? 'firewall-disabled' : 'open-in-firewall', reachable: 'unknown' } } });
445
+ } else if (proto === 'tcp') {
446
+ for (const [port, svc] of SENSITIVE_PORTS) if (port >= r.from && port <= r.to) addFinding('firewall-sensitive-port', { anchor: anchorOf(res), scope: `${res.path}:${port}`, state: 'active', uncertain: !rg.definite, evidence: [evidenceOf(res)], extra: { port, portService: svc, viaRange: { from: r.from, to: r.to }, exposure: { enabled: 'unknown', listening: 'unknown', ingress: fwOff === 'yes' ? 'firewall-disabled' : 'open-in-firewall', reachable: 'unknown' } } });
447
+ }
448
+ }
449
+ }
415
450
  for (const o of interfaceScoped) {
416
451
  const iface = o.path.split('.')[3];
417
452
  controls.push({ control: 'interface-scoped-ingress', option: o.path, interface: iface, ports: asList(view(o).value), applies: true });