@clear-capabilities/agentic-security-scanner 0.130.0 → 0.132.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.
@@ -14,26 +14,9 @@
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import fs from 'node:fs';
16
16
  import { resolveUserspaceBin } from './capabilities.js';
17
- import { buildLimitPrelude } from './limits.js';
17
+ import { buildLimitPrelude, ambientRelativeMaxProcs } from './limits.js';
18
18
  import { buildResult, errorResult, buildConfinedEnv } from './result.js';
19
19
 
20
- // `ulimit -u` (RLIMIT_NPROC) is a per-uid, system-wide cap on this platform,
21
- // not a per-process-tree cap (verified by execution in Task 2). A fixed
22
- // default like the 64 in buildLimitPrelude() breaks ordinary, non-adversarial
23
- // runs on any host whose user already has more than ~64 ambient processes —
24
- // which is common. So unless the caller passes an explicit maxProcs, we
25
- // compute one relative to the ambient count for this uid, the same
26
- // workaround Task 2 used in its own test, to keep default behavior usable
27
- // without pretending a low fixed cap is real containment.
28
- function _ambientProcCount() {
29
- try {
30
- const out = spawnSync('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
31
- return Number(String(out.stdout || '').trim()) || 200;
32
- } catch {
33
- return 200;
34
- }
35
- }
36
-
37
20
  function _profile({ allowNetwork }) {
38
21
  return [
39
22
  '(version 1)',
@@ -72,7 +55,7 @@ export function runUserspace(argv, {
72
55
 
73
56
  const effectiveLimits = {
74
57
  ...limits,
75
- maxProcs: limits.maxProcs ?? (_ambientProcCount() + 64),
58
+ maxProcs: limits.maxProcs ?? ambientRelativeMaxProcs(),
76
59
  };
77
60
 
78
61
  let prelude, unsupported;
@@ -1,7 +1,28 @@
1
1
  // Detects which OS confinement primitive is available. Fail-closed: when none
2
2
  // is found we report 'disabled', which REFUSES execution rather than running
3
3
  // target code unconfined.
4
+ //
5
+ // DETECTION IS FUNCTIONAL, NOT PRESENCE-BASED. An earlier version concluded
6
+ // "available" from "the confinement binary is executable". That is a different
7
+ // question from the one callers are actually asking. Verified on a Linux CI
8
+ // runner: the kernel-namespace tool is installed and executable, but the
9
+ // distribution restricts unprivileged user-namespace creation, so every
10
+ // privilege variant fails and no confined command can start. Presence-based
11
+ // detection reported the backend as available anyway, and `sandboxAvailable()`
12
+ // — the signal callers use to decide whether it is safe to EXECUTE UNTRUSTED
13
+ // CODE — answered true while nothing could actually be confined. False
14
+ // assurance about confinement is precisely the failure this module exists to
15
+ // prevent, so a backend now counts as available only if it just ran a trivial
16
+ // command through its real code path.
17
+ //
18
+ // The probe result is cached for the process (one spawn, not one per call —
19
+ // detection sits on the path of ordinary scans) and cleared by
20
+ // `resetCapabilityCache()`.
4
21
  import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { runUserspace } from './backend-userspace.js';
25
+ import { runNamespace } from './backend-namespace.js';
5
26
 
6
27
  // Referenced by path, never by product name (see Global Constraints).
7
28
  //
@@ -21,13 +42,49 @@ export const CONFINE_BINS_NAMESPACE = Object.freeze([
21
42
  '/usr/sbin/unshare',
22
43
  ]);
23
44
 
45
+ // The filesystem-attach utility used by the namespace backend to establish
46
+ // write confinement (read-only rebind of the whole mount tree, read-write
47
+ // rebind of the sandbox root). Resolved by path for the same reason as the
48
+ // others. Absent => the namespace backend cannot establish write confinement
49
+ // and fails closed; it never runs a command with the filesystem open.
50
+ export const CONFINE_BINS_MOUNT = Object.freeze([
51
+ '/usr/bin/mount',
52
+ '/bin/mount',
53
+ '/sbin/mount',
54
+ '/usr/sbin/mount',
55
+ ]);
56
+
57
+ // The privilege-dropping utility used to remove CAP_SYS_ADMIN (and everything
58
+ // else) from the confined process *after* the mounts are in place, so the
59
+ // payload cannot simply undo the read-only rebinds. Best-effort hardening on
60
+ // top of the mount confinement, not the confinement itself: when it is absent
61
+ // the run still happens under the read-only mount tree and the result declares
62
+ // `privilegeDrop` unenforced rather than staying silent about it.
63
+ export const CONFINE_BINS_PRIVDROP = Object.freeze([
64
+ '/usr/bin/setpriv',
65
+ '/bin/setpriv',
66
+ '/sbin/setpriv',
67
+ '/usr/sbin/setpriv',
68
+ ]);
69
+
24
70
  // Back-compat single-path exports: the first (canonical) candidate.
25
71
  export const CONFINE_BIN_USERSPACE = CONFINE_BINS_USERSPACE[0];
26
72
  export const CONFINE_BIN_NAMESPACE = CONFINE_BINS_NAMESPACE[0];
27
73
 
28
74
  let _cached = null;
29
75
 
30
- export function resetCapabilityCache() { _cached = null; }
76
+ // Which namespace-flag variant actually works on this host, keyed by the
77
+ // requested confinement shape. Probing costs a process spawn, so it is done
78
+ // once; `undefined` means "not probed yet", `null` means "probed and nothing
79
+ // worked" (which the backend turns into a fail-closed error, never a run).
80
+ const _nsVariant = new Map();
81
+
82
+ export function resetCapabilityCache() { _cached = null; _nsVariant.clear(); }
83
+
84
+ export function cachedNamespaceVariant(key) {
85
+ return _nsVariant.has(key) ? _nsVariant.get(key) : undefined;
86
+ }
87
+ export function cacheNamespaceVariant(key, value) { _nsVariant.set(key, value); }
31
88
 
32
89
  /** First executable candidate, or null when none of them exists. */
33
90
  export function resolveConfineBin(candidates) {
@@ -37,13 +94,84 @@ export function resolveConfineBin(candidates) {
37
94
 
38
95
  export function resolveUserspaceBin() { return resolveConfineBin(CONFINE_BINS_USERSPACE); }
39
96
  export function resolveNamespaceBin() { return resolveConfineBin(CONFINE_BINS_NAMESPACE); }
97
+ export function resolveMountBin() { return resolveConfineBin(CONFINE_BINS_MOUNT); }
98
+ export function resolvePrivDropBin() { return resolveConfineBin(CONFINE_BINS_PRIVDROP); }
40
99
 
41
- export function detectBackend({ force } = {}) {
100
+ // Bounded on purpose: a capability check must never hang a scan. The probe is
101
+ // a single `exit 0` under confinement, so anything beyond a couple of seconds
102
+ // is a host that is not going to answer.
103
+ const PROBE_TIMEOUT_MS = Math.max(
104
+ 250,
105
+ Number(process.env.AGENTIC_SECURITY_SANDBOX_PROBE_TIMEOUT_MS) || 4000,
106
+ );
107
+
108
+ /**
109
+ * Run a trivial command through a backend's real code path and report whether
110
+ * confinement actually worked. Anything other than a clean confined run — a
111
+ * missing binary, a refused namespace, a timeout, a throw — is `false`. There
112
+ * is deliberately no branch that relaxes confinement to make a probe pass: a
113
+ * backend that can only succeed with a flag dropped is not available, it is
114
+ * `'disabled'`.
115
+ */
116
+ function _probeThroughBackend(runner) {
117
+ let root = null;
118
+ try {
119
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'agsec-sbx-probe-'));
120
+ const r = runner(['/bin/sh', '-c', 'exit 0'], { root, timeoutMs: PROBE_TIMEOUT_MS });
121
+ return r?.status === 'ok';
122
+ } catch {
123
+ return false;
124
+ } finally {
125
+ if (root) { try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The real probes, one per backend. The binary check stays only as a cheap
131
+ * pre-filter that avoids a pointless temp dir on a host that plainly lacks the
132
+ * primitive — it is no longer the answer, just the fast negative.
133
+ */
134
+ export function defaultProbes() {
135
+ return {
136
+ userspace: () => (resolveUserspaceBin() ? _probeThroughBackend(runUserspace) : false),
137
+ namespace: () => (resolveNamespaceBin() ? _probeThroughBackend(runNamespace) : false),
138
+ };
139
+ }
140
+
141
+ /** Backends worth probing on a platform, most-appropriate first. */
142
+ export function backendCandidates(platform = process.platform) {
143
+ if (platform === 'darwin') return ['userspace'];
144
+ if (platform === 'linux') return ['namespace'];
145
+ return [];
146
+ }
147
+
148
+ /**
149
+ * @param {object} [o]
150
+ * @param {string} [o.force] Bypass detection entirely (tests, and callers
151
+ * that want the disabled path deliberately).
152
+ * @param {object} [o.probes] Probe map override — a seam for tests to drive
153
+ * the selection contract with stand-ins on any
154
+ * platform. Cannot cause unconfined execution:
155
+ * dispatch still goes to the real backend.
156
+ * @param {string[]} [o.candidates] Candidate order override (same seam).
157
+ */
158
+ export function detectBackend({ force, probes, candidates } = {}) {
42
159
  if (force) return force;
43
160
  if (_cached) return _cached;
161
+
162
+ const probeMap = probes || defaultProbes();
163
+ const order = candidates || backendCandidates();
164
+
44
165
  let b = 'disabled';
45
- if (process.platform === 'darwin' && resolveUserspaceBin()) b = 'userspace';
46
- else if (process.platform === 'linux' && resolveNamespaceBin()) b = 'namespace';
166
+ for (const name of order) {
167
+ const probe = probeMap[name];
168
+ if (typeof probe !== 'function') continue;
169
+ let works = false;
170
+ try { works = probe() === true; } catch { works = false; }
171
+ if (works) { b = name; break; }
172
+ // Otherwise fall through to the next candidate, and ultimately to
173
+ // 'disabled' — never to "run it anyway".
174
+ }
47
175
  _cached = b;
48
176
  return b;
49
177
  }
@@ -4,6 +4,27 @@
4
4
  // verified by execution. We therefore DECLARE it unsupported rather than
5
5
  // emitting a limit that silently does nothing, which would be a false
6
6
  // assurance of containment.
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ // `ulimit -u` (RLIMIT_NPROC) is charged per *uid*, system-wide, on both
10
+ // platforms this module supports — it is not a per-process-tree cap. A fixed
11
+ // default like the 64 below therefore breaks ordinary, non-adversarial runs on
12
+ // any host whose user already owns more than ~64 processes, which is most of
13
+ // them: the confined shell cannot even fork the helpers it needs to set itself
14
+ // up, and the failure looks like a broken sandbox rather than a cap doing its
15
+ // job. So unless a caller passes an explicit `maxProcs`, both real backends
16
+ // derive one from the ambient count for this uid. That keeps default behaviour
17
+ // usable without pretending a low fixed cap is real containment — see the
18
+ // "fork-storm containment is weak" note in the module guide.
19
+ export function ambientRelativeMaxProcs(headroom = 64) {
20
+ let ambient = 200;
21
+ try {
22
+ const out = spawnSync('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
23
+ ambient = Number(String(out.stdout || '').trim()) || 200;
24
+ } catch { /* fall through to the conservative default */ }
25
+ return ambient + headroom;
26
+ }
27
+
7
28
  export function buildLimitPrelude({
8
29
  maxProcs = 64,
9
30
  maxFileSizeKb = 65536,
@@ -63,7 +63,7 @@ export function buildResult({ backend, spawnResult, unsupported = [] }) {
63
63
  status,
64
64
  denied,
65
65
  stdout: r.stdout ?? '',
66
- stderr: rawStderr + (unsupported.length ? `\n[sandbox] limits not enforceable here: ${unsupported.join(', ')}` : ''),
66
+ stderr: rawStderr + (unsupported.length ? `\n[sandbox] not enforceable here: ${unsupported.join(', ')}` : ''),
67
67
  exitCode: r.status ?? null,
68
68
  timedOut,
69
69
  backend,
@@ -0,0 +1,173 @@
1
+ // File discovery on top of the Node standard library only.
2
+ //
3
+ // Two entry points, both replacing a third-party glob package that used to be a
4
+ // production dependency:
5
+ //
6
+ // listFiles(root, {ignore}) — the scan-tree walk behind readScan/readTree.
7
+ // globFiles(pattern, {cwd}) — resolve a user-supplied glob to regular files.
8
+ //
9
+ // `globFiles` is a thin wrapper: `fs.promises.glob` matches the old behaviour
10
+ // for the way those call sites used it (dot:false, onlyFiles:true, symlinks
11
+ // followed), so all it adds is the files-only filter.
12
+ //
13
+ // `listFiles` cannot be a thin wrapper, and the reasons are worth stating
14
+ // precisely, because each one is a way file discovery could silently change:
15
+ //
16
+ // * dot — the built-in glob (like every minimatch-derived matcher) will not
17
+ // let `*` or `**` match a path segment starting with `.`, and exposes no
18
+ // option to change that. The scan tree must include hidden files and must
19
+ // descend into hidden directories, so the walk is done with fs.readdir
20
+ // instead, where visibility is simply not a concept.
21
+ // * onlyFiles — a glob walk yields directories as well; only regular files
22
+ // are wanted. readdir's Dirent gives that directly, with no extra stat.
23
+ // * followSymbolicLinks:false — the built-in glob follows symlinked
24
+ // directories with no option to stop it, which would let a link inside the
25
+ // tree pull unrelated content (or content outside the scan root) into the
26
+ // scan. readdir's Dirent is lstat-derived: a symlink reports isSymbolicLink
27
+ // and neither isDirectory nor isFile, so links are skipped by construction
28
+ // and their targets are never reached.
29
+ // * suppressErrors — an unreadable directory must not abort the walk. Each
30
+ // readdir is individually guarded.
31
+ // * ignore — patterns are still matched with the built-in path.matchesGlob;
32
+ // only the dot-blindness above is compensated for (see unhide()).
33
+ //
34
+ // Ordering matches the previous implementation's breadth-first shape: every
35
+ // entry of one depth is emitted before any entry of the next.
36
+ import * as fs from 'node:fs/promises';
37
+ import * as path from 'node:path';
38
+
39
+ // Two properties of path.matchesGlob have to be corrected, and both are
40
+ // corrected the same way: by rewriting the subject *and* the pattern through
41
+ // the same injective transform, so the built-in matcher does the actual glob
42
+ // work while the transform carries the semantics it will not.
43
+ //
44
+ // 1. Dot-blindness. A wildcard will not match a path segment starting with
45
+ // `.`, and there is no option to change that. Rewriting a segment's leading
46
+ // dot to a control character makes wildcards dot-permissive while keeping
47
+ // an explicitly-named segment literal: pattern `.git` becomes `<1>git`,
48
+ // which still matches a directory named `.git` and still does not match one
49
+ // named `git`. Only applied where dot-permissive matching is wanted.
50
+ // 2. Partial case-folding. The matcher folds case in some positions — e.g.
51
+ // `UPPER/MiXeD.JS` matches `**/*.js` — while `Test/a.js` does not match
52
+ // `**/test/**`. That inconsistency is worse than either rule on its own,
53
+ // and case-folding is not what the previous implementation did. Encoding
54
+ // each upper-case letter as a control character plus its lower-case form
55
+ // makes matching case-sensitive everywhere: no folded comparison can
56
+ // reintroduce the marker.
57
+ //
58
+ // Neither control character can appear in a filename produced by any of the
59
+ // filesystems this runs on, so the transform cannot collide with real content.
60
+ const DOT = String.fromCharCode(1);
61
+ const UP = String.fromCharCode(2);
62
+ const HAS_UPPER = /[A-Z]/;
63
+ const ALL_UPPER = /[A-Z]/g;
64
+ const foldUp = c => UP + c.toLowerCase();
65
+
66
+ function prepPath(p, dot) {
67
+ let s = p;
68
+ if (dot && s.includes('.')) {
69
+ const segs = s.split('/');
70
+ for (let i = 0; i < segs.length; i++) {
71
+ if (segs[i].charCodeAt(0) === 46) segs[i] = DOT + segs[i].slice(1);
72
+ }
73
+ s = segs.join('/');
74
+ }
75
+ return HAS_UPPER.test(s) ? s.replace(ALL_UPPER, foldUp) : s;
76
+ }
77
+
78
+ /**
79
+ * True when `rel` (a '/'-separated relative path) matches any of `patterns`.
80
+ * Case-sensitive; wildcards match hidden segments.
81
+ */
82
+ export function matchesAnyGlob(rel, patterns) {
83
+ if (!patterns || patterns.length === 0) return false;
84
+ const subject = prepPath(rel, true);
85
+ for (const pat of patterns) {
86
+ if (path.matchesGlob(subject, prepPath(pat, true))) return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ // A directory may be skipped wholesale only when a pattern provably excludes
92
+ // every possible descendant. `<prefix>/**` is exactly that shape: `**` absorbs
93
+ // any number of trailing segments, so if the directory matches `<prefix>` then
94
+ // nothing beneath it can survive. Anything else (`<prefix>/*`, `**/*.min.js`)
95
+ // may exclude some descendants and keep others, so those directories are walked
96
+ // and their files filtered individually. Pruning is therefore only ever a
97
+ // speed-up: correctness comes from the per-file check.
98
+ function prunable(subject, prepared) {
99
+ for (const p of prepared) {
100
+ if (p.subtree && path.matchesGlob(subject, p.subtree)) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ function prepare(patterns) {
106
+ return patterns.map(pat => {
107
+ const glob = prepPath(pat, true);
108
+ return { glob, subtree: glob.endsWith('/**') ? glob.slice(0, -3) : null };
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Every regular file under `root`, as '/'-separated paths relative to `root`.
114
+ * Hidden entries included; directories and symlinks excluded; unreadable
115
+ * directories skipped; `ignore` globs applied to each file path.
116
+ */
117
+ export async function listFiles(root, { ignore = [] } = {}) {
118
+ const prepared = prepare(ignore);
119
+ const out = [];
120
+ let level = [''];
121
+ while (level.length) {
122
+ const next = [];
123
+ for (const dir of level) {
124
+ let entries;
125
+ try {
126
+ entries = await fs.readdir(dir ? path.join(root, dir) : root, { withFileTypes: true });
127
+ } catch {
128
+ continue; // unreadable or vanished — skip it, keep walking
129
+ }
130
+ for (const e of entries) {
131
+ const rel = dir ? `${dir}/${e.name}` : e.name;
132
+ const subject = prepared.length ? prepPath(rel, true) : '';
133
+ if (e.isDirectory()) {
134
+ if (!prunable(subject, prepared)) next.push(rel);
135
+ } else if (e.isFile()) {
136
+ let skip = false;
137
+ for (const p of prepared) {
138
+ if (path.matchesGlob(subject, p.glob)) { skip = true; break; }
139
+ }
140
+ if (!skip) out.push(rel);
141
+ }
142
+ // symlinks, sockets, fifos and block/char devices are not files
143
+ }
144
+ }
145
+ level = next;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * Resolve a user-supplied glob to regular files, relative to `cwd`. Hidden
152
+ * paths are not matched (the built-in default) and symlinks are followed, both
153
+ * matching the previous behaviour at these call sites.
154
+ *
155
+ * Two corrections are layered on the built-in walk: results are narrowed to
156
+ * regular files (a glob yields directories too), and the partial case-folding
157
+ * described above is undone by re-checking each result against the pattern
158
+ * case-sensitively — a `.SARIF` file must not answer to `*.sarif`. Symlink
159
+ * cycles are the one place this is deliberately not bug-compatible: the
160
+ * built-in stops at a cycle instead of re-enumerating the tree through it.
161
+ */
162
+ export async function globFiles(pattern, { cwd = process.cwd() } = {}) {
163
+ const strict = prepPath(pattern, false);
164
+ const out = [];
165
+ for await (const p of fs.glob(pattern, { cwd })) {
166
+ if (!path.matchesGlob(prepPath(p, false), strict)) continue;
167
+ try {
168
+ const st = await fs.stat(path.isAbsolute(p) ? p : path.join(cwd, p));
169
+ if (st.isFile()) out.push(p);
170
+ } catch { /* vanished or dangling link — not a file */ }
171
+ }
172
+ return out;
173
+ }