@clear-capabilities/agentic-security-scanner 0.157.2 → 0.158.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.
@@ -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 });
@@ -306,6 +306,27 @@ class Resolver {
306
306
  return v.known && typeof v.value === 'boolean' ? known(!v.value) : UNK;
307
307
  }
308
308
  case 'binop': return this.evalBinop(n, env);
309
+ // A list or attribute set is known only when EVERY element is statically known (so `[ { from = 1; to = 65535; } ]` is, and a list
310
+ // containing `config.x` or an interpolated string is not). Bounded; recursive sets and dynamic attribute names stay unknown.
311
+ case 'list': {
312
+ if (n.items.length > 256) return UNK;
313
+ const out = [];
314
+ for (const it of n.items) { const v = this.evalExpr(it, env); if (!v.known) return UNK; out.push(v.value); }
315
+ return known(out);
316
+ }
317
+ case 'attrset': {
318
+ if (n.rec || n.errors || n.bindings.length > 256) return UNK;
319
+ const out = {};
320
+ for (const b of n.bindings) {
321
+ if (b.kind !== 'attr' || !b.path.length || !b.path.every((seg) => seg.kind === 'static' && typeof seg.name === 'string')) return UNK;
322
+ const v = this.evalExpr(b.value, env);
323
+ if (!v.known) return UNK;
324
+ let at = out;
325
+ for (const seg of b.path.slice(0, -1)) { if (typeof at[seg.name] !== 'object' || at[seg.name] === null) at[seg.name] = {}; at = at[seg.name]; }
326
+ at[b.path[b.path.length - 1].name] = v.value;
327
+ }
328
+ return known(out);
329
+ }
309
330
  case 'select': {
310
331
  const flags = platformFlags(this.target.system);
311
332
  const b = unparen(n.base);
@@ -360,7 +381,11 @@ class Resolver {
360
381
  const out = [];
361
382
  for (const it of s.items) {
362
383
  const v = it.type === 'string' && !it.interpolated ? known(it.literal) : it.type === 'number' || it.type === 'bool' ? known(it.value) : UNK;
363
- if (!v.known) return UNK;
384
+ if (!v.known) {
385
+ // Not a list of scalars (for example a list of attribute sets): evaluate the whole expression from its source instead.
386
+ const ast = this.parseAt(def.file, def.valueSpan);
387
+ return ast ? this.evalExpr(ast, { file: def.file, namespace: def.namespace, scope: def.scope }) : UNK;
388
+ }
364
389
  out.push(v.value);
365
390
  }
366
391
  return known(out);
@@ -37,6 +37,7 @@ const NIXOS_OPTIONS = {
37
37
  'security.sudo.extraConfig': { type: 'lines', default: same('') },
38
38
  'security.sudo.execWheelOnly': { type: 'bool', default: same(false) },
39
39
  'networking.firewall.allowedTCPPortRanges': { type: 'listOf', default: same([]) },
40
+ 'networking.firewall.allowedUDPPortRanges': { type: 'listOf', default: same([]) },
40
41
  'users.mutableUsers': { type: 'bool', default: same(true) },
41
42
  'nix.settings.sandbox': { type: 'bool', default: same(true) },
42
43
  'nix.settings.trusted-users': { type: 'listOf', default: same(['root']) },
@@ -16,7 +16,7 @@ import { resolveOperatorSnapshot, IGNORED_NOTE } from './trusted-inputs.js';
16
16
  import { analyzeHaskellManifests } from './haskell-manifests.js';
17
17
  import { buildResolvedGraph } from './haskell-resolved-graph.js';
18
18
  import { importNixClosure } from './nix-closure.js';
19
- import { NixAdvisoryData, matchNixVulnerabilities, overlayEvidence } from './nix-sca.js';
19
+ import { NixAdvisoryData, matchNixVulnerabilities, overlayEvidence, HASKELL_ENV_HINTS } from './nix-sca.js';
20
20
  import { configuredAdvisoryDb, collectUsage, manifestFiles } from './haskell-supply.js';
21
21
  import { runIsolatedEval, mergeEvaluationHealth, _which } from './nix-eval-isolation.js';
22
22
 
@@ -107,6 +107,14 @@ function loadNixExports(files, env = process.env) {
107
107
  return { exports, sources, problems, expected, trust: [...trust] };
108
108
  }
109
109
 
110
+ /** Names of the Haskell packages inside an imported Nix closure (derivations whose env carries Haskell build attributes). */
111
+ export function nixHaskellPackageNames(files, opts = {}) {
112
+ const c = nixClosureOf(files, opts);
113
+ if (!c || (c.refused && c.refused.length)) return [];
114
+ const nodes = (c.closure && c.closure.nodes) || [];
115
+ return [...new Set(nodes.filter((n) => n.pname && n.deriver && c.drvEnv[n.deriver] && HASKELL_ENV_HINTS.some((k) => k in c.drvEnv[n.deriver])).map((n) => n.pname))];
116
+ }
117
+
110
118
  /** 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
119
  export function nixClosureOf(files, { now = Date.now() } = {}) {
112
120
  return memoized(files, 'nix-closure', () => {