disk-space-saviour 0.15.2 → 0.16.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 +14 -0
- package/README.md +34 -0
- package/bin/dss.js +22 -5
- package/package.json +1 -1
- package/src/audit.js +19 -2
- package/src/clean.js +124 -6
- package/src/cli.js +24 -2
- package/src/docker/cli.js +11 -7
- package/src/docker/scan.js +25 -4
- package/src/docker/upperdir.js +44 -0
- package/src/docker/writable.js +90 -13
- package/src/env/batches.js +20 -0
- package/src/env/cache-roots.js +138 -0
- package/src/env/inspection.js +100 -0
- package/src/env/local.js +69 -32
- package/src/env/proc.js +13 -1
- package/src/env/process-scripts.js +24 -0
- package/src/env/scope.js +95 -0
- package/src/env/shell.js +105 -107
- package/src/exec.js +79 -8
- package/src/health.js +13 -2
- package/src/index.d.ts +25 -1
- package/src/items.js +9 -3
- package/src/liveness.js +112 -25
- package/src/options.js +6 -0
- package/src/report.js +8 -2
- package/src/rules/toolchains.js +66 -0
- package/src/scan.js +112 -7
- package/src/scanners/agents.js +3 -2
- package/src/scanners/common.js +6 -1
- package/src/scanners/global.js +72 -20
- package/src/scanners/projects.js +27 -2
- package/src/scanners/rust.js +7 -1
- package/src/scanners/system.js +7 -6
- package/src/scanners/versions.js +18 -9
package/src/docker/writable.js
CHANGED
|
@@ -3,11 +3,13 @@ import { DockerCli } from './cli.js';
|
|
|
3
3
|
import { isWithin } from '../paths.js';
|
|
4
4
|
import { trace } from '../exec.js';
|
|
5
5
|
import { resetDiffSnapshots } from './snapshots.js';
|
|
6
|
+
import { rootUpperdir, upperdirBytes } from './upperdir.js';
|
|
6
7
|
|
|
7
8
|
let measurementQueue = Promise.resolve();
|
|
8
9
|
const layers = new WeakMap();
|
|
9
10
|
|
|
10
11
|
export function sharedWritableLayer(executor, id) {
|
|
12
|
+
executor = executor.unscoped ?? executor;
|
|
11
13
|
if (!layers.has(executor)) {
|
|
12
14
|
layers.set(executor, new Map());
|
|
13
15
|
}
|
|
@@ -19,12 +21,14 @@ export function sharedWritableLayer(executor, id) {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export function resetWritableLayers(executor) {
|
|
24
|
+
executor = executor.unscoped ?? executor;
|
|
22
25
|
layers.delete(executor);
|
|
23
26
|
resetDiffSnapshots(executor);
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
/** Overlay without an authoritative upper layer must never count merged bytes. */
|
|
27
30
|
export class UnknownWritableLayer {
|
|
31
|
+
source = 'unknown-overlay';
|
|
28
32
|
measure(env, usages) {
|
|
29
33
|
return Promise.resolve(
|
|
30
34
|
new Map(
|
|
@@ -67,14 +71,14 @@ export class WritableLayer {
|
|
|
67
71
|
if (!container) {
|
|
68
72
|
return { diff: null, container };
|
|
69
73
|
}
|
|
70
|
-
const upperdir =
|
|
71
|
-
container.GraphDriver?.Data?.UpperDir ?? this.upperdirCandidate;
|
|
74
|
+
const { upperdir, source } = await this.upperdir(container);
|
|
72
75
|
if (upperdir?.startsWith('/')) {
|
|
73
76
|
const result = await this.docker.executor
|
|
74
77
|
.run(['stat', '-c', '%F', '--', upperdir])
|
|
75
78
|
.catch(() => null);
|
|
76
79
|
if (result?.code === 0 && result.stdout.trim() === 'directory') {
|
|
77
80
|
trace('using authoritative upperdir', this.id);
|
|
81
|
+
this.source = source;
|
|
78
82
|
return { container, upperdir, diff: null };
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -86,6 +90,7 @@ export class WritableLayer {
|
|
|
86
90
|
return { diff: null, container };
|
|
87
91
|
}
|
|
88
92
|
const diff = await this.docker.diff(this.id);
|
|
93
|
+
this.source = diff === null ? 'unknown' : 'docker-diff';
|
|
89
94
|
trace('finished measuring container', this.id);
|
|
90
95
|
// Bound the retained change set; unknown is safer than a partial result.
|
|
91
96
|
return {
|
|
@@ -102,6 +107,27 @@ export class WritableLayer {
|
|
|
102
107
|
return this.snapshotPromise;
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
async upperdir(container) {
|
|
111
|
+
if (container.GraphDriver?.Data?.UpperDir) {
|
|
112
|
+
return {
|
|
113
|
+
upperdir: container.GraphDriver.Data.UpperDir,
|
|
114
|
+
source: 'graphdriver-upperdir',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const pid = container.State?.Pid;
|
|
118
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
119
|
+
const info = await this.docker.executor.run([
|
|
120
|
+
'cat',
|
|
121
|
+
`/proc/${pid}/mountinfo`,
|
|
122
|
+
]);
|
|
123
|
+
const upperdir = info.code === 0 ? rootUpperdir(info.stdout) : null;
|
|
124
|
+
if (upperdir) {
|
|
125
|
+
return { upperdir, source: 'mountinfo-upperdir' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { upperdir: this.upperdirCandidate, source: 'overlay-upperdir' };
|
|
129
|
+
}
|
|
130
|
+
|
|
105
131
|
forget(target, pathApi) {
|
|
106
132
|
this.removed.add(target);
|
|
107
133
|
for (const file of this.stats.keys()) {
|
|
@@ -111,11 +137,55 @@ export class WritableLayer {
|
|
|
111
137
|
}
|
|
112
138
|
}
|
|
113
139
|
|
|
140
|
+
/** Allocated upper blocks before/after deletion, independent of merged data. */
|
|
141
|
+
async allocatedBytes(paths, pathApi) {
|
|
142
|
+
const { container, upperdir } = await this.snapshot();
|
|
143
|
+
if (
|
|
144
|
+
!upperdir ||
|
|
145
|
+
(container.Mounts ?? []).some((mount) =>
|
|
146
|
+
paths.some(
|
|
147
|
+
(target) =>
|
|
148
|
+
isWithin(target, mount.Destination, pathApi) ||
|
|
149
|
+
isWithin(mount.Destination, target, pathApi)
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const measured = await upperdirBytes(
|
|
156
|
+
this.docker.executor,
|
|
157
|
+
paths.map((target) => `${upperdir}/${target.replace(/^\/+/, '')}`)
|
|
158
|
+
);
|
|
159
|
+
const values = [...measured.values()];
|
|
160
|
+
return values.length === paths.length &&
|
|
161
|
+
values.every((value) => value !== null)
|
|
162
|
+
? values.reduce((sum, value) => sum + value, 0)
|
|
163
|
+
: null;
|
|
164
|
+
}
|
|
165
|
+
|
|
114
166
|
async measureUpperdir(env, usages, container, upperdir) {
|
|
115
167
|
const result = new Map();
|
|
116
168
|
const mounts = (container.Mounts ?? [])
|
|
117
169
|
.map((mount) => mount.Destination)
|
|
118
170
|
.filter(Boolean);
|
|
171
|
+
const upperPath = (target) => `${upperdir}/${target.replace(/^\/+/, '')}`;
|
|
172
|
+
const targets = [...usages.keys()].filter(
|
|
173
|
+
(target) => !mounts.some((mount) => isWithin(target, mount, env.path))
|
|
174
|
+
);
|
|
175
|
+
const measurements = await upperdirBytes(
|
|
176
|
+
this.docker.executor,
|
|
177
|
+
targets.map(upperPath)
|
|
178
|
+
);
|
|
179
|
+
const mountRoots = mounts.filter(
|
|
180
|
+
(mount) =>
|
|
181
|
+
targets.some((target) => isWithin(mount, target, env.path)) &&
|
|
182
|
+
!mounts.some(
|
|
183
|
+
(other) => other !== mount && isWithin(mount, other, env.path)
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
const mountedUsage = mountRoots.length
|
|
187
|
+
? await env.rawUsageMany(mountRoots)
|
|
188
|
+
: new Map();
|
|
119
189
|
for (const [target, usage] of usages) {
|
|
120
190
|
if (mounts.some((mount) => isWithin(target, mount, env.path))) {
|
|
121
191
|
result.set(target, {
|
|
@@ -125,17 +195,24 @@ export class WritableLayer {
|
|
|
125
195
|
});
|
|
126
196
|
continue;
|
|
127
197
|
}
|
|
128
|
-
const measured =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
198
|
+
const measured = measurements.get(upperPath(target));
|
|
199
|
+
const contained = mountRoots.filter((mount) =>
|
|
200
|
+
isWithin(mount, target, env.path)
|
|
201
|
+
);
|
|
202
|
+
const known =
|
|
203
|
+
measured !== null &&
|
|
204
|
+
measured !== undefined &&
|
|
205
|
+
contained.every((mount) => mountedUsage.has(mount));
|
|
206
|
+
const bytes = known
|
|
207
|
+
? Math.min(
|
|
208
|
+
usage.bytes,
|
|
209
|
+
measured +
|
|
210
|
+
contained.reduce(
|
|
211
|
+
(sum, mount) => sum + mountedUsage.get(mount).bytes,
|
|
212
|
+
0
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
: 0;
|
|
139
216
|
result.set(target, {
|
|
140
217
|
...usage,
|
|
141
218
|
bytes,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Bound argument count and encoded size below common exec limits. */
|
|
2
|
+
export function batches(items, size = 200, maxBytes = 48000) {
|
|
3
|
+
const result = [];
|
|
4
|
+
let batch = [],
|
|
5
|
+
bytes = 0;
|
|
6
|
+
for (const item of items) {
|
|
7
|
+
const length = Buffer.byteLength(String(item)) + 1;
|
|
8
|
+
if (batch.length && (batch.length >= size || bytes + length > maxBytes)) {
|
|
9
|
+
result.push(batch);
|
|
10
|
+
batch = [];
|
|
11
|
+
bytes = 0;
|
|
12
|
+
}
|
|
13
|
+
batch.push(item);
|
|
14
|
+
bytes += length;
|
|
15
|
+
}
|
|
16
|
+
if (batch.length) {
|
|
17
|
+
result.push(batch);
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Resolve cache configuration once per environment, using only that user's tools. */
|
|
2
|
+
export const CACHE_VARIABLES = [
|
|
3
|
+
'LOCALAPPDATA',
|
|
4
|
+
'APPDATA',
|
|
5
|
+
'TMPDIR',
|
|
6
|
+
'XDG_CACHE_HOME',
|
|
7
|
+
'CARGO_HOME',
|
|
8
|
+
'RUSTUP_HOME',
|
|
9
|
+
'GRADLE_USER_HOME',
|
|
10
|
+
'DENO_DIR',
|
|
11
|
+
'BUN_INSTALL_CACHE_DIR',
|
|
12
|
+
'GOMODCACHE',
|
|
13
|
+
'GOCACHE',
|
|
14
|
+
'GOPATH',
|
|
15
|
+
'NVM_DIR',
|
|
16
|
+
'SDKMAN_DIR',
|
|
17
|
+
'PERLBREW_ROOT',
|
|
18
|
+
'RBENV_ROOT',
|
|
19
|
+
'GEM_HOME',
|
|
20
|
+
'GEM_PATH',
|
|
21
|
+
'npm_config_cache',
|
|
22
|
+
'NPM_CONFIG_CACHE',
|
|
23
|
+
'PIP_CACHE_DIR',
|
|
24
|
+
'YARN_CACHE_FOLDER',
|
|
25
|
+
'PNPM_STORE_DIR',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const ROOTS = [
|
|
29
|
+
['~/.cargo', 'CARGO_HOME'],
|
|
30
|
+
['~/.rustup', 'RUSTUP_HOME'],
|
|
31
|
+
['~/.gradle', 'GRADLE_USER_HOME'],
|
|
32
|
+
['~/.nvm', 'NVM_DIR'],
|
|
33
|
+
['~/.sdkman', 'SDKMAN_DIR'],
|
|
34
|
+
['~/.perl5', 'PERLBREW_ROOT'],
|
|
35
|
+
['~/perl5/perlbrew', 'PERLBREW_ROOT'],
|
|
36
|
+
['~/.rbenv', 'RBENV_ROOT'],
|
|
37
|
+
['~/.bun/install/cache', 'BUN_INSTALL_CACHE_DIR'],
|
|
38
|
+
['~/.cache/deno', 'DENO_DIR'],
|
|
39
|
+
['~/.npm', 'npm_config_cache', 'NPM_CONFIG_CACHE'],
|
|
40
|
+
['~/.cache/pip', 'PIP_CACHE_DIR'],
|
|
41
|
+
['~/.cache/yarn', 'YARN_CACHE_FOLDER'],
|
|
42
|
+
['~/.local/share/pnpm/store', 'PNPM_STORE_DIR'],
|
|
43
|
+
['~/go/pkg/mod', 'GOMODCACHE'],
|
|
44
|
+
['~/.cache/go-build', 'GOCACHE'],
|
|
45
|
+
['~/Library/Caches/go-build', 'GOCACHE'],
|
|
46
|
+
['{LOCALAPPDATA}/go-build', 'GOCACHE'],
|
|
47
|
+
['~/Library/Caches/deno', 'DENO_DIR'],
|
|
48
|
+
['{LOCALAPPDATA}/deno', 'DENO_DIR'],
|
|
49
|
+
['{LOCALAPPDATA}/npm-cache', 'npm_config_cache', 'NPM_CONFIG_CACHE'],
|
|
50
|
+
['~/Library/Caches/pip', 'PIP_CACHE_DIR'],
|
|
51
|
+
['{LOCALAPPDATA}/pip/Cache', 'PIP_CACHE_DIR'],
|
|
52
|
+
['~/Library/Caches/Yarn', 'YARN_CACHE_FOLDER'],
|
|
53
|
+
['{LOCALAPPDATA}/Yarn/Cache', 'YARN_CACHE_FOLDER'],
|
|
54
|
+
['~/Library/pnpm/store', 'PNPM_STORE_DIR'],
|
|
55
|
+
['~/.pnpm-store', 'PNPM_STORE_DIR'],
|
|
56
|
+
['{LOCALAPPDATA}/pnpm/store', 'PNPM_STORE_DIR'],
|
|
57
|
+
['~/.cache', 'XDG_CACHE_HOME'],
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
export function configuredPattern(env, home, pattern) {
|
|
61
|
+
if (home !== env.currentHome) {
|
|
62
|
+
return pattern;
|
|
63
|
+
}
|
|
64
|
+
for (const [prefix, ...keys] of ROOTS) {
|
|
65
|
+
if (pattern !== prefix && !pattern.startsWith(`${prefix}/`)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const root = keys
|
|
69
|
+
.map((key) => env.vars?.[key])
|
|
70
|
+
.find((value) => value && env.path.isAbsolute(value));
|
|
71
|
+
if (root) {
|
|
72
|
+
return env.path.join(root, pattern.slice(prefix.length));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return pattern;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function applyQuery(vars, keys, values, pathApi) {
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
if (
|
|
81
|
+
!vars[key] &&
|
|
82
|
+
typeof values[key] === 'string' &&
|
|
83
|
+
pathApi.isAbsolute(values[key])
|
|
84
|
+
) {
|
|
85
|
+
vars[key] = values[key];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function configureCacheRoots(env, homes) {
|
|
91
|
+
if (env.variables) {
|
|
92
|
+
Object.assign(env.vars, await env.variables(CACHE_VARIABLES));
|
|
93
|
+
}
|
|
94
|
+
if (!homes.includes(env.currentHome)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const vars = env.vars;
|
|
98
|
+
if (!vars.GOMODCACHE && vars.GOPATH) {
|
|
99
|
+
vars.GOMODCACHE = env.path.join(
|
|
100
|
+
vars.GOPATH.split(env.platform === 'win32' ? ';' : ':')[0],
|
|
101
|
+
'pkg/mod'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const queries = [
|
|
105
|
+
[
|
|
106
|
+
'go',
|
|
107
|
+
['go', 'env', '-json', 'GOMODCACHE', 'GOCACHE'],
|
|
108
|
+
['GOMODCACHE', 'GOCACHE'],
|
|
109
|
+
],
|
|
110
|
+
['npm', ['npm', 'config', 'get', 'cache'], ['npm_config_cache']],
|
|
111
|
+
['pip', ['pip', 'cache', 'dir'], ['PIP_CACHE_DIR']],
|
|
112
|
+
['yarn', ['yarn', 'cache', 'dir'], ['YARN_CACHE_FOLDER']],
|
|
113
|
+
['pnpm', ['pnpm', 'store', 'path'], ['PNPM_STORE_DIR']],
|
|
114
|
+
];
|
|
115
|
+
for (const [tool, argv, keys] of queries) {
|
|
116
|
+
if (
|
|
117
|
+
keys.every((key) => vars[key]) ||
|
|
118
|
+
(tool === 'npm' && vars.NPM_CONFIG_CACHE) ||
|
|
119
|
+
!(await env.which(tool))
|
|
120
|
+
) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const result = await env.run(argv, { timeoutMs: 5000 });
|
|
124
|
+
if (result.code !== 0) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
let values;
|
|
128
|
+
try {
|
|
129
|
+
values =
|
|
130
|
+
tool === 'go'
|
|
131
|
+
? JSON.parse(result.stdout)
|
|
132
|
+
: { [keys[0]]: result.stdout.trim().split('\n').at(-1) };
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
applyQuery(vars, keys, values, env.path);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { OPEN_PATHS_SCRIPT } from './process-scripts.js';
|
|
2
|
+
import { trace } from '../exec.js';
|
|
3
|
+
|
|
4
|
+
export function parseProcessProbe(result) {
|
|
5
|
+
const gids = new Map(
|
|
6
|
+
result.stderr
|
|
7
|
+
.split('\n')
|
|
8
|
+
.filter((line) => line.startsWith('DSS_GID|'))
|
|
9
|
+
.map((line) => {
|
|
10
|
+
const [, pid, gid] = line.split('|');
|
|
11
|
+
return [Number(pid), gid ? Number(gid) : null];
|
|
12
|
+
})
|
|
13
|
+
);
|
|
14
|
+
return {
|
|
15
|
+
paths: new Set(
|
|
16
|
+
result.stdout
|
|
17
|
+
.split('\n')
|
|
18
|
+
.filter((line) => line.startsWith('/'))
|
|
19
|
+
.map((line) => line.replace(/ \(deleted\)$/, ''))
|
|
20
|
+
),
|
|
21
|
+
unreadable: result.stderr
|
|
22
|
+
.split('\n')
|
|
23
|
+
.filter((line) => line.startsWith('DSS_UNREADABLE|'))
|
|
24
|
+
.map((line) => {
|
|
25
|
+
const [, pid, uid, name, ...command] = line.split('|');
|
|
26
|
+
return {
|
|
27
|
+
pid: Number(pid),
|
|
28
|
+
uid: uid ? Number(uid) : null,
|
|
29
|
+
gid: gids.get(Number(pid)) ?? null,
|
|
30
|
+
name,
|
|
31
|
+
command: command.join('|'),
|
|
32
|
+
};
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Root without ptrace can often inspect a dumpable process as its own UID/GID. */
|
|
38
|
+
export async function retryProcessOwners(executor, evidence) {
|
|
39
|
+
const groups = new Map();
|
|
40
|
+
for (const proc of evidence.unreadable) {
|
|
41
|
+
if (
|
|
42
|
+
!Number.isInteger(proc.uid) ||
|
|
43
|
+
!Number.isInteger(proc.gid) ||
|
|
44
|
+
proc.uid === 0
|
|
45
|
+
) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const key = `${proc.uid}:${proc.gid}`;
|
|
49
|
+
groups.set(key, [...(groups.get(key) ?? []), proc]);
|
|
50
|
+
}
|
|
51
|
+
const unresolved = new Map(
|
|
52
|
+
evidence.unreadable.map((proc) => [proc.pid, proc])
|
|
53
|
+
);
|
|
54
|
+
for (const [identity, procs] of groups) {
|
|
55
|
+
const [uid, gid] = identity.split(':');
|
|
56
|
+
trace(
|
|
57
|
+
'process inspection as owning uid/gid',
|
|
58
|
+
identity,
|
|
59
|
+
procs.map((proc) => proc.pid)
|
|
60
|
+
);
|
|
61
|
+
const result = await executor.run([
|
|
62
|
+
'setpriv',
|
|
63
|
+
`--reuid=${uid}`,
|
|
64
|
+
`--regid=${gid}`,
|
|
65
|
+
'--clear-groups',
|
|
66
|
+
'sh',
|
|
67
|
+
'-c',
|
|
68
|
+
OPEN_PATHS_SCRIPT,
|
|
69
|
+
'dss',
|
|
70
|
+
...procs.map((proc) => `/proc/${proc.pid}`),
|
|
71
|
+
]);
|
|
72
|
+
const retried = parseProcessProbe(result);
|
|
73
|
+
for (const target of retried.paths) {
|
|
74
|
+
evidence.paths.add(target);
|
|
75
|
+
}
|
|
76
|
+
if (result.code === 0 || retried.unreadable.length) {
|
|
77
|
+
for (const proc of procs) {
|
|
78
|
+
unresolved.delete(proc.pid);
|
|
79
|
+
}
|
|
80
|
+
for (const proc of retried.unreadable) {
|
|
81
|
+
unresolved.set(proc.pid, proc);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { paths: evidence.paths, unreadable: [...unresolved.values()] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function inspectionHint(unreadable) {
|
|
89
|
+
if (!unreadable.length) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const identities = unreadable
|
|
93
|
+
.slice(0, 5)
|
|
94
|
+
.map(
|
|
95
|
+
(proc) =>
|
|
96
|
+
`${proc.name || 'unknown'} pid ${proc.pid} uid ${proc.uid ?? '?'} gid ${proc.gid ?? '?'}`
|
|
97
|
+
)
|
|
98
|
+
.join('; ');
|
|
99
|
+
return `${unreadable.length} processes are unreadable (${identities}). Activity uncertainty is scoped to daemon storage and accessible paths. Inspect as the owning UID/GID (setpriv), or grant CAP_SYS_PTRACE for read-only inspection; root alone may lack that capability.`;
|
|
100
|
+
}
|
package/src/env/local.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { checkSignal } from './scope.js';
|
|
2
|
+
import { retryProcessOwners, inspectionHint } from './inspection.js';
|
|
1
3
|
/**
|
|
2
4
|
* Base names of a process's executable and `argv[0]`, without duplicates
|
|
3
5
|
* and without the ` (deleted)` suffix of replaced binaries.
|
|
@@ -58,6 +60,7 @@ import os from 'node:os';
|
|
|
58
60
|
import path from 'node:path';
|
|
59
61
|
import { URL, fileURLToPath } from 'node:url';
|
|
60
62
|
import { procIdentity, procOpenPaths } from './proc.js';
|
|
63
|
+
import { CACHE_VARIABLES } from './cache-roots.js';
|
|
61
64
|
|
|
62
65
|
import { hostExecutor } from '../exec.js';
|
|
63
66
|
import { matchesGlob } from '../paths.js';
|
|
@@ -189,11 +192,11 @@ export class LocalEnv {
|
|
|
189
192
|
this.protectedPaths = [fileURLToPath(new URL('../../', import.meta.url))];
|
|
190
193
|
this.currentHome = os.homedir();
|
|
191
194
|
this.skipNames = new Set(options.skipNames ?? DEFAULT_SKIP_NAMES);
|
|
192
|
-
this.vars =
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
this.vars =
|
|
196
|
+
options.vars ??
|
|
197
|
+
Object.fromEntries(
|
|
198
|
+
CACHE_VARIABLES.map((name) => [name, process.env[name]])
|
|
199
|
+
);
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
recordAccessError(target, error) {
|
|
@@ -251,21 +254,26 @@ export class LocalEnv {
|
|
|
251
254
|
mtimeMs: stats.mtimeMs,
|
|
252
255
|
inode: `${stats.dev}:${stats.ino}`,
|
|
253
256
|
uid: stats.uid,
|
|
257
|
+
gid: stats.gid,
|
|
258
|
+
mode: stats.mode,
|
|
254
259
|
device: stats.dev,
|
|
255
260
|
};
|
|
256
261
|
}
|
|
257
262
|
|
|
258
263
|
async readText(target, maxBytes = 1024 * 1024) {
|
|
264
|
+
checkSignal(this.signal);
|
|
259
265
|
try {
|
|
260
266
|
const handle = await fsp.open(target, 'r');
|
|
261
267
|
try {
|
|
262
268
|
const buffer = Buffer.alloc(maxBytes);
|
|
263
269
|
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
|
270
|
+
checkSignal(this.signal);
|
|
264
271
|
return buffer.subarray(0, bytesRead).toString('utf8');
|
|
265
272
|
} finally {
|
|
266
273
|
await handle.close();
|
|
267
274
|
}
|
|
268
275
|
} catch {
|
|
276
|
+
checkSignal(this.signal);
|
|
269
277
|
return null;
|
|
270
278
|
}
|
|
271
279
|
}
|
|
@@ -394,6 +402,7 @@ export class LocalEnv {
|
|
|
394
402
|
};
|
|
395
403
|
let frontier = root.isDirectory() ? [target] : [];
|
|
396
404
|
while (frontier.length > 0) {
|
|
405
|
+
checkSignal(this.signal);
|
|
397
406
|
const next = [];
|
|
398
407
|
for (let index = 0; index < frontier.length; index += WALK_BATCH) {
|
|
399
408
|
const found = await Promise.all(
|
|
@@ -475,6 +484,7 @@ export class LocalEnv {
|
|
|
475
484
|
roots.map(async (root) => [root, 0, await this.deviceId(root)])
|
|
476
485
|
);
|
|
477
486
|
while (queue.length > 0) {
|
|
487
|
+
checkSignal(this.signal);
|
|
478
488
|
const [dir, depth, device] = queue.shift();
|
|
479
489
|
if (visited.has(dir)) {
|
|
480
490
|
continue;
|
|
@@ -613,11 +623,15 @@ export class LocalEnv {
|
|
|
613
623
|
|
|
614
624
|
async #linuxProcesses() {
|
|
615
625
|
const io = this.options.procFs ?? fsp;
|
|
626
|
+
this.mountNamespace = await io
|
|
627
|
+
.readlink('/proc/self/ns/mnt')
|
|
628
|
+
.catch(() => null);
|
|
616
629
|
const pids = (await io.readdir('/proc')).filter((name) =>
|
|
617
630
|
/^\d+$/.test(name)
|
|
618
631
|
);
|
|
619
632
|
const processes = [];
|
|
620
633
|
for (const pid of pids) {
|
|
634
|
+
checkSignal(this.signal);
|
|
621
635
|
const base = `/proc/${pid}`;
|
|
622
636
|
const stat = parseProcStat(
|
|
623
637
|
await io.readFile(`${base}/stat`, 'utf8').catch(() => '')
|
|
@@ -641,6 +655,7 @@ export class LocalEnv {
|
|
|
641
655
|
...(await procIdentity(base, io)),
|
|
642
656
|
name,
|
|
643
657
|
startTime: stat.startTime,
|
|
658
|
+
mountNamespace: await io.readlink(`${base}/ns/mnt`).catch(() => null),
|
|
644
659
|
cwd,
|
|
645
660
|
aliases: processAliases(exe, cmdline.split('\0')[0]),
|
|
646
661
|
command: cmdline.replaceAll('\0', ' '),
|
|
@@ -658,29 +673,8 @@ export class LocalEnv {
|
|
|
658
673
|
async openPaths({ strict = true } = {}) {
|
|
659
674
|
try {
|
|
660
675
|
let paths = await this.rawOpenPaths({ strict });
|
|
661
|
-
if (strict
|
|
662
|
-
|
|
663
|
-
if (privileged) {
|
|
664
|
-
this.unreadableProcesses =
|
|
665
|
-
this.processInspector.unreadableProcesses ?? [];
|
|
666
|
-
paths = privileged;
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
if (strict && this.unreadableProcesses?.length) {
|
|
670
|
-
const { OPEN_PATHS_SCRIPT } = await import('./shell.js');
|
|
671
|
-
const elevated = await this.run([
|
|
672
|
-
'sudo',
|
|
673
|
-
'-n',
|
|
674
|
-
'sh',
|
|
675
|
-
'-c',
|
|
676
|
-
OPEN_PATHS_SCRIPT,
|
|
677
|
-
]);
|
|
678
|
-
if (elevated.code === 0) {
|
|
679
|
-
paths = new Set(
|
|
680
|
-
elevated.stdout.split('\n').filter((line) => line.startsWith('/'))
|
|
681
|
-
);
|
|
682
|
-
this.unreadableProcesses = [];
|
|
683
|
-
}
|
|
676
|
+
if (strict) {
|
|
677
|
+
paths = await this.#completeInspection(paths);
|
|
684
678
|
}
|
|
685
679
|
this.#processHint();
|
|
686
680
|
return paths;
|
|
@@ -688,6 +682,9 @@ export class LocalEnv {
|
|
|
688
682
|
if (this.processInspector) {
|
|
689
683
|
const paths = await this.processInspector.openPaths();
|
|
690
684
|
this.probeDiagnostic = this.processInspector.probeDiagnostic;
|
|
685
|
+
this.unreadableProcesses =
|
|
686
|
+
this.processInspector.unreadableProcesses ?? [];
|
|
687
|
+
this.#processHint();
|
|
691
688
|
return paths;
|
|
692
689
|
}
|
|
693
690
|
this.probeDiagnostic = error.message;
|
|
@@ -695,16 +692,56 @@ export class LocalEnv {
|
|
|
695
692
|
}
|
|
696
693
|
}
|
|
697
694
|
|
|
695
|
+
async #completeInspection(paths) {
|
|
696
|
+
paths = await this.#inspectOwners(paths);
|
|
697
|
+
if (this.unreadableProcesses?.length && this.processInspector) {
|
|
698
|
+
const privileged = await this.processInspector.openPaths();
|
|
699
|
+
if (privileged) {
|
|
700
|
+
this.unreadableProcesses =
|
|
701
|
+
this.processInspector.unreadableProcesses ?? [];
|
|
702
|
+
paths = new Set([...(paths ?? []), ...privileged]);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (this.unreadableProcesses?.length) {
|
|
706
|
+
const { OPEN_PATHS_SCRIPT } = await import('./shell.js');
|
|
707
|
+
const elevated = await this.run([
|
|
708
|
+
'sudo',
|
|
709
|
+
'-n',
|
|
710
|
+
'sh',
|
|
711
|
+
'-c',
|
|
712
|
+
OPEN_PATHS_SCRIPT,
|
|
713
|
+
]);
|
|
714
|
+
if (elevated.code === 0) {
|
|
715
|
+
paths = new Set(
|
|
716
|
+
elevated.stdout.split('\n').filter((line) => line.startsWith('/'))
|
|
717
|
+
);
|
|
718
|
+
this.unreadableProcesses = [];
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return paths;
|
|
722
|
+
}
|
|
723
|
+
|
|
698
724
|
#processHint() {
|
|
699
|
-
this.probeHint = this.unreadableProcesses
|
|
700
|
-
|
|
701
|
-
|
|
725
|
+
this.probeHint = inspectionHint(this.unreadableProcesses ?? []);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async #inspectOwners(paths) {
|
|
729
|
+
if (!this.unreadableProcesses?.length || !(await this.isRoot())) {
|
|
730
|
+
return paths;
|
|
731
|
+
}
|
|
732
|
+
const evidence = await retryProcessOwners(this.executor, {
|
|
733
|
+
paths,
|
|
734
|
+
unreadable: this.unreadableProcesses,
|
|
735
|
+
});
|
|
736
|
+
this.unreadableProcesses = evidence.unreadable;
|
|
737
|
+
return evidence.paths;
|
|
702
738
|
}
|
|
703
739
|
|
|
704
740
|
async rawOpenPaths({ strict = true } = {}) {
|
|
705
741
|
if (this.platform === 'linux') {
|
|
706
742
|
const { paths, unreadable } = await procOpenPaths(
|
|
707
|
-
this.options.procFs ?? fsp
|
|
743
|
+
this.options.procFs ?? fsp,
|
|
744
|
+
this.signal
|
|
708
745
|
);
|
|
709
746
|
this.unreadableProcesses = strict ? unreadable : [];
|
|
710
747
|
return paths;
|
package/src/env/proc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Linux proc inspection preserves evidence when only some PIDs are denied. */
|
|
2
2
|
import { promises as fsp } from 'node:fs';
|
|
3
|
+
import { checkSignal } from './scope.js';
|
|
3
4
|
|
|
4
5
|
export async function procIdentity(base, io = fsp) {
|
|
5
6
|
const read = (name) => io.readFile(`${base}/${name}`, 'utf8').catch(() => '');
|
|
@@ -9,20 +10,30 @@ export async function procIdentity(base, io = fsp) {
|
|
|
9
10
|
read('cmdline'),
|
|
10
11
|
]);
|
|
11
12
|
const uid = /^Uid:\s+(\d+)/m.exec(status)?.[1];
|
|
13
|
+
const gid = /^Gid:\s+(\d+)/m.exec(status)?.[1];
|
|
12
14
|
return {
|
|
13
15
|
pid: Number(base.split('/').at(-1)),
|
|
14
16
|
uid: uid === undefined ? null : Number(uid),
|
|
17
|
+
gid: gid === undefined ? null : Number(gid),
|
|
18
|
+
groups:
|
|
19
|
+
/^Groups:\s*(.*)$/m
|
|
20
|
+
.exec(status)?.[1]
|
|
21
|
+
.trim()
|
|
22
|
+
.split(/\s+/)
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map(Number) ?? null,
|
|
15
25
|
name: comm.trim(),
|
|
16
26
|
argv: command.split('\0').filter(Boolean),
|
|
17
27
|
command: command.replaceAll('\0', ' '),
|
|
18
28
|
};
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
export async function procOpenPaths(io = fsp) {
|
|
31
|
+
export async function procOpenPaths(io = fsp, signal) {
|
|
22
32
|
const paths = new Set();
|
|
23
33
|
const unreadable = [];
|
|
24
34
|
const pids = (await io.readdir('/proc')).filter((pid) => /^\d+$/.test(pid));
|
|
25
35
|
for (const pid of pids) {
|
|
36
|
+
checkSignal(signal);
|
|
26
37
|
const base = `/proc/${pid}`;
|
|
27
38
|
const stat = await io.readFile(`${base}/stat`, 'utf8').catch(() => '');
|
|
28
39
|
if (stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z ')) {
|
|
@@ -40,6 +51,7 @@ export async function procOpenPaths(io = fsp) {
|
|
|
40
51
|
return [];
|
|
41
52
|
});
|
|
42
53
|
for (const link of ['cwd', 'exe', ...fds.map((fd) => `fd/${fd}`)]) {
|
|
54
|
+
checkSignal(signal);
|
|
43
55
|
const target = await io.readlink(`${base}/${link}`).catch(failure);
|
|
44
56
|
if (target.startsWith('/')) {
|
|
45
57
|
paths.add(target.replace(/ \(deleted\)$/, ''));
|