@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.
- package/CHANGELOG.md +122 -0
- package/bin/agentic-security.js +19 -2
- package/dist/113.index.js +292 -3
- package/dist/207.index.js +7 -4
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/435.index.js +2 -2
- package/dist/526.index.js +292 -3
- package/dist/agentic-security.mjs +23 -62
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +17 -9
- package/src/engine.js +16 -0
- package/src/ir/parser-js.js +8 -0
- package/src/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +83 -6
- package/src/posture/attestation.js +7 -4
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +52 -0
- package/src/posture/custom-rules.js +2 -2
- package/src/posture/execution-proof.js +44 -4
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +76 -2
- package/src/posture/root-cause-sweep.js +0 -0
- package/src/runScan.js +2 -6
- package/src/sandbox/CLAUDE.md +168 -46
- package/src/sandbox/backend-namespace.js +292 -40
- package/src/sandbox/backend-userspace.js +2 -19
- package/src/sandbox/capabilities.js +132 -4
- package/src/sandbox/limits.js +21 -0
- package/src/sandbox/result.js +1 -1
- package/src/util/glob.js +173 -0
|
@@ -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 ?? (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
}
|
package/src/sandbox/limits.js
CHANGED
|
@@ -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,
|
package/src/sandbox/result.js
CHANGED
|
@@ -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]
|
|
66
|
+
stderr: rawStderr + (unsupported.length ? `\n[sandbox] not enforceable here: ${unsupported.join(', ')}` : ''),
|
|
67
67
|
exitCode: r.status ?? null,
|
|
68
68
|
timedOut,
|
|
69
69
|
backend,
|
package/src/util/glob.js
ADDED
|
@@ -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
|
+
}
|