disk-space-saviour 0.15.2 → 0.17.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 +20 -0
- package/README.md +74 -1
- package/bin/dss.js +22 -5
- package/package.json +1 -1
- package/src/audit.js +19 -2
- package/src/clean.js +153 -11
- package/src/cli.js +102 -14
- 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/emergency.js +37 -6
- 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 +314 -96
- package/src/env/parallel.js +23 -0
- package/src/env/platform-index.js +87 -0
- 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 +146 -108
- package/src/exec.js +79 -8
- package/src/fast-clean.js +39 -0
- package/src/health.js +16 -5
- package/src/index.d.ts +52 -1
- package/src/items.js +9 -3
- package/src/liveness.js +112 -25
- package/src/options.js +9 -1
- package/src/report.js +25 -3
- package/src/rules/toolchains.js +66 -0
- package/src/scan-state.js +239 -0
- package/src/scan.js +240 -19
- package/src/scanners/agents.js +3 -2
- package/src/scanners/common.js +6 -1
- package/src/scanners/global.js +91 -22
- package/src/scanners/projects.js +114 -20
- package/src/scanners/rust.js +7 -1
- package/src/scanners/system.js +7 -6
- package/src/scanners/versions.js +25 -13
- package/src/swap.js +69 -0
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\)$/, ''));
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Read-only probes shared by local identity retries and Docker adapters. */
|
|
2
|
+
export const OPEN_PATHS_SCRIPT = `failed=0
|
|
3
|
+
[ "$#" -gt 0 ] || set -- /proc/[0-9]*
|
|
4
|
+
for p do
|
|
5
|
+
[ -d "$p" ] || continue
|
|
6
|
+
state=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1)
|
|
7
|
+
[ "$state" = Z ] && continue
|
|
8
|
+
unreadable=0
|
|
9
|
+
ls "$p/fd" >/dev/null 2>&1 || unreadable=1
|
|
10
|
+
for l in "$p/cwd" "$p/exe" "$p"/fd/*; do
|
|
11
|
+
[ -L "$l" ] || continue
|
|
12
|
+
readlink "$l" 2>/dev/null || { [ -L "$l" ] && unreadable=1; }
|
|
13
|
+
done
|
|
14
|
+
if [ "$unreadable" = 1 ] && [ -d "$p" ]; then
|
|
15
|
+
printf 'unreadable pid %s (%s)\\n' "\${p#/proc/}" "$(cat "$p/comm" 2>/dev/null)" >&2
|
|
16
|
+
uid=$(awk '/^Uid:/{print $2}' "$p/status" 2>/dev/null)
|
|
17
|
+
gid=$(awk '/^Gid:/{print $2}' "$p/status" 2>/dev/null)
|
|
18
|
+
printf 'DSS_GID|%s|%s\\n' "\${p#/proc/}" "$gid" >&2
|
|
19
|
+
command=$(tr '\\000' ' ' < "$p/cmdline" 2>/dev/null)
|
|
20
|
+
printf 'DSS_UNREADABLE|%s|%s|%s|%s\\n' "\${p#/proc/}" "$uid" "$(cat "$p/comm" 2>/dev/null)" "$command" >&2
|
|
21
|
+
failed=1
|
|
22
|
+
fi
|
|
23
|
+
done
|
|
24
|
+
exit "$failed"`;
|
package/src/env/scope.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Scan cancellation and deadlines shared by local and remote adapters. */
|
|
2
|
+
export function checkSignal(signal) {
|
|
3
|
+
if (signal?.aborted) {
|
|
4
|
+
const error = new Error(signal.reason?.message ?? 'scan aborted');
|
|
5
|
+
error.name = 'AbortError';
|
|
6
|
+
throw error;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function signalExecutor(executor, signal, deadline = Infinity) {
|
|
11
|
+
const base = executor.unscoped ?? executor;
|
|
12
|
+
return {
|
|
13
|
+
...executor,
|
|
14
|
+
unscoped: executor.unscoped ?? executor,
|
|
15
|
+
signal,
|
|
16
|
+
spawn: (argv, options = {}) => {
|
|
17
|
+
checkSignal(options.signal ?? signal);
|
|
18
|
+
return base.spawn(argv, {
|
|
19
|
+
...options,
|
|
20
|
+
signal: options.signal ?? signal,
|
|
21
|
+
timeoutMs: Math.max(
|
|
22
|
+
1,
|
|
23
|
+
Math.min(options.timeoutMs ?? 600000, deadline - Date.now())
|
|
24
|
+
),
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
run: async (argv, options = {}) => {
|
|
28
|
+
const active = options.signal ?? signal;
|
|
29
|
+
checkSignal(active);
|
|
30
|
+
const result = await base.run(argv, {
|
|
31
|
+
...options,
|
|
32
|
+
signal: active,
|
|
33
|
+
timeoutMs: Math.max(
|
|
34
|
+
1,
|
|
35
|
+
Math.min(options.timeoutMs ?? 600000, deadline - Date.now())
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
checkSignal(active);
|
|
39
|
+
return result;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Restore adapter state so later clean/re-scan calls never inherit an old abort. */
|
|
45
|
+
export async function environmentScope(
|
|
46
|
+
env,
|
|
47
|
+
signal,
|
|
48
|
+
operation,
|
|
49
|
+
deadline = Infinity
|
|
50
|
+
) {
|
|
51
|
+
const original = {
|
|
52
|
+
executor: env.executor,
|
|
53
|
+
signal: env.signal,
|
|
54
|
+
deadline: env.deadline,
|
|
55
|
+
};
|
|
56
|
+
const probe = env.probeExecutor;
|
|
57
|
+
env.signal = signal;
|
|
58
|
+
env.deadline = deadline;
|
|
59
|
+
env.executor = signalExecutor(env.executor, signal, deadline);
|
|
60
|
+
if (probe) {
|
|
61
|
+
env.probeExecutor = signalExecutor(probe, signal, deadline);
|
|
62
|
+
}
|
|
63
|
+
const layerExecutor = env.writableLayer?.docker?.executor;
|
|
64
|
+
if (layerExecutor) {
|
|
65
|
+
env.writableLayer.docker.executor = signalExecutor(
|
|
66
|
+
layerExecutor,
|
|
67
|
+
signal,
|
|
68
|
+
deadline
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
checkSignal(signal);
|
|
73
|
+
return await (env.processInspector
|
|
74
|
+
? environmentScope(env.processInspector, signal, operation, deadline)
|
|
75
|
+
: operation());
|
|
76
|
+
} finally {
|
|
77
|
+
Object.assign(env, original);
|
|
78
|
+
env.probeExecutor = env.probeExecutor?.unscoped ?? probe;
|
|
79
|
+
if (layerExecutor) {
|
|
80
|
+
env.writableLayer.docker.executor = layerExecutor;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Scanner arrays emit completed findings immediately, retaining them on abort. */
|
|
86
|
+
export function scanItems(context) {
|
|
87
|
+
const items = [];
|
|
88
|
+
items.push = (...entries) => {
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
context.emit?.(entry);
|
|
91
|
+
}
|
|
92
|
+
return Array.prototype.push.apply(items, entries);
|
|
93
|
+
};
|
|
94
|
+
return items;
|
|
95
|
+
}
|
package/src/env/shell.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { OPEN_PATHS_SCRIPT } from './process-scripts.js';
|
|
2
|
+
import {
|
|
3
|
+
parseProcessProbe,
|
|
4
|
+
retryProcessOwners,
|
|
5
|
+
inspectionHint,
|
|
6
|
+
} from './inspection.js';
|
|
7
|
+
export { OPEN_PATHS_SCRIPT } from './process-scripts.js';
|
|
1
8
|
/**
|
|
2
9
|
* Environment adapter for a filesystem reached through an executor, usually
|
|
3
10
|
* a running container via `docker exec`.
|
|
@@ -12,9 +19,10 @@ import { sharedWritableLayer } from '../docker/writable.js';
|
|
|
12
19
|
|
|
13
20
|
import { containerExecutor, shellQuote, trace } from '../exec.js';
|
|
14
21
|
import { DEFAULT_SKIP_NAMES, parseDf, processAliases } from './local.js';
|
|
22
|
+
import { batches } from './batches.js';
|
|
23
|
+
import { signalExecutor } from './scope.js';
|
|
15
24
|
|
|
16
25
|
const STAT_FORMAT = '%F|%s|%b|%B|%Y|%n';
|
|
17
|
-
const ARG_BATCH = 200;
|
|
18
26
|
const RECORD = '\u0001';
|
|
19
27
|
|
|
20
28
|
const LIST_SCRIPT = `for d in "$@"; do
|
|
@@ -50,27 +58,6 @@ for p in /proc/[0-9]*; do
|
|
|
50
58
|
printf '%s|%s|%s|%s|%s|%s|%s\\n' "\${p#/proc/}" "$t" "$c" "$e" "$a" "$w" "$q"
|
|
51
59
|
done`;
|
|
52
60
|
|
|
53
|
-
export const OPEN_PATHS_SCRIPT = `failed=0
|
|
54
|
-
for p in /proc/[0-9]*; do
|
|
55
|
-
[ -d "$p" ] || continue
|
|
56
|
-
state=$(sed 's/.*) //' "$p/stat" 2>/dev/null | cut -d' ' -f1)
|
|
57
|
-
[ "$state" = Z ] && continue
|
|
58
|
-
unreadable=0
|
|
59
|
-
ls "$p/fd" >/dev/null 2>&1 || unreadable=1
|
|
60
|
-
for l in "$p/cwd" "$p/exe" "$p"/fd/*; do
|
|
61
|
-
[ -L "$l" ] || continue
|
|
62
|
-
readlink "$l" 2>/dev/null || { [ -L "$l" ] && unreadable=1; }
|
|
63
|
-
done
|
|
64
|
-
if [ "$unreadable" = 1 ] && [ -d "$p" ]; then
|
|
65
|
-
printf 'unreadable pid %s (%s)\\n' "\${p#/proc/}" "$(cat "$p/comm" 2>/dev/null)" >&2
|
|
66
|
-
uid=$(awk '/^Uid:/{print $2}' "$p/status" 2>/dev/null)
|
|
67
|
-
command=$(tr '\\000' ' ' < "$p/cmdline" 2>/dev/null)
|
|
68
|
-
printf 'DSS_UNREADABLE|%s|%s|%s|%s\\n' "\${p#/proc/}" "$uid" "$(cat "$p/comm" 2>/dev/null)" "$command" >&2
|
|
69
|
-
failed=1
|
|
70
|
-
fi
|
|
71
|
-
done
|
|
72
|
-
exit "$failed"`;
|
|
73
|
-
|
|
74
61
|
const REMOVE_SCRIPT = `rm -rf -- "$@" 2>/dev/null && exit 0
|
|
75
62
|
chmod -R u+w -- "$@" 2>/dev/null
|
|
76
63
|
rm -rf -- "$@"`;
|
|
@@ -146,14 +133,6 @@ function parseUsage(body) {
|
|
|
146
133
|
};
|
|
147
134
|
}
|
|
148
135
|
|
|
149
|
-
function batches(items, size = ARG_BATCH) {
|
|
150
|
-
const result = [];
|
|
151
|
-
for (let index = 0; index < items.length; index += size) {
|
|
152
|
-
result.push(items.slice(index, index + size));
|
|
153
|
-
}
|
|
154
|
-
return result;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
136
|
function findNameExpression(names) {
|
|
158
137
|
return names.flatMap((name, index) =>
|
|
159
138
|
index === 0 ? ['-name', name] : ['-o', '-name', name]
|
|
@@ -201,6 +180,21 @@ export class ShellEnv {
|
|
|
201
180
|
return Promise.resolve(this.options.tmpDirs ?? ['/tmp']);
|
|
202
181
|
}
|
|
203
182
|
|
|
183
|
+
async variables(names) {
|
|
184
|
+
const result = await this.sh(
|
|
185
|
+
'for n do printf "%s\\0" "$n"; printenv "$n" | tr -d "\\n"; printf "\\0"; done',
|
|
186
|
+
names
|
|
187
|
+
);
|
|
188
|
+
const fields = result.stdout.split('\0');
|
|
189
|
+
const vars = {};
|
|
190
|
+
for (let index = 0; index + 1 < fields.length; index += 2) {
|
|
191
|
+
if (fields[index + 1]) {
|
|
192
|
+
vars[fields[index]] = fields[index + 1];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return vars;
|
|
196
|
+
}
|
|
197
|
+
|
|
204
198
|
async exists(target) {
|
|
205
199
|
const result = await this.sh('[ -e "$1" ] || [ -L "$1" ]', [target]);
|
|
206
200
|
return result.code === 0;
|
|
@@ -223,26 +217,7 @@ export class ShellEnv {
|
|
|
223
217
|
}
|
|
224
218
|
|
|
225
219
|
async stat(target) {
|
|
226
|
-
|
|
227
|
-
'stat',
|
|
228
|
-
'-c',
|
|
229
|
-
STAT_FORMAT,
|
|
230
|
-
'--',
|
|
231
|
-
target,
|
|
232
|
-
]);
|
|
233
|
-
if (result.code !== 0) {
|
|
234
|
-
return null;
|
|
235
|
-
}
|
|
236
|
-
const parsed = parseStatLine(result.stdout.trim());
|
|
237
|
-
if (!parsed) {
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
return {
|
|
241
|
-
type: parsed.type,
|
|
242
|
-
size: parsed.size,
|
|
243
|
-
bytes: parsed.bytes,
|
|
244
|
-
mtimeMs: parsed.mtimeMs,
|
|
245
|
-
};
|
|
220
|
+
return (await this.statMany([target])).get(target) ?? null;
|
|
246
221
|
}
|
|
247
222
|
|
|
248
223
|
async readText(target, maxBytes = 1024 * 1024) {
|
|
@@ -317,7 +292,7 @@ export class ShellEnv {
|
|
|
317
292
|
const result = await this.executor.run([
|
|
318
293
|
'stat',
|
|
319
294
|
'-c',
|
|
320
|
-
`${STAT_FORMAT}|%d:%i:%u`,
|
|
295
|
+
`${STAT_FORMAT}|%d:%i:%u:%g:%f`,
|
|
321
296
|
'--',
|
|
322
297
|
...batch,
|
|
323
298
|
]);
|
|
@@ -330,6 +305,11 @@ export class ShellEnv {
|
|
|
330
305
|
...stat,
|
|
331
306
|
inode: identity.slice(0, 2).join(':'),
|
|
332
307
|
uid: identity.length > 2 ? Number(identity[2]) : undefined,
|
|
308
|
+
gid: identity.length > 3 ? Number(identity[3]) : undefined,
|
|
309
|
+
mode:
|
|
310
|
+
identity.length > 4
|
|
311
|
+
? Number.parseInt(identity[4], 16)
|
|
312
|
+
: undefined,
|
|
333
313
|
});
|
|
334
314
|
}
|
|
335
315
|
}
|
|
@@ -342,31 +322,24 @@ export class ShellEnv {
|
|
|
342
322
|
const policy = this.scanPolicy;
|
|
343
323
|
if (policy) {
|
|
344
324
|
targets = targets.filter((target) => !policy.removalReason(target));
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
'-xdev',
|
|
357
|
-
'-mindepth',
|
|
358
|
-
'1',
|
|
359
|
-
'(',
|
|
360
|
-
...expression,
|
|
361
|
-
')',
|
|
362
|
-
'-print',
|
|
363
|
-
'-quit',
|
|
364
|
-
]);
|
|
325
|
+
const expression = this.#boundaryPatterns().flatMap((pattern, index) => [
|
|
326
|
+
...(index ? ['-o'] : []),
|
|
327
|
+
/[/\\]/.test(pattern) ? '-path' : '-name',
|
|
328
|
+
pattern.replaceAll('\\', '/'),
|
|
329
|
+
]);
|
|
330
|
+
const script = `for t in "$@"; do
|
|
331
|
+
[ -e "$t" ] || [ -L "$t" ] || continue
|
|
332
|
+
find "$t" -xdev -mindepth 1 '(' ${expression.map(shellQuote).join(' ')} ')' -print -quit || printf '%s\\n' "$t"
|
|
333
|
+
done`;
|
|
334
|
+
for (const batch of batches(targets)) {
|
|
335
|
+
const found = await this.sh(script, batch);
|
|
365
336
|
for (const line of found.stdout.split('\n').filter(Boolean)) {
|
|
366
337
|
policy.excludedPaths.add(line);
|
|
367
338
|
}
|
|
368
339
|
if (found.code !== 0) {
|
|
369
|
-
|
|
340
|
+
for (const target of batch) {
|
|
341
|
+
policy.excludedPaths.add(target);
|
|
342
|
+
}
|
|
370
343
|
}
|
|
371
344
|
}
|
|
372
345
|
// du cannot prune same-device mounts or excluded children. Skip their
|
|
@@ -388,8 +361,21 @@ export class ShellEnv {
|
|
|
388
361
|
}
|
|
389
362
|
|
|
390
363
|
async realPath(target) {
|
|
391
|
-
|
|
392
|
-
|
|
364
|
+
return (await this.realPathMany([target])).get(target) ?? null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async realPathMany(targets) {
|
|
368
|
+
const resolved = new Map();
|
|
369
|
+
for (const batch of batches(targets)) {
|
|
370
|
+
const result = await this.sh(
|
|
371
|
+
`for t do printf '${RECORD}%s\\n' "$t"; readlink -f -- "$t"; done`,
|
|
372
|
+
batch
|
|
373
|
+
);
|
|
374
|
+
for (const [target, body] of splitRecords(result.stdout)) {
|
|
375
|
+
resolved.set(target, body.trim() || null);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return resolved;
|
|
393
379
|
}
|
|
394
380
|
|
|
395
381
|
async isRoot() {
|
|
@@ -419,7 +405,47 @@ export class ShellEnv {
|
|
|
419
405
|
}
|
|
420
406
|
|
|
421
407
|
findFiles(roots, { names, maxDepth = 6, skipNames } = {}) {
|
|
422
|
-
return this.#find(roots, 'f', names, maxDepth,
|
|
408
|
+
return this.#find(roots, 'f', names, maxDepth, [
|
|
409
|
+
...new Set([
|
|
410
|
+
...(skipNames ?? this.skipNames),
|
|
411
|
+
'node_modules',
|
|
412
|
+
'target',
|
|
413
|
+
'vendor',
|
|
414
|
+
'.venv',
|
|
415
|
+
'venv',
|
|
416
|
+
]),
|
|
417
|
+
]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async projectActivity(project, globs) {
|
|
421
|
+
const prune = [
|
|
422
|
+
'-type',
|
|
423
|
+
'd',
|
|
424
|
+
'(',
|
|
425
|
+
...findNameExpression([...new Set([...this.skipNames, ...globs])]),
|
|
426
|
+
')',
|
|
427
|
+
'-prune',
|
|
428
|
+
'-o',
|
|
429
|
+
];
|
|
430
|
+
const argv = [
|
|
431
|
+
'find',
|
|
432
|
+
project,
|
|
433
|
+
'-xdev',
|
|
434
|
+
...this.#prunePaths(),
|
|
435
|
+
...prune,
|
|
436
|
+
'-type',
|
|
437
|
+
'f',
|
|
438
|
+
'-exec',
|
|
439
|
+
'stat',
|
|
440
|
+
'-c',
|
|
441
|
+
'%Y',
|
|
442
|
+
'{}',
|
|
443
|
+
'+',
|
|
444
|
+
];
|
|
445
|
+
const result = await this.sh(
|
|
446
|
+
`${argv.map(shellQuote).join(' ')} | awk 'BEGIN{m=0} {if ($1>m) m=$1} END{print m}'`
|
|
447
|
+
);
|
|
448
|
+
return Number(result.stdout.trim()) * 1000 || 0;
|
|
423
449
|
}
|
|
424
450
|
|
|
425
451
|
/**
|
|
@@ -544,6 +570,29 @@ export class ShellEnv {
|
|
|
544
570
|
OPEN_PATHS_SCRIPT,
|
|
545
571
|
]);
|
|
546
572
|
this.probeDiagnostic = null;
|
|
573
|
+
const probe = await this.#privilegedProbe(result);
|
|
574
|
+
result = probe.result;
|
|
575
|
+
let evidence = probe.evidence;
|
|
576
|
+
if (
|
|
577
|
+
evidence.unreadable.length &&
|
|
578
|
+
(this.probeExecutor || (await this.isRoot()))
|
|
579
|
+
) {
|
|
580
|
+
evidence = await retryProcessOwners(
|
|
581
|
+
this.probeExecutor ?? this.executor,
|
|
582
|
+
evidence
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
this.unreadableProcesses = evidence.unreadable;
|
|
586
|
+
this.probeHint = inspectionHint(this.unreadableProcesses);
|
|
587
|
+
if (result.code !== 0) {
|
|
588
|
+
this.probeDiagnostic ??= result.stderr.trim();
|
|
589
|
+
}
|
|
590
|
+
return probe.coverageKnown ? evidence.paths : null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async #privilegedProbe(result) {
|
|
594
|
+
const evidence = parseProcessProbe(result);
|
|
595
|
+
let coverageKnown = result.code === 0 || evidence.unreadable.length > 0;
|
|
547
596
|
if (
|
|
548
597
|
result.code !== 0 &&
|
|
549
598
|
this.executor.parent &&
|
|
@@ -555,44 +604,33 @@ export class ShellEnv {
|
|
|
555
604
|
this.id,
|
|
556
605
|
diagnostic
|
|
557
606
|
);
|
|
558
|
-
const inspector =
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
607
|
+
const inspector = signalExecutor(
|
|
608
|
+
containerExecutor(
|
|
609
|
+
this.executor.parent.unscoped ?? this.executor.parent,
|
|
610
|
+
this.executor.containerId,
|
|
611
|
+
{ user: '0', privileged: true }
|
|
612
|
+
),
|
|
613
|
+
this.signal,
|
|
614
|
+
this.deadline
|
|
562
615
|
);
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
616
|
+
const retried = await inspector.run(['sh', '-c', OPEN_PATHS_SCRIPT]);
|
|
617
|
+
const next = parseProcessProbe(retried);
|
|
618
|
+
for (const target of next.paths) {
|
|
619
|
+
evidence.paths.add(target);
|
|
620
|
+
}
|
|
621
|
+
if (retried.code === 0 || next.unreadable.length) {
|
|
622
|
+
coverageKnown = true;
|
|
623
|
+
evidence.unreadable = next.unreadable;
|
|
624
|
+
result = retried;
|
|
567
625
|
this.probeExecutor = inspector;
|
|
568
626
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
.map((line) => {
|
|
574
|
-
const [, pid, uid, name, ...command] = line.split('|');
|
|
575
|
-
return {
|
|
576
|
-
pid: Number(pid),
|
|
577
|
-
uid: uid ? Number(uid) : null,
|
|
578
|
-
name,
|
|
579
|
-
command: command.join('|'),
|
|
580
|
-
};
|
|
581
|
-
});
|
|
582
|
-
if (result.code !== 0) {
|
|
583
|
-
this.probeDiagnostic ??= result.stderr.trim();
|
|
584
|
-
if (!this.unreadableProcesses.length) {
|
|
585
|
-
return null;
|
|
627
|
+
if (retried.code !== 0) {
|
|
628
|
+
this.probeDiagnostic = `${diagnostic}; privileged inspection failed: ${retried.stderr.trim()}`;
|
|
629
|
+
} else {
|
|
630
|
+
this.probeExecutor = inspector;
|
|
586
631
|
}
|
|
587
|
-
this.probeHint =
|
|
588
|
-
'some processes could not be inspected; run as root for full verification';
|
|
589
632
|
}
|
|
590
|
-
return
|
|
591
|
-
result.stdout
|
|
592
|
-
.split('\n')
|
|
593
|
-
.filter((line) => line.startsWith('/'))
|
|
594
|
-
.map((line) => line.replace(/ \(deleted\)$/, ''))
|
|
595
|
-
);
|
|
633
|
+
return { result, evidence, coverageKnown };
|
|
596
634
|
}
|
|
597
635
|
|
|
598
636
|
run(argv, options) {
|
package/src/exec.js
CHANGED
|
@@ -41,7 +41,15 @@ export function trace(...parts) {
|
|
|
41
41
|
* @returns {Promise<{code: number, stdout: string, stderr: string, error?: Error}>}
|
|
42
42
|
*/
|
|
43
43
|
export function runProcess(argv, options = {}) {
|
|
44
|
-
const { input, timeoutMs = DEFAULT_TIMEOUT_MS, cwd, env } = options;
|
|
44
|
+
const { input, timeoutMs = DEFAULT_TIMEOUT_MS, cwd, env, signal } = options;
|
|
45
|
+
if (signal?.aborted) {
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
code: 130,
|
|
48
|
+
stdout: '',
|
|
49
|
+
stderr: 'aborted',
|
|
50
|
+
aborted: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
45
53
|
trace('exec', JSON.stringify(argv));
|
|
46
54
|
return new Promise((resolve) => {
|
|
47
55
|
let child;
|
|
@@ -51,6 +59,7 @@ export function runProcess(argv, options = {}) {
|
|
|
51
59
|
env: env ? { ...process.env, ...env } : process.env,
|
|
52
60
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
53
61
|
windowsHide: true,
|
|
62
|
+
detached: process.platform !== 'win32',
|
|
54
63
|
});
|
|
55
64
|
} catch (error) {
|
|
56
65
|
resolve({ code: 127, stdout: '', stderr: String(error), error });
|
|
@@ -66,12 +75,33 @@ export function runProcess(argv, options = {}) {
|
|
|
66
75
|
if (!settled) {
|
|
67
76
|
settled = true;
|
|
68
77
|
clearTimeout(timer);
|
|
78
|
+
signal?.removeEventListener('abort', abort);
|
|
69
79
|
trace(`exit ${result.code} after ${Date.now() - startedAt}ms`);
|
|
70
80
|
resolve(result);
|
|
71
81
|
}
|
|
72
82
|
};
|
|
83
|
+
const kill = () => {
|
|
84
|
+
try {
|
|
85
|
+
if (process.platform !== 'win32') {
|
|
86
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
87
|
+
} else {
|
|
88
|
+
child.kill('SIGKILL');
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
child.kill('SIGKILL');
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const abort = () => {
|
|
95
|
+
kill();
|
|
96
|
+
finish({
|
|
97
|
+
code: 130,
|
|
98
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
99
|
+
stderr: 'aborted',
|
|
100
|
+
aborted: true,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
73
103
|
const timer = setTimeout(() => {
|
|
74
|
-
|
|
104
|
+
kill();
|
|
75
105
|
finish({
|
|
76
106
|
code: 124,
|
|
77
107
|
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
@@ -79,6 +109,10 @@ export function runProcess(argv, options = {}) {
|
|
|
79
109
|
error: new Error(`timed out after ${timeoutMs}ms`),
|
|
80
110
|
});
|
|
81
111
|
}, timeoutMs);
|
|
112
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
113
|
+
if (signal?.aborted) {
|
|
114
|
+
abort();
|
|
115
|
+
}
|
|
82
116
|
const collect = (target) => (chunk) => {
|
|
83
117
|
captured += chunk.length;
|
|
84
118
|
if (captured <= captureLimit) {
|
|
@@ -117,6 +151,11 @@ export function runProcess(argv, options = {}) {
|
|
|
117
151
|
* @param {{timeoutMs?: number, cwd?: string}} [options]
|
|
118
152
|
*/
|
|
119
153
|
export async function pipeInto(source, argv, options = {}) {
|
|
154
|
+
const abortSource = () => source.kill?.('SIGKILL');
|
|
155
|
+
options.signal?.addEventListener('abort', abortSource, { once: true });
|
|
156
|
+
if (options.signal?.aborted) {
|
|
157
|
+
abortSource();
|
|
158
|
+
}
|
|
120
159
|
const sourceErr = [];
|
|
121
160
|
source.stderr?.on('data', (chunk) => sourceErr.push(chunk));
|
|
122
161
|
const sourceDone = new Promise((resolve) => {
|
|
@@ -124,7 +163,11 @@ export async function pipeInto(source, argv, options = {}) {
|
|
|
124
163
|
source.on('close', (code) => resolve({ code: code ?? 1 }));
|
|
125
164
|
});
|
|
126
165
|
const result = await runProcess(argv, { ...options, stdin: source.stdout });
|
|
166
|
+
if (result.code !== 0) {
|
|
167
|
+
abortSource();
|
|
168
|
+
}
|
|
127
169
|
const upstream = await sourceDone;
|
|
170
|
+
options.signal?.removeEventListener('abort', abortSource);
|
|
128
171
|
if (upstream.code !== 0) {
|
|
129
172
|
return {
|
|
130
173
|
...result,
|
|
@@ -143,11 +186,7 @@ export function hostExecutor() {
|
|
|
143
186
|
label: 'host',
|
|
144
187
|
depth: 0,
|
|
145
188
|
run: (argv, options) => runProcess(argv, options),
|
|
146
|
-
spawn:
|
|
147
|
-
spawn(argv[0], argv.slice(1), {
|
|
148
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
149
|
-
windowsHide: true,
|
|
150
|
-
}),
|
|
189
|
+
spawn: spawnStream,
|
|
151
190
|
};
|
|
152
191
|
}
|
|
153
192
|
|
|
@@ -175,10 +214,42 @@ export function containerExecutor(parent, containerId, options = {}) {
|
|
|
175
214
|
parent,
|
|
176
215
|
run: (argv, runOptions = {}) =>
|
|
177
216
|
parent.run(wrap(argv, runOptions.input !== undefined), runOptions),
|
|
178
|
-
spawn: (argv) =>
|
|
217
|
+
spawn: (argv, spawnOptions) =>
|
|
218
|
+
parent.spawn(wrap(argv, false), spawnOptions),
|
|
179
219
|
};
|
|
180
220
|
}
|
|
181
221
|
|
|
222
|
+
/** Stream commands (Docker logs/cp) obey the same cancellation and deadline. */
|
|
223
|
+
function spawnStream(argv, options = {}) {
|
|
224
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
225
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
226
|
+
windowsHide: true,
|
|
227
|
+
detached: process.platform !== 'win32',
|
|
228
|
+
});
|
|
229
|
+
const stop = () => {
|
|
230
|
+
try {
|
|
231
|
+
if (process.platform !== 'win32') {
|
|
232
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
233
|
+
} else {
|
|
234
|
+
child.kill('SIGKILL');
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
child.kill('SIGKILL');
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const timer = setTimeout(stop, options.timeoutMs ?? 600000);
|
|
241
|
+
timer.unref?.();
|
|
242
|
+
options.signal?.addEventListener('abort', stop, { once: true });
|
|
243
|
+
if (options.signal?.aborted) {
|
|
244
|
+
stop();
|
|
245
|
+
}
|
|
246
|
+
child.once('close', () => {
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
options.signal?.removeEventListener('abort', stop);
|
|
249
|
+
});
|
|
250
|
+
return child;
|
|
251
|
+
}
|
|
252
|
+
|
|
182
253
|
/**
|
|
183
254
|
* Quotes a value for inclusion in a POSIX shell script.
|
|
184
255
|
* @param {string} value
|