disk-space-saviour 0.13.0 → 0.15.1
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 +34 -0
- package/README.md +88 -27
- package/bin/dss.js +12 -1
- package/package.json +5 -5
- package/src/audit.js +6 -0
- package/src/clean.js +166 -32
- package/src/cli.js +12 -2
- package/src/docker/cli.js +33 -5
- package/src/docker/containers.js +63 -7
- package/src/docker/scan.js +170 -46
- package/src/docker/snapshots.js +65 -0
- package/src/docker/writable.js +288 -0
- package/src/emergency.js +12 -3
- package/src/env/local.js +162 -78
- package/src/env/policy.js +155 -0
- package/src/env/proc.js +53 -0
- package/src/env/shell.js +190 -12
- package/src/exec.js +4 -1
- package/src/git-preserved.js +120 -0
- package/src/git.js +34 -6
- package/src/index.d.ts +14 -2
- package/src/liveness.js +160 -13
- package/src/options.js +5 -0
- package/src/paths.js +34 -2
- package/src/report.js +67 -7
- package/src/rules/apps.js +178 -0
- package/src/rules/ecosystems.js +4 -3
- package/src/rules/other.js +29 -5
- package/src/rules/versions.js +23 -0
- package/src/scan.js +86 -12
- package/src/scanners/agents.js +1 -1
- package/src/scanners/common.js +2 -1
- package/src/scanners/global.js +59 -7
- package/src/scanners/projects.js +4 -8
- package/src/scanners/system.js +4 -4
- package/src/scanners/versions.js +16 -4
package/src/env/local.js
CHANGED
|
@@ -56,6 +56,8 @@ export function parseLsofCwds(output) {
|
|
|
56
56
|
import { promises as fsp } from 'node:fs';
|
|
57
57
|
import os from 'node:os';
|
|
58
58
|
import path from 'node:path';
|
|
59
|
+
import { URL, fileURLToPath } from 'node:url';
|
|
60
|
+
import { procIdentity, procOpenPaths } from './proc.js';
|
|
59
61
|
|
|
60
62
|
import { hostExecutor } from '../exec.js';
|
|
61
63
|
import { matchesGlob } from '../paths.js';
|
|
@@ -184,6 +186,7 @@ export class LocalEnv {
|
|
|
184
186
|
this.platform = options.platform ?? process.platform;
|
|
185
187
|
this.executor = options.executor ?? hostExecutor();
|
|
186
188
|
this.options = options;
|
|
189
|
+
this.protectedPaths = [fileURLToPath(new URL('../../', import.meta.url))];
|
|
187
190
|
this.currentHome = os.homedir();
|
|
188
191
|
this.skipNames = new Set(options.skipNames ?? DEFAULT_SKIP_NAMES);
|
|
189
192
|
this.vars = options.vars ?? {
|
|
@@ -193,6 +196,30 @@ export class LocalEnv {
|
|
|
193
196
|
};
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
recordAccessError(target, error) {
|
|
200
|
+
if (!['EACCES', 'EPERM'].includes(error.code)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
this.accessErrors ??= new Map();
|
|
204
|
+
const advice =
|
|
205
|
+
this.platform === 'darwin' && /Safari|WebKit/.test(target)
|
|
206
|
+
? '; grant Full Disk Access to the terminal running dss'
|
|
207
|
+
: '';
|
|
208
|
+
this.accessErrors.set(
|
|
209
|
+
target,
|
|
210
|
+
`cannot inspect ${target}: ${error.code}${advice}`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async #lstat(target) {
|
|
215
|
+
try {
|
|
216
|
+
return await (this.options.fileFs ?? fsp).lstat(target);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.recordAccessError(target, error);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
196
223
|
homeDirs() {
|
|
197
224
|
return Promise.resolve(this.options.homes ?? [os.homedir()]);
|
|
198
225
|
}
|
|
@@ -209,11 +236,11 @@ export class LocalEnv {
|
|
|
209
236
|
}
|
|
210
237
|
|
|
211
238
|
async exists(target) {
|
|
212
|
-
return (await
|
|
239
|
+
return (await this.#lstat(target)) !== null;
|
|
213
240
|
}
|
|
214
241
|
|
|
215
242
|
async stat(target) {
|
|
216
|
-
const stats = await
|
|
243
|
+
const stats = await this.#lstat(target);
|
|
217
244
|
if (!stats) {
|
|
218
245
|
return null;
|
|
219
246
|
}
|
|
@@ -222,6 +249,9 @@ export class LocalEnv {
|
|
|
222
249
|
size: stats.size,
|
|
223
250
|
bytes: diskBytes(stats),
|
|
224
251
|
mtimeMs: stats.mtimeMs,
|
|
252
|
+
inode: `${stats.dev}:${stats.ino}`,
|
|
253
|
+
uid: stats.uid,
|
|
254
|
+
device: stats.dev,
|
|
225
255
|
};
|
|
226
256
|
}
|
|
227
257
|
|
|
@@ -260,14 +290,15 @@ export class LocalEnv {
|
|
|
260
290
|
async list(dir) {
|
|
261
291
|
let names;
|
|
262
292
|
try {
|
|
263
|
-
names = await fsp.readdir(dir);
|
|
264
|
-
} catch {
|
|
293
|
+
names = await (this.options.fileFs ?? fsp).readdir(dir);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
this.recordAccessError(dir, error);
|
|
265
296
|
return [];
|
|
266
297
|
}
|
|
267
298
|
const entries = await Promise.all(
|
|
268
299
|
names.map(async (name) => {
|
|
269
300
|
const full = path.join(dir, name);
|
|
270
|
-
const stats = await
|
|
301
|
+
const stats = await this.#lstat(full);
|
|
271
302
|
if (!stats) {
|
|
272
303
|
return null;
|
|
273
304
|
}
|
|
@@ -290,7 +321,18 @@ export class LocalEnv {
|
|
|
290
321
|
* file mtime.
|
|
291
322
|
*/
|
|
292
323
|
async usage(target) {
|
|
293
|
-
|
|
324
|
+
return (await this.usageMany([target])).get(target) ?? null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async statMany(targets) {
|
|
328
|
+
const entries = await Promise.all(
|
|
329
|
+
targets.map(async (target) => [target, await this.stat(target)])
|
|
330
|
+
);
|
|
331
|
+
return new Map(entries.filter(([, stat]) => stat));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async rawUsage(target) {
|
|
335
|
+
const root = await this.#lstat(target);
|
|
294
336
|
if (!root) {
|
|
295
337
|
return null;
|
|
296
338
|
}
|
|
@@ -319,15 +361,16 @@ export class LocalEnv {
|
|
|
319
361
|
const visit = async (dir) => {
|
|
320
362
|
let names;
|
|
321
363
|
try {
|
|
322
|
-
names = await fsp.readdir(dir);
|
|
323
|
-
} catch {
|
|
364
|
+
names = await (this.options.fileFs ?? fsp).readdir(dir);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
this.recordAccessError(dir, error);
|
|
324
367
|
errors++;
|
|
325
368
|
return [];
|
|
326
369
|
}
|
|
327
370
|
const children = await Promise.all(
|
|
328
371
|
names.map(async (name) => {
|
|
329
372
|
const full = path.join(dir, name);
|
|
330
|
-
return [full, await
|
|
373
|
+
return [full, await this.#lstat(full)];
|
|
331
374
|
})
|
|
332
375
|
);
|
|
333
376
|
const subdirs = [];
|
|
@@ -336,6 +379,12 @@ export class LocalEnv {
|
|
|
336
379
|
errors++;
|
|
337
380
|
continue;
|
|
338
381
|
}
|
|
382
|
+
if (
|
|
383
|
+
stats.dev !== root.dev ||
|
|
384
|
+
(this.scanPolicy && !this.scanPolicy.allows(full))
|
|
385
|
+
) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
339
388
|
account(stats);
|
|
340
389
|
if (stats.isDirectory()) {
|
|
341
390
|
subdirs.push(full);
|
|
@@ -422,14 +471,24 @@ export class LocalEnv {
|
|
|
422
471
|
async #walk(roots, maxDepth, skipNames, visit) {
|
|
423
472
|
const skip = skipNames ? new Set(skipNames) : this.skipNames;
|
|
424
473
|
const visited = new Set();
|
|
425
|
-
const queue =
|
|
474
|
+
const queue = await Promise.all(
|
|
475
|
+
roots.map(async (root) => [root, 0, await this.deviceId(root)])
|
|
476
|
+
);
|
|
426
477
|
while (queue.length > 0) {
|
|
427
|
-
const [dir, depth] = queue.shift();
|
|
478
|
+
const [dir, depth, device] = queue.shift();
|
|
428
479
|
if (visited.has(dir)) {
|
|
429
480
|
continue;
|
|
430
481
|
}
|
|
431
482
|
visited.add(dir);
|
|
432
|
-
const entries =
|
|
483
|
+
const entries = [];
|
|
484
|
+
for (const entry of await this.list(dir)) {
|
|
485
|
+
if (
|
|
486
|
+
entry.type !== 'dir' ||
|
|
487
|
+
(await this.deviceId(entry.path)) === device
|
|
488
|
+
) {
|
|
489
|
+
entries.push(entry);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
433
492
|
const pruned = visit(dir, entries) ?? new Set();
|
|
434
493
|
if (depth + 1 >= maxDepth) {
|
|
435
494
|
continue;
|
|
@@ -438,18 +497,26 @@ export class LocalEnv {
|
|
|
438
497
|
if (
|
|
439
498
|
entry.type === 'dir' &&
|
|
440
499
|
!pruned.has(entry.name) &&
|
|
441
|
-
!skip.has(entry.name)
|
|
500
|
+
!skip.has(entry.name) &&
|
|
501
|
+
(await this.deviceId(entry.path)) === device
|
|
442
502
|
) {
|
|
443
|
-
queue.push([entry.path, depth + 1]);
|
|
503
|
+
queue.push([entry.path, depth + 1, device]);
|
|
444
504
|
}
|
|
445
505
|
}
|
|
446
506
|
}
|
|
447
507
|
}
|
|
448
508
|
|
|
449
509
|
async usageMany(targets) {
|
|
510
|
+
const usages = await this.rawUsageMany(targets);
|
|
511
|
+
return this.writableLayer
|
|
512
|
+
? this.writableLayer.measure(this, usages)
|
|
513
|
+
: usages;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async rawUsageMany(targets) {
|
|
450
517
|
const usages = new Map();
|
|
451
518
|
for (const target of targets) {
|
|
452
|
-
usages.set(target, await this.
|
|
519
|
+
usages.set(target, await this.rawUsage(target));
|
|
453
520
|
}
|
|
454
521
|
return usages;
|
|
455
522
|
}
|
|
@@ -492,6 +559,13 @@ export class LocalEnv {
|
|
|
492
559
|
|
|
493
560
|
async processes() {
|
|
494
561
|
const selfPids = new Set([process.pid, process.ppid]);
|
|
562
|
+
if (this.processInspector?.probeExecutor) {
|
|
563
|
+
return (
|
|
564
|
+
(await this.processInspector.processes())?.filter(
|
|
565
|
+
(proc) => !selfPids.has(proc.pid)
|
|
566
|
+
) ?? null
|
|
567
|
+
);
|
|
568
|
+
}
|
|
495
569
|
if (this.platform === 'linux') {
|
|
496
570
|
return (await this.#linuxProcesses()).filter(
|
|
497
571
|
(proc) => !selfPids.has(proc.pid)
|
|
@@ -507,6 +581,13 @@ export class LocalEnv {
|
|
|
507
581
|
.filter((proc) => !selfPids.has(proc.pid));
|
|
508
582
|
}
|
|
509
583
|
const result = await this.run(['ps', '-axo', 'pid=,comm=']);
|
|
584
|
+
const commands = new Map(
|
|
585
|
+
(await this.run(['ps', '-axo', 'pid=,args='])).stdout
|
|
586
|
+
.split('\n')
|
|
587
|
+
.map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
|
|
588
|
+
.filter(Boolean)
|
|
589
|
+
.map((match) => [Number(match[1]), match[2]])
|
|
590
|
+
);
|
|
510
591
|
const cwds =
|
|
511
592
|
this.platform === 'darwin'
|
|
512
593
|
? parseLsofCwds(
|
|
@@ -525,44 +606,45 @@ export class LocalEnv {
|
|
|
525
606
|
pid: Number(m[1]),
|
|
526
607
|
name: path.basename(m[2].trim()),
|
|
527
608
|
cwd: cwds.get(Number(m[1])) ?? null,
|
|
609
|
+
command: commands.get(Number(m[1])) ?? '',
|
|
528
610
|
}))
|
|
529
611
|
.filter((proc) => !selfPids.has(proc.pid));
|
|
530
612
|
}
|
|
531
613
|
|
|
532
614
|
async #linuxProcesses() {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
return [];
|
|
538
|
-
}
|
|
615
|
+
const io = this.options.procFs ?? fsp;
|
|
616
|
+
const pids = (await io.readdir('/proc')).filter((name) =>
|
|
617
|
+
/^\d+$/.test(name)
|
|
618
|
+
);
|
|
539
619
|
const processes = [];
|
|
540
620
|
for (const pid of pids) {
|
|
541
621
|
const base = `/proc/${pid}`;
|
|
622
|
+
const stat = parseProcStat(
|
|
623
|
+
await io.readFile(`${base}/stat`, 'utf8').catch(() => '')
|
|
624
|
+
);
|
|
625
|
+
if (stat.state === 'Z') {
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
542
628
|
let name;
|
|
543
629
|
try {
|
|
544
|
-
name = (await
|
|
630
|
+
name = (await io.readFile(`${base}/comm`, 'utf8')).trim();
|
|
545
631
|
} catch {
|
|
546
632
|
continue;
|
|
547
633
|
}
|
|
548
|
-
const cwd = await
|
|
549
|
-
const exe = await
|
|
550
|
-
const cmdline = await
|
|
634
|
+
const cwd = await io.readlink(`${base}/cwd`).catch(() => null);
|
|
635
|
+
const exe = await io.readlink(`${base}/exe`).catch(() => '');
|
|
636
|
+
const cmdline = await io
|
|
551
637
|
.readFile(`${base}/cmdline`, 'utf8')
|
|
552
638
|
.catch(() => '');
|
|
553
|
-
const stat = parseProcStat(
|
|
554
|
-
await fsp.readFile(`${base}/stat`, 'utf8').catch(() => '')
|
|
555
|
-
);
|
|
556
|
-
if (stat.state === 'Z') {
|
|
557
|
-
continue;
|
|
558
|
-
}
|
|
559
639
|
processes.push({
|
|
560
640
|
pid: Number(pid),
|
|
641
|
+
...(await procIdentity(base, io)),
|
|
561
642
|
name,
|
|
562
643
|
startTime: stat.startTime,
|
|
563
644
|
cwd,
|
|
564
645
|
aliases: processAliases(exe, cmdline.split('\0')[0]),
|
|
565
646
|
command: cmdline.replaceAll('\0', ' '),
|
|
647
|
+
argv: cmdline.split('\0').filter(Boolean),
|
|
566
648
|
});
|
|
567
649
|
}
|
|
568
650
|
return processes;
|
|
@@ -574,55 +656,57 @@ export class LocalEnv {
|
|
|
574
656
|
* @returns {Promise<Set<string>|null>}
|
|
575
657
|
*/
|
|
576
658
|
async openPaths({ strict = true } = {}) {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
for (const pid of pids) {
|
|
586
|
-
const base = `/proc/${pid}`;
|
|
587
|
-
for (const link of ['cwd', 'exe']) {
|
|
588
|
-
const target = await fsp
|
|
589
|
-
.readlink(`${base}/${link}`)
|
|
590
|
-
.catch((error) => {
|
|
591
|
-
if (
|
|
592
|
-
strict &&
|
|
593
|
-
(error.code === 'EACCES' || error.code === 'EPERM')
|
|
594
|
-
) {
|
|
595
|
-
throw error;
|
|
596
|
-
}
|
|
597
|
-
return '';
|
|
598
|
-
});
|
|
599
|
-
if (target.startsWith('/')) {
|
|
600
|
-
paths.add(target.replace(/ \(deleted\)$/, ''));
|
|
601
|
-
}
|
|
659
|
+
try {
|
|
660
|
+
let paths = await this.rawOpenPaths({ strict });
|
|
661
|
+
if (strict && this.unreadableProcesses?.length && this.processInspector) {
|
|
662
|
+
const privileged = await this.processInspector.openPaths();
|
|
663
|
+
if (privileged) {
|
|
664
|
+
this.unreadableProcesses =
|
|
665
|
+
this.processInspector.unreadableProcesses ?? [];
|
|
666
|
+
paths = privileged;
|
|
602
667
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
}
|
|
619
|
-
return '';
|
|
620
|
-
});
|
|
621
|
-
if (target.startsWith('/')) {
|
|
622
|
-
paths.add(target.replace(/ \(deleted\)$/, ''));
|
|
623
|
-
}
|
|
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 = [];
|
|
624
683
|
}
|
|
625
684
|
}
|
|
685
|
+
this.#processHint();
|
|
686
|
+
return paths;
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if (this.processInspector) {
|
|
689
|
+
const paths = await this.processInspector.openPaths();
|
|
690
|
+
this.probeDiagnostic = this.processInspector.probeDiagnostic;
|
|
691
|
+
return paths;
|
|
692
|
+
}
|
|
693
|
+
this.probeDiagnostic = error.message;
|
|
694
|
+
throw error;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
#processHint() {
|
|
699
|
+
this.probeHint = this.unreadableProcesses?.length
|
|
700
|
+
? `${this.unreadableProcesses.length} processes are unreadable; uncertainty is scoped to their paths and UID. Re-run as root for complete inspection (docker exec -u 0 -e HOME=<user-home> <container> dss scan).`
|
|
701
|
+
: null;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
async rawOpenPaths({ strict = true } = {}) {
|
|
705
|
+
if (this.platform === 'linux') {
|
|
706
|
+
const { paths, unreadable } = await procOpenPaths(
|
|
707
|
+
this.options.procFs ?? fsp
|
|
708
|
+
);
|
|
709
|
+
this.unreadableProcesses = strict ? unreadable : [];
|
|
626
710
|
return paths;
|
|
627
711
|
}
|
|
628
712
|
if (this.platform === 'darwin') {
|
|
@@ -654,7 +738,7 @@ export class LocalEnv {
|
|
|
654
738
|
: [''];
|
|
655
739
|
for (const dir of dirs.filter(Boolean)) {
|
|
656
740
|
for (const extension of extensions) {
|
|
657
|
-
const stats = await
|
|
741
|
+
const stats = await this.#lstat(path.join(dir, command + extension));
|
|
658
742
|
if (stats && !stats.isDirectory()) {
|
|
659
743
|
return true;
|
|
660
744
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Shared traversal and deletion boundaries for every scanner/environment. */
|
|
2
|
+
import { isWithin, matchesExcluded } from '../paths.js';
|
|
3
|
+
import { trace } from '../exec.js';
|
|
4
|
+
|
|
5
|
+
const RUNTIME_ROOTS = [
|
|
6
|
+
'/var/lib/docker',
|
|
7
|
+
'/var/lib/containerd',
|
|
8
|
+
'/run/containerd',
|
|
9
|
+
'/var/lib/containers',
|
|
10
|
+
];
|
|
11
|
+
const unescapeMount = (value) =>
|
|
12
|
+
value.replace(/\\([0-7]{3})/g, (_, octal) =>
|
|
13
|
+
String.fromCharCode(parseInt(octal, 8))
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export function parseMountInfo(text) {
|
|
17
|
+
return (text ?? '').split('\n').flatMap((line) => {
|
|
18
|
+
const [left, right] = line.split(' - ');
|
|
19
|
+
if (!right) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
const fields = left.split(' ');
|
|
23
|
+
const [type, source, options = ''] = right.split(' ');
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
path: unescapeMount(fields[4]),
|
|
27
|
+
type,
|
|
28
|
+
source,
|
|
29
|
+
readonly: fields[5].split(',').includes('ro'),
|
|
30
|
+
options,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class ScanPolicy {
|
|
37
|
+
constructor(env, options, mounts) {
|
|
38
|
+
this.env = env;
|
|
39
|
+
this.exclude = options.exclude ?? [];
|
|
40
|
+
this.roots = options.roots ?? [];
|
|
41
|
+
this.mounts = mounts;
|
|
42
|
+
this.excludedPaths = new Set();
|
|
43
|
+
this.pruned = mounts
|
|
44
|
+
.filter(
|
|
45
|
+
(mount) =>
|
|
46
|
+
mount.path !== '/' &&
|
|
47
|
+
(mount.readonly ||
|
|
48
|
+
mount.type === 'overlay' ||
|
|
49
|
+
mount.type === 'fuse.overlayfs' ||
|
|
50
|
+
!this.roots.some((root) => isWithin(root, mount.path, env.path)))
|
|
51
|
+
)
|
|
52
|
+
.map((mount) => mount.path);
|
|
53
|
+
this.overlay = mounts.find(
|
|
54
|
+
(mount) =>
|
|
55
|
+
mount.path === '/' && ['overlay', 'fuse.overlayfs'].includes(mount.type)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
allows(target) {
|
|
60
|
+
const { path } = this.env;
|
|
61
|
+
if (matchesExcluded(target, this.exclude, path)) {
|
|
62
|
+
this.excludedPaths.add(target);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
!RUNTIME_ROOTS.some((root) => isWithin(target, root, path)) &&
|
|
67
|
+
!matchesExcluded(
|
|
68
|
+
target,
|
|
69
|
+
['/tmp/containerd-mount*', '/private/tmp/containerd-mount*'],
|
|
70
|
+
path
|
|
71
|
+
) &&
|
|
72
|
+
!this.pruned.some((root) => isWithin(target, root, path)) &&
|
|
73
|
+
!this.mounts.some(
|
|
74
|
+
(mount) => mount.readonly && isWithin(target, mount.path, path)
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
removalReason(target) {
|
|
80
|
+
if (!this.allows(target)) {
|
|
81
|
+
return 'path is excluded, mounted, read-only or owned by a container runtime';
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
[...this.excludedPaths].some((root) =>
|
|
85
|
+
isWithin(root, target, this.env.path)
|
|
86
|
+
) ||
|
|
87
|
+
this.exclude.some(
|
|
88
|
+
(pattern) =>
|
|
89
|
+
!/[?*[]/.test(pattern) && isWithin(pattern, target, this.env.path)
|
|
90
|
+
)
|
|
91
|
+
) {
|
|
92
|
+
return 'contains an excluded path';
|
|
93
|
+
}
|
|
94
|
+
if (this.pruned.some((root) => isWithin(root, target, this.env.path))) {
|
|
95
|
+
return 'contains a mounted filesystem';
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Install boundaries before any scanner expands paths or traverses roots. */
|
|
102
|
+
export async function configureScanPolicy(env, options) {
|
|
103
|
+
const mounts =
|
|
104
|
+
env.platform === 'linux' && env.readText
|
|
105
|
+
? parseMountInfo(await env.readText('/proc/self/mountinfo'))
|
|
106
|
+
: [];
|
|
107
|
+
const excluded = env.scanPolicy?.excludedPaths;
|
|
108
|
+
env.scanPolicy = new ScanPolicy(env, options, mounts);
|
|
109
|
+
if (excluded) {
|
|
110
|
+
env.scanPolicy.excludedPaths = excluded;
|
|
111
|
+
}
|
|
112
|
+
if (env.policyInstalled) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
env.policyInstalled = true;
|
|
116
|
+
const allowed = (target) => env.scanPolicy.allows(target);
|
|
117
|
+
for (const name of ['list', 'stat', 'exists']) {
|
|
118
|
+
if (!env[name]) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const original = env[name].bind(env);
|
|
122
|
+
env[name] = async (target, ...args) => {
|
|
123
|
+
if (!allowed(target)) {
|
|
124
|
+
trace('pruned', target);
|
|
125
|
+
return name === 'list' ? [] : name === 'exists' ? false : null;
|
|
126
|
+
}
|
|
127
|
+
const value = await original(target, ...args);
|
|
128
|
+
return name === 'list'
|
|
129
|
+
? value.filter((entry) =>
|
|
130
|
+
allowed(entry.path ?? env.path.join(target, entry.name))
|
|
131
|
+
)
|
|
132
|
+
: value;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
for (const name of ['listMany', 'existsMany', 'statMany', 'rawUsageMany']) {
|
|
136
|
+
if (!env[name]) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const original = env[name].bind(env);
|
|
140
|
+
env[name] = async (targets, ...args) => {
|
|
141
|
+
const value = await original(targets.filter(allowed), ...args);
|
|
142
|
+
if (name !== 'listMany') {
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
return new Map(
|
|
146
|
+
[...value].map(([dir, entries]) => [
|
|
147
|
+
dir,
|
|
148
|
+
entries.filter((entry) =>
|
|
149
|
+
allowed(entry.path ?? env.path.join(dir, entry.name))
|
|
150
|
+
),
|
|
151
|
+
])
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
package/src/env/proc.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Linux proc inspection preserves evidence when only some PIDs are denied. */
|
|
2
|
+
import { promises as fsp } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export async function procIdentity(base, io = fsp) {
|
|
5
|
+
const read = (name) => io.readFile(`${base}/${name}`, 'utf8').catch(() => '');
|
|
6
|
+
const [status, comm, command] = await Promise.all([
|
|
7
|
+
read('status'),
|
|
8
|
+
read('comm'),
|
|
9
|
+
read('cmdline'),
|
|
10
|
+
]);
|
|
11
|
+
const uid = /^Uid:\s+(\d+)/m.exec(status)?.[1];
|
|
12
|
+
return {
|
|
13
|
+
pid: Number(base.split('/').at(-1)),
|
|
14
|
+
uid: uid === undefined ? null : Number(uid),
|
|
15
|
+
name: comm.trim(),
|
|
16
|
+
argv: command.split('\0').filter(Boolean),
|
|
17
|
+
command: command.replaceAll('\0', ' '),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function procOpenPaths(io = fsp) {
|
|
22
|
+
const paths = new Set();
|
|
23
|
+
const unreadable = [];
|
|
24
|
+
const pids = (await io.readdir('/proc')).filter((pid) => /^\d+$/.test(pid));
|
|
25
|
+
for (const pid of pids) {
|
|
26
|
+
const base = `/proc/${pid}`;
|
|
27
|
+
const stat = await io.readFile(`${base}/stat`, 'utf8').catch(() => '');
|
|
28
|
+
if (stat.slice(stat.lastIndexOf(')') + 2).startsWith('Z ')) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
let denied = false;
|
|
32
|
+
const failure = (error) => {
|
|
33
|
+
if (['EACCES', 'EPERM'].includes(error.code)) {
|
|
34
|
+
denied = true;
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
};
|
|
38
|
+
const fds = await io.readdir(`${base}/fd`).catch((error) => {
|
|
39
|
+
failure(error);
|
|
40
|
+
return [];
|
|
41
|
+
});
|
|
42
|
+
for (const link of ['cwd', 'exe', ...fds.map((fd) => `fd/${fd}`)]) {
|
|
43
|
+
const target = await io.readlink(`${base}/${link}`).catch(failure);
|
|
44
|
+
if (target.startsWith('/')) {
|
|
45
|
+
paths.add(target.replace(/ \(deleted\)$/, ''));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (denied) {
|
|
49
|
+
unreadable.push(await procIdentity(base, io));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { paths, unreadable };
|
|
53
|
+
}
|