@dsh-enhanced/assistant-isolation 0.1.65 → 0.1.67
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/README.md +287 -9
- package/lib/audit-archive.d.ts +1 -1
- package/lib/audit-archive.d.ts.map +1 -1
- package/lib/audit-archive.js +31 -13
- package/lib/audit-archive.js.map +1 -1
- package/lib/cli.d.ts +7 -3
- package/lib/cli.d.ts.map +1 -1
- package/lib/cli.js +104 -23
- package/lib/cli.js.map +1 -1
- package/lib/config.d.ts +15 -2
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +49 -9
- package/lib/config.js.map +1 -1
- package/lib/diagnostics.js +2 -2
- package/lib/diagnostics.js.map +1 -1
- package/lib/index.d.ts +17 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +17 -1
- package/lib/index.js.map +1 -1
- package/lib/ledger.d.ts +19 -2
- package/lib/ledger.d.ts.map +1 -1
- package/lib/ledger.js +371 -44
- package/lib/ledger.js.map +1 -1
- package/lib/linux-cgroups.d.ts +75 -0
- package/lib/linux-cgroups.d.ts.map +1 -0
- package/lib/linux-cgroups.js +517 -0
- package/lib/linux-cgroups.js.map +1 -0
- package/lib/linux-execution-contract.d.ts +123 -0
- package/lib/linux-execution-contract.d.ts.map +1 -0
- package/lib/linux-execution-contract.js +279 -0
- package/lib/linux-execution-contract.js.map +1 -0
- package/lib/linux-execution.d.ts +123 -0
- package/lib/linux-execution.d.ts.map +1 -0
- package/lib/linux-execution.js +1264 -0
- package/lib/linux-execution.js.map +1 -0
- package/lib/linux-job-recovery.d.ts +5 -0
- package/lib/linux-job-recovery.d.ts.map +1 -0
- package/lib/linux-job-recovery.js +63 -0
- package/lib/linux-job-recovery.js.map +1 -0
- package/lib/linux-namespace.d.ts +80 -0
- package/lib/linux-namespace.d.ts.map +1 -0
- package/lib/linux-namespace.js +471 -0
- package/lib/linux-namespace.js.map +1 -0
- package/lib/linux-repository-admission.d.ts +26 -0
- package/lib/linux-repository-admission.d.ts.map +1 -0
- package/lib/linux-repository-admission.js +64 -0
- package/lib/linux-repository-admission.js.map +1 -0
- package/lib/linux-repository-runner.d.ts +54 -0
- package/lib/linux-repository-runner.d.ts.map +1 -0
- package/lib/linux-repository-runner.js +614 -0
- package/lib/linux-repository-runner.js.map +1 -0
- package/lib/linux-runner-state-migration.d.ts +94 -0
- package/lib/linux-runner-state-migration.d.ts.map +1 -0
- package/lib/linux-runner-state-migration.js +463 -0
- package/lib/linux-runner-state-migration.js.map +1 -0
- package/lib/linux-runner-state.d.ts +53 -0
- package/lib/linux-runner-state.d.ts.map +1 -0
- package/lib/linux-runner-state.js +332 -0
- package/lib/linux-runner-state.js.map +1 -0
- package/lib/linux-runtime-source.d.ts +28 -0
- package/lib/linux-runtime-source.d.ts.map +1 -0
- package/lib/linux-runtime-source.js +237 -0
- package/lib/linux-runtime-source.js.map +1 -0
- package/lib/linux-runtime.d.ts +37 -0
- package/lib/linux-runtime.d.ts.map +1 -0
- package/lib/linux-runtime.js +192 -0
- package/lib/linux-runtime.js.map +1 -0
- package/lib/linux-system-snapshot.d.ts +47 -0
- package/lib/linux-system-snapshot.d.ts.map +1 -0
- package/lib/linux-system-snapshot.js +704 -0
- package/lib/linux-system-snapshot.js.map +1 -0
- package/lib/linux-user-namespace.d.ts +79 -0
- package/lib/linux-user-namespace.d.ts.map +1 -0
- package/lib/linux-user-namespace.js +234 -0
- package/lib/linux-user-namespace.js.map +1 -0
- package/lib/linux-verifier-runner.d.ts +27 -0
- package/lib/linux-verifier-runner.d.ts.map +1 -0
- package/lib/linux-verifier-runner.js +346 -0
- package/lib/linux-verifier-runner.js.map +1 -0
- package/lib/native-cgroup-roots.d.ts +20 -0
- package/lib/native-cgroup-roots.d.ts.map +1 -0
- package/lib/native-cgroup-roots.js +121 -0
- package/lib/native-cgroup-roots.js.map +1 -0
- package/lib/native-resources.d.ts +44 -0
- package/lib/native-resources.d.ts.map +1 -0
- package/lib/native-resources.js +354 -0
- package/lib/native-resources.js.map +1 -0
- package/lib/probe.d.ts +10 -0
- package/lib/probe.d.ts.map +1 -1
- package/lib/probe.js +52 -2
- package/lib/probe.js.map +1 -1
- package/lib/service.d.ts +1 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +122 -9
- package/lib/service.js.map +1 -1
- package/lib/storage.d.ts.map +1 -1
- package/lib/storage.js +36 -6
- package/lib/storage.js.map +1 -1
- package/lib/tools.js +1 -1
- package/lib/tools.js.map +1 -1
- package/lib/types.d.ts +5 -1
- package/lib/types.d.ts.map +1 -1
- package/lib/verifier-runner.d.ts +4 -2
- package/lib/verifier-runner.d.ts.map +1 -1
- package/lib/verifier-runner.js +22 -1
- package/lib/verifier-runner.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +15 -10
- package/runtime/linux-candidate-entry.py +82 -0
- package/runtime/linux-namespace-worker.mjs +561 -0
- package/runtime/linux-seccomp-policy.LICENSE +202 -0
- package/runtime/linux-seccomp-policy.json +886 -0
- package/runtime/linux-seccomp.py +106 -0
- package/runtime/linux-userns-launcher.py +149 -0
- package/runtime/linux-userns-proof.py +299 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { hashLinuxRuntimeTree, validateLinuxRuntime, verifyLinuxRuntime } from './linux-runtime.js';
|
|
8
|
+
export const linuxSystemSnapshotLimits = Object.freeze({ bytes: 640 * 1024 * 1024, entries: 32768 });
|
|
9
|
+
const MANIFEST = '.dsh-system-manifest.json';
|
|
10
|
+
const MAX_MANIFEST = 16 * 1024 * 1024;
|
|
11
|
+
const HEX = /^[a-f0-9]{64}$/u;
|
|
12
|
+
const exec = promisify(execFile);
|
|
13
|
+
const sha = (value) => createHash('sha256').update(value).digest('hex');
|
|
14
|
+
const canonical = (value) => typeof value === 'string'
|
|
15
|
+
&& isAbsolute(value) && value === resolve(value) && ![...value].some(character => character.charCodeAt(0) < 32);
|
|
16
|
+
const stamp = (s) => JSON.stringify([s.dev, s.ino, s.uid, s.mode, s.size, s.mtimeMs, s.ctimeMs]);
|
|
17
|
+
const checkAbort = (signal) => { signal?.throwIfAborted(); };
|
|
18
|
+
const tools = `bash dash sh env printenv bwrap unshare mount umount setpriv script flock setsid nsenter timeout kill sleep yes ps pgrep pkill id whoami uname hostname mkfifo cat ls chmod cp mv rm mkdir rmdir mktemp touch ln readlink realpath stat basename dirname date du df head tail tr sort cut wc od tee printf true false test pwd expr seq install truncate sync sha256sum find xargs grep sed awk which tar gzip gunzip xz curl git openssl perl python3 strace`.split(' ');
|
|
19
|
+
/** Resolve every component without trusting a user-owned intermediate or a mutable symlink. */
|
|
20
|
+
export async function inspectTrustedLinuxSystemSource(path) {
|
|
21
|
+
if (!canonical(path))
|
|
22
|
+
throw new Error('noncanonical system source');
|
|
23
|
+
let pending = path.split('/').filter(Boolean), current = '/', hops = 0;
|
|
24
|
+
const links = [];
|
|
25
|
+
const root = await lstat('/');
|
|
26
|
+
if (root.uid !== 0 || root.mode & 0o022)
|
|
27
|
+
throw new Error('untrusted system source root');
|
|
28
|
+
while (pending.length) {
|
|
29
|
+
current = join(current, pending.shift());
|
|
30
|
+
if (current === '/etc/ssl/private' || current.startsWith('/etc/ssl/private/'))
|
|
31
|
+
throw new Error('private system source forbidden');
|
|
32
|
+
const info = await lstat(current);
|
|
33
|
+
if (!info.isSymbolicLink() && !info.isFile() && !info.isDirectory())
|
|
34
|
+
throw new Error('special system source forbidden');
|
|
35
|
+
if (info.uid !== 0 || (!info.isSymbolicLink() && info.mode & 0o022))
|
|
36
|
+
throw new Error(`untrusted system source: ${current}`);
|
|
37
|
+
if (info.isSymbolicLink()) {
|
|
38
|
+
if (++hops > 40)
|
|
39
|
+
throw new Error('system source symlink cycle');
|
|
40
|
+
const target = await readlink(current);
|
|
41
|
+
if (stamp(await lstat(current)) !== stamp(info))
|
|
42
|
+
throw new Error('system source link changed');
|
|
43
|
+
links.push({ path: current, target, info });
|
|
44
|
+
pending = [...resolve(dirname(current), target).split('/').filter(Boolean), ...pending];
|
|
45
|
+
current = '/';
|
|
46
|
+
}
|
|
47
|
+
else if (!info.isFile() && !info.isDirectory())
|
|
48
|
+
throw new Error('special system source forbidden');
|
|
49
|
+
else if (pending.length && !info.isDirectory())
|
|
50
|
+
throw new Error('invalid system source parent');
|
|
51
|
+
}
|
|
52
|
+
const info = await lstat(current);
|
|
53
|
+
if (info.uid !== 0 || info.mode & 0o022 || !info.isFile() && !info.isDirectory())
|
|
54
|
+
throw new Error('system source changed after resolution');
|
|
55
|
+
return { path: current, info, links };
|
|
56
|
+
}
|
|
57
|
+
async function sourceBytes(source, destination, signal) {
|
|
58
|
+
const fd = await open(source.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
59
|
+
let output;
|
|
60
|
+
try {
|
|
61
|
+
output = destination ? await open(destination, 'wx', 0o600) : undefined;
|
|
62
|
+
if (stamp(await fd.stat()) !== stamp(source.info) || await realpath(`/proc/self/fd/${fd.fd}`) !== source.path)
|
|
63
|
+
throw new Error('system source descriptor changed');
|
|
64
|
+
if (source.info.size > linuxSystemSnapshotLimits.bytes)
|
|
65
|
+
throw new Error('system source size bound exceeded');
|
|
66
|
+
const buffer = Buffer.alloc(65536), hash = createHash('sha256');
|
|
67
|
+
let offset = 0, prefix = Buffer.alloc(0);
|
|
68
|
+
while (offset < source.info.size) {
|
|
69
|
+
checkAbort(signal);
|
|
70
|
+
const result = await fd.read(buffer, 0, Math.min(buffer.length, source.info.size - offset), offset);
|
|
71
|
+
if (!result.bytesRead)
|
|
72
|
+
throw new Error('system source truncated');
|
|
73
|
+
const chunk = buffer.subarray(0, result.bytesRead);
|
|
74
|
+
if (!offset)
|
|
75
|
+
prefix = Buffer.from(chunk.subarray(0, 4096));
|
|
76
|
+
hash.update(chunk);
|
|
77
|
+
if (output) {
|
|
78
|
+
let written = 0;
|
|
79
|
+
while (written < chunk.length) {
|
|
80
|
+
const result = await output.write(chunk, written, chunk.length - written);
|
|
81
|
+
if (!result.bytesWritten)
|
|
82
|
+
throw new Error('system snapshot copy stalled');
|
|
83
|
+
written += result.bytesWritten;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
offset += result.bytesRead;
|
|
87
|
+
}
|
|
88
|
+
if ((await fd.read(buffer, 0, 1, offset)).bytesRead)
|
|
89
|
+
throw new Error('system source grew');
|
|
90
|
+
if (stamp(await fd.stat()) !== stamp(source.info) || stamp(await lstat(source.path)) !== stamp(source.info))
|
|
91
|
+
throw new Error('system source changed');
|
|
92
|
+
return { sha256: hash.digest('hex'), prefix };
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
try {
|
|
96
|
+
await output?.close();
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await fd.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Metadata reads are bounded independently of ELF size; no loader or inspection executable runs. */
|
|
104
|
+
export async function parseLinuxSystemElf(readAt, size, path) {
|
|
105
|
+
const read = async (offset, length, limit = 4 * 1024 * 1024) => {
|
|
106
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0
|
|
107
|
+
|| length > limit || offset + length > size)
|
|
108
|
+
throw new Error(`ELF range invalid or metadata bound exceeded: ${path}`);
|
|
109
|
+
const result = await readAt(offset, length);
|
|
110
|
+
if (result.length !== length)
|
|
111
|
+
throw new Error('ELF metadata truncated');
|
|
112
|
+
return result;
|
|
113
|
+
};
|
|
114
|
+
const header = await read(0, Math.min(size, 64));
|
|
115
|
+
if (header.length < 4 || header.subarray(0, 4).toString('hex') !== '7f454c46')
|
|
116
|
+
return undefined;
|
|
117
|
+
if (header.length < 64 || header[4] !== 2 || header[5] !== 1
|
|
118
|
+
|| header.readUInt16LE(18) !== (process.arch === 'x64' ? 62 : process.arch === 'arm64' ? 183 : -1))
|
|
119
|
+
throw new Error(`unsupported system ELF: ${path}`);
|
|
120
|
+
const number = (buffer, offset) => {
|
|
121
|
+
const value = Number(buffer.readBigUInt64LE(offset));
|
|
122
|
+
if (!Number.isSafeInteger(value))
|
|
123
|
+
throw new Error('ELF integer overflow');
|
|
124
|
+
return value;
|
|
125
|
+
};
|
|
126
|
+
const cstring = (buffer, offset) => {
|
|
127
|
+
const end = buffer.indexOf(0, offset);
|
|
128
|
+
if (offset < 0 || end < offset || end - offset > 4096)
|
|
129
|
+
throw new Error('ELF string invalid or over bound');
|
|
130
|
+
return buffer.subarray(offset, end).toString('utf8');
|
|
131
|
+
};
|
|
132
|
+
if (header.readUInt16LE(16) === 1)
|
|
133
|
+
return { needed: [], search: [], inherit: false };
|
|
134
|
+
const segments = [];
|
|
135
|
+
const start = number(header, 32), width = header.readUInt16LE(54), count = header.readUInt16LE(56);
|
|
136
|
+
if (count > 4096 || width < 56 || width > 256)
|
|
137
|
+
throw new Error('ELF program table invalid');
|
|
138
|
+
const programs = await read(start, count * width);
|
|
139
|
+
for (let i = 0; i < count; i++) {
|
|
140
|
+
const offset = width * i;
|
|
141
|
+
segments.push({ type: programs.readUInt32LE(offset), offset: number(programs, offset + 8),
|
|
142
|
+
address: number(programs, offset + 16), size: number(programs, offset + 32) });
|
|
143
|
+
}
|
|
144
|
+
for (const type of [2, 3])
|
|
145
|
+
if (segments.filter(segment => segment.type === type).length > 1)
|
|
146
|
+
throw new Error('duplicate ELF program metadata');
|
|
147
|
+
const result = { needed: [], search: [], inherit: false };
|
|
148
|
+
const interpreter = segments.find(s => s.type === 3);
|
|
149
|
+
if (interpreter)
|
|
150
|
+
result.interpreter = cstring(await read(interpreter.offset, interpreter.size, 4096), 0);
|
|
151
|
+
const dynamic = segments.find(s => s.type === 2);
|
|
152
|
+
if (!dynamic)
|
|
153
|
+
return result;
|
|
154
|
+
const table = await read(dynamic.offset, dynamic.size, 256 * 1024), tags = [];
|
|
155
|
+
for (let offset = 0; offset + 16 <= table.length; offset += 16) {
|
|
156
|
+
const tag = Number(table.readBigInt64LE(offset)), value = number(table, offset + 8);
|
|
157
|
+
if (!tag)
|
|
158
|
+
break;
|
|
159
|
+
tags.push([tag, value]);
|
|
160
|
+
}
|
|
161
|
+
for (const type of [5, 10, 15, 29])
|
|
162
|
+
if (tags.filter(([tag]) => tag === type).length > 1)
|
|
163
|
+
throw new Error('duplicate ELF dynamic metadata');
|
|
164
|
+
const address = tags.find(([tag]) => tag === 5)?.[1], length = tags.find(([tag]) => tag === 10)?.[1];
|
|
165
|
+
if (address === undefined || length === undefined) {
|
|
166
|
+
if (tags.some(([tag]) => tag === 1))
|
|
167
|
+
throw new Error('ELF string table missing');
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
const segment = segments.find(s => s.type === 1 && address >= s.address && address + length <= s.address + s.size);
|
|
171
|
+
if (!segment)
|
|
172
|
+
throw new Error('ELF string table unmapped');
|
|
173
|
+
const strings = await read(segment.offset + address - segment.address, length, 8 * 1024 * 1024);
|
|
174
|
+
if (tags.filter(([tag]) => tag === 1).length > 512)
|
|
175
|
+
throw new Error('ELF dependency count bound exceeded');
|
|
176
|
+
result.needed = tags.filter(([tag]) => tag === 1).map(([, offset]) => cstring(strings, offset));
|
|
177
|
+
const runpath = tags.find(([tag]) => tag === 29);
|
|
178
|
+
const rpath = tags.find(([tag]) => tag === 15);
|
|
179
|
+
const searches = new Map();
|
|
180
|
+
for (const entry of [rpath, runpath]) {
|
|
181
|
+
if (!entry)
|
|
182
|
+
continue;
|
|
183
|
+
const paths = [];
|
|
184
|
+
const directories = cstring(strings, entry[1]).split(':');
|
|
185
|
+
if (directories.length > 64)
|
|
186
|
+
throw new Error('ELF search directory count bound exceeded');
|
|
187
|
+
for (const value of directories) {
|
|
188
|
+
const expanded = value.replace(/\$\{ORIGIN\}|\$ORIGIN(?=\/|$)/gu, dirname(path));
|
|
189
|
+
if (!isAbsolute(expanded) || expanded.includes('$') || [...expanded].some(character => character.charCodeAt(0) < 32))
|
|
190
|
+
throw new Error(`unsupported ELF search path: ${path}`);
|
|
191
|
+
const directory = resolve(expanded);
|
|
192
|
+
const approved = (value) => ['/usr/lib', '/usr/lib64', '/lib', '/lib64'].some(root => value === root || value.startsWith(root + '/'))
|
|
193
|
+
|| value === dirname(path) && /^\/usr\/(?:bin|sbin|libexec)\//u.test(path);
|
|
194
|
+
if (!approved(directory))
|
|
195
|
+
throw new Error(`unsafe ELF search directory: ${directory}`);
|
|
196
|
+
const source = await inspectTrustedLinuxSystemSource(directory).catch(() => { throw new Error(`unsafe ELF search directory: ${directory}`); });
|
|
197
|
+
if (!source.info.isDirectory() || !approved(source.path))
|
|
198
|
+
throw new Error(`unsafe ELF search directory: ${directory}`);
|
|
199
|
+
paths.push(directory);
|
|
200
|
+
}
|
|
201
|
+
searches.set(entry[0], paths);
|
|
202
|
+
}
|
|
203
|
+
result.inherit = !runpath && Boolean(rpath);
|
|
204
|
+
result.search = searches.get(runpath ? 29 : 15) ?? [];
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
async function sourceElf(source, signal, virtualPath = source.path) {
|
|
208
|
+
const fd = await open(source.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
209
|
+
try {
|
|
210
|
+
if (stamp(await fd.stat()) !== stamp(source.info) || await realpath(`/proc/self/fd/${fd.fd}`) !== source.path)
|
|
211
|
+
throw new Error('system ELF descriptor changed');
|
|
212
|
+
const result = await parseLinuxSystemElf(async (offset, length) => {
|
|
213
|
+
checkAbort(signal);
|
|
214
|
+
const buffer = Buffer.alloc(length);
|
|
215
|
+
let used = 0;
|
|
216
|
+
while (used < length) {
|
|
217
|
+
const part = await fd.read(buffer, used, length - used, offset + used);
|
|
218
|
+
if (!part.bytesRead)
|
|
219
|
+
throw new Error('system ELF truncated');
|
|
220
|
+
used += part.bytesRead;
|
|
221
|
+
}
|
|
222
|
+
return buffer;
|
|
223
|
+
}, source.info.size, virtualPath);
|
|
224
|
+
if (stamp(await fd.stat()) !== stamp(source.info) || stamp(await lstat(source.path)) !== stamp(source.info))
|
|
225
|
+
throw new Error('system ELF source changed');
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
await fd.close();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** The destination must be fresh, below a canonical owner-private directory. */
|
|
233
|
+
export async function prepareLinuxSystemSnapshot(root, signal, runtime) {
|
|
234
|
+
if (process.platform !== 'linux' || !canonical(root) || root === '/')
|
|
235
|
+
throw new Error('invalid system snapshot destination');
|
|
236
|
+
checkAbort(signal);
|
|
237
|
+
const parent = await lstat(dirname(root));
|
|
238
|
+
if (!parent.isDirectory() || parent.uid !== process.getuid?.() || parent.mode & 0o077
|
|
239
|
+
|| await realpath(dirname(root)) !== dirname(root))
|
|
240
|
+
throw new Error('system snapshot parent must be owner-private');
|
|
241
|
+
const fixedRuntime = runtime ? await verifyLinuxRuntime(runtime) : undefined;
|
|
242
|
+
checkAbort(signal);
|
|
243
|
+
await mkdir(root, { mode: 0o700 });
|
|
244
|
+
const created = await lstat(root);
|
|
245
|
+
const origins = new Map(), sources = new Map();
|
|
246
|
+
const programs = {};
|
|
247
|
+
const expandedDirectories = new Set();
|
|
248
|
+
const activeDirectories = new Set();
|
|
249
|
+
const metadataByPath = new Map();
|
|
250
|
+
const scriptByPath = new Map();
|
|
251
|
+
const contexts = new Set();
|
|
252
|
+
const resolutions = new Map();
|
|
253
|
+
let bytes = 0, entries = 1;
|
|
254
|
+
const architecture = process.arch === 'x64' ? 'x86_64-linux-gnu' : process.arch === 'arm64' ? 'aarch64-linux-gnu' : '';
|
|
255
|
+
const libraryDirs = [`/lib/${architecture}`, `/usr/lib/${architecture}`, '/lib64', '/usr/lib64', '/lib', '/usr/lib'];
|
|
256
|
+
const allocate = (size = 0) => {
|
|
257
|
+
checkAbort(signal);
|
|
258
|
+
bytes += size;
|
|
259
|
+
if (++entries > linuxSystemSnapshotLimits.entries || bytes > linuxSystemSnapshotLimits.bytes)
|
|
260
|
+
throw new Error('system snapshot size bound exceeded');
|
|
261
|
+
};
|
|
262
|
+
const remember = (path, info, digest) => {
|
|
263
|
+
const old = sources.get(path);
|
|
264
|
+
if (old && (stamp(old.info) !== stamp(info) || old.digest && digest && old.digest !== digest))
|
|
265
|
+
throw new Error('system source changed during preparation');
|
|
266
|
+
sources.set(path, { info, digest: digest ?? old?.digest });
|
|
267
|
+
};
|
|
268
|
+
const directory = async (path) => {
|
|
269
|
+
if (path === '/' || origins.has(path))
|
|
270
|
+
return;
|
|
271
|
+
await directory(dirname(path));
|
|
272
|
+
const source = await inspectTrustedLinuxSystemSource(path);
|
|
273
|
+
if (source.path !== path || !source.info.isDirectory())
|
|
274
|
+
throw new Error('noncanonical system directory');
|
|
275
|
+
allocate();
|
|
276
|
+
remember(path, source.info);
|
|
277
|
+
await mkdir(join(root, path), { mode: 0o755 });
|
|
278
|
+
await chmod(join(root, path), 0o755);
|
|
279
|
+
origins.set(path, { path, source: path, sourceMode: source.info.mode & 0o7777, kind: 'dir', mode: 0o755 });
|
|
280
|
+
};
|
|
281
|
+
const optional = async (path) => {
|
|
282
|
+
try {
|
|
283
|
+
await lstat(path);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (error.code === 'ENOENT')
|
|
288
|
+
return false;
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const library = async (name, search) => {
|
|
293
|
+
if (!name || name === '.' || name === '..' || name.includes('/'))
|
|
294
|
+
throw new Error(`unsupported ELF dependency: ${name}`);
|
|
295
|
+
for (const dirs of [search, libraryDirs]) {
|
|
296
|
+
const matches = new Map();
|
|
297
|
+
for (const dir of dirs) {
|
|
298
|
+
const path = join(dir, name);
|
|
299
|
+
if (await optional(path))
|
|
300
|
+
matches.set((await inspectTrustedLinuxSystemSource(path)).path, path);
|
|
301
|
+
}
|
|
302
|
+
if (matches.size > 1)
|
|
303
|
+
throw new Error(`ambiguous system library: ${name}`);
|
|
304
|
+
if (matches.size)
|
|
305
|
+
return [...matches.values()][0];
|
|
306
|
+
}
|
|
307
|
+
throw new Error(`missing system library: ${name}`);
|
|
308
|
+
};
|
|
309
|
+
const add = async (requested, recursive = true, inherited = []) => {
|
|
310
|
+
checkAbort(signal);
|
|
311
|
+
const source = await inspectTrustedLinuxSystemSource(requested);
|
|
312
|
+
if (!source.path.startsWith('/usr/') && !source.path.startsWith('/etc/alternatives/')
|
|
313
|
+
&& !source.path.startsWith('/etc/ssl/certs/') && source.path !== '/etc/ssl/certs'
|
|
314
|
+
&& !source.path.startsWith('/run/systemd/resolve/')
|
|
315
|
+
&& !/^\/etc\/python[0-9]+\.[0-9]+\/sitecustomize\.py$/u.test(source.path)
|
|
316
|
+
&& !['/etc/ssl/openssl.cnf', '/etc/nsswitch.conf', '/etc/hosts', '/etc/resolv.conf'].includes(source.path)) {
|
|
317
|
+
throw new Error(`system source escaped the fixed data roots: ${source.path}`);
|
|
318
|
+
}
|
|
319
|
+
for (const link of source.links) {
|
|
320
|
+
if (origins.has(link.path))
|
|
321
|
+
continue;
|
|
322
|
+
await directory(dirname(link.path));
|
|
323
|
+
const target = resolve(dirname(link.path), link.target);
|
|
324
|
+
// Link paths are retained, but absolute host targets become relative guest targets.
|
|
325
|
+
allocate();
|
|
326
|
+
remember(link.path, link.info);
|
|
327
|
+
await symlink(relative(dirname(link.path), target) || '.', join(root, link.path));
|
|
328
|
+
origins.set(link.path, { path: link.path, source: link.path, sourceMode: link.info.mode & 0o7777, kind: 'link', mode: 0o777, sourceTarget: link.target, target: relative(dirname(link.path), target) || '.' });
|
|
329
|
+
}
|
|
330
|
+
if (source.info.isDirectory()) {
|
|
331
|
+
await directory(source.path);
|
|
332
|
+
if (recursive && !expandedDirectories.has(source.path)) {
|
|
333
|
+
if (activeDirectories.has(source.path))
|
|
334
|
+
throw new Error('system source directory link cycle');
|
|
335
|
+
activeDirectories.add(source.path);
|
|
336
|
+
for (const name of (await readdir(source.path)).sort()) {
|
|
337
|
+
if (name !== '__pycache__' && !name.endsWith('.pyc'))
|
|
338
|
+
await add(join(source.path, name));
|
|
339
|
+
}
|
|
340
|
+
activeDirectories.delete(source.path);
|
|
341
|
+
expandedDirectories.add(source.path);
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!source.info.isFile())
|
|
346
|
+
throw new Error('special system source forbidden');
|
|
347
|
+
if (!origins.has(source.path)) {
|
|
348
|
+
allocate(source.info.size);
|
|
349
|
+
await directory(dirname(source.path));
|
|
350
|
+
const { sha256: digest, prefix: content } = await sourceBytes(source, join(root, source.path), signal);
|
|
351
|
+
remember(source.path, source.info, digest);
|
|
352
|
+
const mode = source.info.mode & 0o111 ? 0o555 : 0o444;
|
|
353
|
+
await chmod(join(root, source.path), mode);
|
|
354
|
+
origins.set(source.path, { path: source.path, source: source.path, sourceMode: source.info.mode & 0o7777, kind: 'file', mode, size: source.info.size, sha256: digest });
|
|
355
|
+
metadataByPath.set(source.path, await sourceElf(source, signal));
|
|
356
|
+
if (source.info.mode & 0o111 && /^\/usr\/(?:s?bin\/|lib(?:exec)?\/git-core\/)/u.test(source.path)
|
|
357
|
+
&& content.subarray(0, 2).toString() === '#!') {
|
|
358
|
+
const newline = content.indexOf(10);
|
|
359
|
+
const interpreter = content.subarray(2, newline < 0 ? Math.min(content.length, 4096) : Math.min(newline, 4096)).toString().trim().split(/\s+/u)[0];
|
|
360
|
+
if (!canonical(interpreter))
|
|
361
|
+
throw new Error('unsupported system script interpreter');
|
|
362
|
+
scriptByPath.set(source.path, interpreter);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const context = JSON.stringify([source.path, inherited]);
|
|
366
|
+
if (contexts.has(context))
|
|
367
|
+
return;
|
|
368
|
+
if (contexts.size >= linuxSystemSnapshotLimits.entries)
|
|
369
|
+
throw new Error('system ELF context bound exceeded');
|
|
370
|
+
contexts.add(context);
|
|
371
|
+
const metadata = metadataByPath.get(source.path);
|
|
372
|
+
const search = [...new Set([...(metadata?.inherit ? metadata.search : []), ...inherited,
|
|
373
|
+
...(!metadata?.inherit ? metadata?.search ?? [] : [])])];
|
|
374
|
+
const nextInherited = metadata?.inherit ? search : inherited;
|
|
375
|
+
if (metadata?.interpreter)
|
|
376
|
+
await add(metadata.interpreter);
|
|
377
|
+
for (const needed of metadata?.needed ?? []) {
|
|
378
|
+
const dependency = await library(needed, search);
|
|
379
|
+
const target = (await inspectTrustedLinuxSystemSource(dependency)).path;
|
|
380
|
+
const key = source.path + ':' + needed, previous = resolutions.get(key);
|
|
381
|
+
if (previous && previous !== target)
|
|
382
|
+
throw new Error('ambiguous inherited system ELF dependency');
|
|
383
|
+
resolutions.set(key, target);
|
|
384
|
+
await add(dependency, true, nextInherited);
|
|
385
|
+
}
|
|
386
|
+
const interpreter = scriptByPath.get(source.path);
|
|
387
|
+
if (interpreter)
|
|
388
|
+
await add(interpreter);
|
|
389
|
+
};
|
|
390
|
+
const probe = async (path, args) => {
|
|
391
|
+
await add(path);
|
|
392
|
+
const source = await inspectTrustedLinuxSystemSource(path);
|
|
393
|
+
const before = (await sourceBytes(source, undefined, signal)).sha256;
|
|
394
|
+
const result = await exec(source.path, args, { timeout: 5000, maxBuffer: 64 * 1024, signal, cwd: '/',
|
|
395
|
+
env: { PATH: '/usr/bin:/bin', HOME: '/nonexistent', LANG: 'C', LC_ALL: 'C' } });
|
|
396
|
+
if ((await sourceBytes(source, undefined, signal)).sha256 !== before)
|
|
397
|
+
throw new Error('system probe changed');
|
|
398
|
+
programs[path + ' ' + args.join(' ')] = result.stdout.trim();
|
|
399
|
+
return result.stdout.trim();
|
|
400
|
+
};
|
|
401
|
+
try {
|
|
402
|
+
if (!architecture)
|
|
403
|
+
throw new Error('unsupported system snapshot architecture');
|
|
404
|
+
for (const name of tools)
|
|
405
|
+
await add(`/usr/bin/${name}`);
|
|
406
|
+
for (const alias of ['/bin', '/sbin', '/lib', '/lib64'])
|
|
407
|
+
if (await optional(alias))
|
|
408
|
+
await add(alias, false);
|
|
409
|
+
const python = await probe('/usr/bin/python3', ['-I', '-c', 'import sysconfig; print(sysconfig.get_path("stdlib"))']);
|
|
410
|
+
if (!/^\/usr\/lib\/python[0-9.]+$/u.test(python))
|
|
411
|
+
throw new Error('unexpected Python standard library');
|
|
412
|
+
await add(python);
|
|
413
|
+
const perl = await probe('/usr/bin/perl', ['-T', '-MConfig', '-e', 'print "$Config{privlib}\n$Config{archlib}\n"']);
|
|
414
|
+
for (const path of perl.split('\n')) {
|
|
415
|
+
if (!/^\/usr\/(?:lib|share)\/[a-zA-Z0-9_./-]+$/u.test(path))
|
|
416
|
+
throw new Error('unexpected Perl library');
|
|
417
|
+
await add(path);
|
|
418
|
+
}
|
|
419
|
+
const git = await probe('/usr/bin/git', ['--exec-path']);
|
|
420
|
+
if (!/^\/usr\/lib(?:exec)?\/git-core$/u.test(git))
|
|
421
|
+
throw new Error('unexpected Git exec path');
|
|
422
|
+
for (const path of [git, '/usr/share/git-core/templates', '/etc/ssl/certs', '/etc/ssl/openssl.cnf',
|
|
423
|
+
`/usr/lib/${architecture}/ossl-modules`, `/usr/lib/${architecture}/engines-3`,
|
|
424
|
+
'/usr/lib/terminfo', '/usr/share/terminfo', '/usr/share/zoneinfo/UTC', '/etc/nsswitch.conf', '/etc/hosts', '/etc/resolv.conf']) {
|
|
425
|
+
if (await optional(path))
|
|
426
|
+
await add(path);
|
|
427
|
+
}
|
|
428
|
+
// Preserve OpenSSL's compiled public defaults without copying the ssl
|
|
429
|
+
// directory: its private-key alias and maintenance scripts are excluded.
|
|
430
|
+
for (const [alias, target] of [['/usr/lib/ssl/openssl.cnf', '/etc/ssl/openssl.cnf'],
|
|
431
|
+
['/usr/lib/ssl/certs', '/etc/ssl/certs'], ['/usr/lib/ssl/cert.pem', '/etc/ssl/certs/ca-certificates.crt']]) {
|
|
432
|
+
if (!await optional(alias))
|
|
433
|
+
continue;
|
|
434
|
+
const source = await inspectTrustedLinuxSystemSource(alias);
|
|
435
|
+
const approved = await inspectTrustedLinuxSystemSource(target);
|
|
436
|
+
if (source.path !== approved.path)
|
|
437
|
+
throw new Error('unexpected public OpenSSL alias target');
|
|
438
|
+
await add(alias, false);
|
|
439
|
+
}
|
|
440
|
+
for (const name of ['libseccomp.so.2', 'libatomic.so.1', 'libnss_files.so.2', 'libnss_dns.so.2',
|
|
441
|
+
'libdl.so.2', 'libpthread.so.0', 'librt.so.1', 'libstdc++.so.6', 'libgcc_s.so.1'])
|
|
442
|
+
await add(await library(name, []));
|
|
443
|
+
if (await optional('/usr/bin/gcc')) {
|
|
444
|
+
for (const name of ['cc', 'gcc', 'g++', 'c++', 'as', 'ld', 'ar', 'ranlib', 'nm', 'strip', 'objcopy', 'objdump', 'readelf'])
|
|
445
|
+
if (await optional(`/usr/bin/${name}`))
|
|
446
|
+
await add(`/usr/bin/${name}`);
|
|
447
|
+
const gcc = await probe('/usr/bin/gcc', ['-print-libgcc-file-name']);
|
|
448
|
+
if (!gcc.startsWith(`/usr/lib/gcc/${architecture}/`) || basename(gcc) !== 'libgcc.a')
|
|
449
|
+
throw new Error('unexpected GCC library directory');
|
|
450
|
+
await add(dirname(gcc));
|
|
451
|
+
await add('/usr/include');
|
|
452
|
+
const scripts = `/usr/lib/${architecture}/ldscripts`;
|
|
453
|
+
if (await optional(scripts))
|
|
454
|
+
await add(scripts);
|
|
455
|
+
for (const name of ['crt1.o', 'Scrt1.o', 'crti.o', 'crtn.o', 'libc.so', 'libc.a', 'libc_nonshared.a', 'libm.so', 'libm.a', 'libmvec.so', 'libmvec.a', 'libpthread.a', 'libdl.a', 'librt.a']) {
|
|
456
|
+
const path = `/usr/lib/${architecture}/${name}`;
|
|
457
|
+
if (await optional(path))
|
|
458
|
+
await add(path);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (fixedRuntime) {
|
|
462
|
+
const path = fixedRuntime.nodePath, source = { path, info: await lstat(path), links: [] };
|
|
463
|
+
if (await realpath(path) !== path || !source.info.isFile() || source.info.uid !== process.getuid?.()
|
|
464
|
+
|| source.info.nlink !== 1 || source.info.mode & 0o022 || !(source.info.mode & 0o111))
|
|
465
|
+
throw new Error('unsafe pinned runtime Node');
|
|
466
|
+
allocate(source.info.size);
|
|
467
|
+
await directory('/usr/bin');
|
|
468
|
+
const copied = await sourceBytes(source, join(root, 'usr/bin/node'), signal);
|
|
469
|
+
if (copied.sha256 !== fixedRuntime.nodeSha256)
|
|
470
|
+
throw new Error('pinned runtime Node digest differs');
|
|
471
|
+
await chmod(join(root, 'usr/bin/node'), 0o555);
|
|
472
|
+
remember(path, source.info, copied.sha256);
|
|
473
|
+
origins.set('/usr/bin/node', { path: '/usr/bin/node', source: path, sourceMode: source.info.mode & 0o7777,
|
|
474
|
+
kind: 'pinned-runtime-node', mode: 0o555, size: source.info.size, sha256: copied.sha256,
|
|
475
|
+
pinnedNodeRuntimeDigest: pinnedNodeRuntimeDigest(fixedRuntime) });
|
|
476
|
+
const metadata = await sourceElf(source, signal);
|
|
477
|
+
if (!metadata)
|
|
478
|
+
throw new Error('pinned runtime Node is not ELF');
|
|
479
|
+
if (metadata.interpreter)
|
|
480
|
+
await add(metadata.interpreter);
|
|
481
|
+
for (const name of metadata.needed)
|
|
482
|
+
await add(await library(name, metadata.search), true, metadata.inherit ? metadata.search : []);
|
|
483
|
+
}
|
|
484
|
+
for (const [path, args] of [['/usr/bin/bash', ['--version']], ['/usr/bin/git', ['--version']],
|
|
485
|
+
['/usr/bin/openssl', ['version']], ['/usr/bin/python3', ['--version']], ['/usr/bin/bwrap', ['--version']]]) {
|
|
486
|
+
await probe(path, [...args]);
|
|
487
|
+
}
|
|
488
|
+
for (const [path, record] of sources) {
|
|
489
|
+
checkAbort(signal);
|
|
490
|
+
const current = await lstat(path);
|
|
491
|
+
if (stamp(current) !== stamp(record.info) || record.digest && (await sourceBytes({ path, info: current, links: [] }, undefined, signal)).sha256 !== record.digest)
|
|
492
|
+
throw new Error('system source changed before commit');
|
|
493
|
+
}
|
|
494
|
+
const manifest = { schemaVersion: 1, platform: `linux-${process.arch}`, tools: programs,
|
|
495
|
+
origins: [...origins.values()].sort((a, b) => a.path.localeCompare(b.path, 'en')) };
|
|
496
|
+
const encoded = JSON.stringify(manifest) + '\n';
|
|
497
|
+
if (Buffer.byteLength(encoded) > MAX_MANIFEST)
|
|
498
|
+
throw new Error('system snapshot manifest bound exceeded');
|
|
499
|
+
allocate(Buffer.byteLength(encoded));
|
|
500
|
+
await writeFile(join(root, MANIFEST), encoded, { mode: 0o444, flag: 'wx' });
|
|
501
|
+
await chmod(join(root, MANIFEST), 0o444);
|
|
502
|
+
await chmod(root, 0o755);
|
|
503
|
+
const info = await lstat(root);
|
|
504
|
+
const snapshot = { schemaVersion: 1, root: { path: root, dev: info.dev, ino: info.ino, sha256: await hashLinuxRuntimeTree(root) },
|
|
505
|
+
manifestSha256: sha(encoded), bytes, entries };
|
|
506
|
+
checkAbort(signal);
|
|
507
|
+
return await verifyLinuxSystemSnapshot(snapshot, signal);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
const current = await lstat(root).catch((failure) => {
|
|
511
|
+
if (failure.code === 'ENOENT')
|
|
512
|
+
return undefined;
|
|
513
|
+
throw failure;
|
|
514
|
+
});
|
|
515
|
+
if (current && (current.dev !== created.dev || current.ino !== created.ino || !current.isDirectory() || current.uid !== process.getuid?.())) {
|
|
516
|
+
throw new Error('system snapshot destination replaced; cleanup refused', { cause: error });
|
|
517
|
+
}
|
|
518
|
+
if (current)
|
|
519
|
+
await rm(root, { recursive: true, force: true });
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** Verify actual modes, complete inventory and bytes, independently of live host sources. */
|
|
524
|
+
export async function verifyLinuxSystemSnapshot(value, signal) {
|
|
525
|
+
checkAbort(signal);
|
|
526
|
+
if (!value || Object.keys(value).sort().join() !== 'bytes,entries,manifestSha256,root,schemaVersion'
|
|
527
|
+
|| value.schemaVersion !== 1 || !value.root || Object.keys(value.root).sort().join() !== 'dev,ino,path,sha256'
|
|
528
|
+
|| !canonical(value.root.path) || value.root.path === '/' || !HEX.test(value.root.sha256) || !HEX.test(value.manifestSha256)
|
|
529
|
+
|| !Number.isSafeInteger(value.root.dev) || value.root.dev < 0 || !Number.isSafeInteger(value.root.ino) || value.root.ino < 0
|
|
530
|
+
|| !Number.isSafeInteger(value.bytes) || value.bytes < 1 || value.bytes > linuxSystemSnapshotLimits.bytes
|
|
531
|
+
|| !Number.isSafeInteger(value.entries) || value.entries < 2 || value.entries > linuxSystemSnapshotLimits.entries)
|
|
532
|
+
throw new Error('invalid system snapshot');
|
|
533
|
+
const root = value.root.path, info = await lstat(root);
|
|
534
|
+
if (await realpath(root) !== root || !info.isDirectory() || info.uid !== process.getuid?.() || (info.mode & 0o7777) !== 0o755
|
|
535
|
+
|| info.dev !== value.root.dev || info.ino !== value.root.ino)
|
|
536
|
+
throw new Error('system snapshot root differs');
|
|
537
|
+
const manifestInfo = await lstat(join(root, MANIFEST));
|
|
538
|
+
if (!manifestInfo.isFile() || manifestInfo.size > MAX_MANIFEST || (manifestInfo.mode & 0o7777) !== 0o444)
|
|
539
|
+
throw new Error('invalid system snapshot manifest');
|
|
540
|
+
const encoded = await readFile(join(root, MANIFEST));
|
|
541
|
+
if (sha(encoded) !== value.manifestSha256)
|
|
542
|
+
throw new Error('system snapshot manifest differs');
|
|
543
|
+
const manifest = JSON.parse(encoded.toString());
|
|
544
|
+
if (manifest.schemaVersion !== 1 || manifest.platform !== `linux-${process.arch}` || !Array.isArray(manifest.origins)
|
|
545
|
+
|| manifest.origins.length + 2 !== value.entries)
|
|
546
|
+
throw new Error('invalid system snapshot inventory');
|
|
547
|
+
const expected = new Map();
|
|
548
|
+
for (const origin of manifest.origins) {
|
|
549
|
+
if (!canonical(origin.path) || origin.path === '/' || expected.has(origin.path)
|
|
550
|
+
|| origin.path === '/' + MANIFEST || !['file', 'dir', 'link', 'pinned-runtime-node'].includes(origin.kind)
|
|
551
|
+
|| (origin.kind === 'pinned-runtime-node' ? origin.path !== '/usr/bin/node' || !canonical(origin.source)
|
|
552
|
+
|| origin.mode !== 0o555 || !HEX.test(origin.pinnedNodeRuntimeDigest ?? '')
|
|
553
|
+
: origin.source !== origin.path || origin.pinnedNodeRuntimeDigest !== undefined))
|
|
554
|
+
throw new Error('invalid system snapshot origin');
|
|
555
|
+
expected.set(origin.path, origin);
|
|
556
|
+
}
|
|
557
|
+
let bytes = encoded.length, entries = 1;
|
|
558
|
+
const visit = async (path) => {
|
|
559
|
+
checkAbort(signal);
|
|
560
|
+
if (++entries > linuxSystemSnapshotLimits.entries)
|
|
561
|
+
throw new Error('system snapshot entry bound exceeded');
|
|
562
|
+
const item = await lstat(path), name = '/' + relative(root, path), origin = expected.get(name);
|
|
563
|
+
if (item.uid !== process.getuid?.())
|
|
564
|
+
throw new Error('system snapshot owner differs');
|
|
565
|
+
if (name === '/' + MANIFEST)
|
|
566
|
+
return;
|
|
567
|
+
if (!origin || (item.mode & 0o7777) !== origin.mode)
|
|
568
|
+
throw new Error('system snapshot inventory differs');
|
|
569
|
+
if (item.isSymbolicLink()) {
|
|
570
|
+
const target = await readlink(path);
|
|
571
|
+
const lexical = resolve(dirname(path), target);
|
|
572
|
+
if (origin.kind !== 'link' || typeof origin.sourceTarget !== 'string'
|
|
573
|
+
|| resolve(dirname(origin.path), origin.sourceTarget) !== resolve(dirname(origin.path), target) || isAbsolute(target) || target !== origin.target || !lexical.startsWith(root + '/')
|
|
574
|
+
|| !(await realpath(path)).startsWith(root + '/'))
|
|
575
|
+
throw new Error('system snapshot link escaped or differs');
|
|
576
|
+
}
|
|
577
|
+
else if (item.isDirectory()) {
|
|
578
|
+
if (origin.kind !== 'dir' || origin.mode !== 0o755)
|
|
579
|
+
throw new Error('system snapshot directory differs');
|
|
580
|
+
for (const child of (await readdir(path)).sort())
|
|
581
|
+
await visit(join(path, child));
|
|
582
|
+
}
|
|
583
|
+
else if (item.isFile()) {
|
|
584
|
+
if (origin.kind !== 'file' && origin.kind !== 'pinned-runtime-node' || item.nlink !== 1 || ![0o444, 0o555].includes(origin.mode) || item.size !== origin.size
|
|
585
|
+
|| (bytes += item.size) > linuxSystemSnapshotLimits.bytes || (await sourceBytes({ path, info: item, links: [] }, undefined, signal)).sha256 !== origin.sha256)
|
|
586
|
+
throw new Error('system snapshot file differs');
|
|
587
|
+
}
|
|
588
|
+
else
|
|
589
|
+
throw new Error('system snapshot special file');
|
|
590
|
+
};
|
|
591
|
+
for (const child of (await readdir(root)).sort())
|
|
592
|
+
await visit(join(root, child));
|
|
593
|
+
if (entries !== value.entries || bytes !== value.bytes || await hashLinuxRuntimeTree(root) !== value.root.sha256)
|
|
594
|
+
throw new Error('system snapshot tree differs');
|
|
595
|
+
checkAbort(signal);
|
|
596
|
+
return Object.freeze({ ...value, root: Object.freeze({ ...value.root }) });
|
|
597
|
+
}
|
|
598
|
+
/** Unlike linuxRuntimeDigest, this provenance identity deliberately normalizes
|
|
599
|
+
* the fixed derived paths so an atomic stage->final rename preserves identity. */
|
|
600
|
+
function pinnedNodeRuntimeDigest(value) {
|
|
601
|
+
const runtime = validateLinuxRuntime(value);
|
|
602
|
+
return sha(JSON.stringify({ schemaVersion: runtime.schemaVersion, backend: runtime.backend, platform: runtime.platform,
|
|
603
|
+
sourceCommit: runtime.sourceCommit, lockSha256: runtime.lockSha256,
|
|
604
|
+
root: { dev: runtime.root.dev, ino: runtime.root.ino, sha256: runtime.root.sha256 }, nodeSha256: runtime.nodeSha256,
|
|
605
|
+
versions: { node: runtime.versions.node, pnpm: runtime.versions.pnpm, typescript: runtime.versions.typescript,
|
|
606
|
+
cordis: runtime.versions.cordis, tools: runtime.versions.tools, systemPrompt: runtime.versions.systemPrompt } }));
|
|
607
|
+
}
|
|
608
|
+
/** Check the ABI closure of only the frozen runtime's selected Node/pnpm
|
|
609
|
+
* binaries. Host programs are never selected by caller-supplied arbitrary paths. */
|
|
610
|
+
export async function verifyLinuxSystemSnapshotRuntimeDependencies(snapshot, runtime, signal) {
|
|
611
|
+
snapshot = await verifyLinuxSystemSnapshot(snapshot, signal);
|
|
612
|
+
runtime = await verifyLinuxRuntime(runtime);
|
|
613
|
+
checkAbort(signal);
|
|
614
|
+
const architecture = process.arch === 'x64' ? 'x86_64-linux-gnu' : process.arch === 'arm64' ? 'aarch64-linux-gnu' : '';
|
|
615
|
+
if (!architecture)
|
|
616
|
+
throw new Error('unsupported system runtime dependency architecture');
|
|
617
|
+
const defaults = [`/lib/${architecture}`, `/usr/lib/${architecture}`, '/lib64', '/usr/lib64', '/lib', '/usr/lib'];
|
|
618
|
+
const seen = new Set(), resolutions = new Map();
|
|
619
|
+
const guest = async (path) => {
|
|
620
|
+
if (!canonical(path))
|
|
621
|
+
throw new Error('noncanonical runtime system dependency');
|
|
622
|
+
const actual = await realpath(join(snapshot.root.path, path));
|
|
623
|
+
if (!actual.startsWith(snapshot.root.path + '/'))
|
|
624
|
+
throw new Error('runtime system dependency escaped snapshot');
|
|
625
|
+
const info = await lstat(actual);
|
|
626
|
+
if (!info.isFile() || info.uid !== process.getuid?.() || info.mode & 0o222)
|
|
627
|
+
throw new Error('unsafe runtime system dependency');
|
|
628
|
+
return { path: actual, info, links: [] };
|
|
629
|
+
};
|
|
630
|
+
const library = async (name, search) => {
|
|
631
|
+
if (!name || name === '.' || name === '..' || name.includes('/'))
|
|
632
|
+
throw new Error('unsupported runtime system dependency name');
|
|
633
|
+
for (const group of [search, defaults]) {
|
|
634
|
+
const matches = new Map();
|
|
635
|
+
for (const directory of group) {
|
|
636
|
+
try {
|
|
637
|
+
const source = await guest(join(directory, name));
|
|
638
|
+
matches.set(source.path, source);
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
if (error.code !== 'ENOENT')
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (matches.size > 1)
|
|
646
|
+
throw new Error('ambiguous runtime system dependency');
|
|
647
|
+
if (matches.size)
|
|
648
|
+
return [...matches.values()][0];
|
|
649
|
+
}
|
|
650
|
+
throw new Error(`missing runtime system dependency: ${name}`);
|
|
651
|
+
};
|
|
652
|
+
const visit = async (source, virtual, inherited) => {
|
|
653
|
+
checkAbort(signal);
|
|
654
|
+
const key = JSON.stringify([source.path, inherited]);
|
|
655
|
+
if (seen.has(key))
|
|
656
|
+
return;
|
|
657
|
+
if (seen.size >= linuxSystemSnapshotLimits.entries)
|
|
658
|
+
throw new Error('runtime system dependency context bound exceeded');
|
|
659
|
+
seen.add(key);
|
|
660
|
+
const metadata = await sourceElf(source, signal, virtual);
|
|
661
|
+
if (!metadata) {
|
|
662
|
+
const fd = await open(source.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
663
|
+
try {
|
|
664
|
+
if (stamp(await fd.stat()) !== stamp(source.info))
|
|
665
|
+
throw new Error('runtime script descriptor changed');
|
|
666
|
+
const buffer = Buffer.alloc(4096), read = await fd.read(buffer, 0, buffer.length, 0);
|
|
667
|
+
const line = buffer.subarray(0, read.bytesRead).toString().split('\n')[0];
|
|
668
|
+
const interpreter = /^#!\s*(\/[^\s]+)(?:\s.*)?$/u.exec(line)?.[1];
|
|
669
|
+
if (!interpreter || !canonical(interpreter))
|
|
670
|
+
throw new Error('unsupported selected runtime executable');
|
|
671
|
+
await visit(await guest(interpreter), interpreter, []);
|
|
672
|
+
if (stamp(await fd.stat()) !== stamp(source.info))
|
|
673
|
+
throw new Error('runtime script changed');
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
await fd.close();
|
|
677
|
+
}
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const search = [...new Set([...(metadata.inherit ? metadata.search : []), ...inherited,
|
|
681
|
+
...(!metadata.inherit ? metadata.search : [])])];
|
|
682
|
+
if (metadata.interpreter)
|
|
683
|
+
await visit(await guest(metadata.interpreter), metadata.interpreter, []);
|
|
684
|
+
for (const name of metadata.needed) {
|
|
685
|
+
const dependency = await library(name, search), dependencyKey = source.path + ':' + name;
|
|
686
|
+
if (resolutions.has(dependencyKey) && resolutions.get(dependencyKey) !== dependency.path)
|
|
687
|
+
throw new Error('ambiguous inherited runtime dependency');
|
|
688
|
+
resolutions.set(dependencyKey, dependency.path);
|
|
689
|
+
await visit(dependency, '/' + relative(snapshot.root.path, dependency.path), metadata.inherit ? search : inherited);
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
for (const path of [runtime.nodePath, runtime.nativePnpmPath, join(runtime.nativePnpmRoot, 'node')]) {
|
|
693
|
+
if (await realpath(path) !== path || !path.startsWith(runtime.root.path + '/'))
|
|
694
|
+
throw new Error('selected runtime executable escaped');
|
|
695
|
+
await visit({ path, info: await lstat(path), links: [] }, path, []);
|
|
696
|
+
}
|
|
697
|
+
const manifest = JSON.parse(await readFile(join(snapshot.root.path, MANIFEST), 'utf8'));
|
|
698
|
+
const alias = manifest.origins.find(origin => origin.path === '/usr/bin/node');
|
|
699
|
+
if (!alias || alias.kind !== 'pinned-runtime-node' || alias.sha256 !== runtime.nodeSha256
|
|
700
|
+
|| alias.pinnedNodeRuntimeDigest !== pinnedNodeRuntimeDigest(runtime))
|
|
701
|
+
throw new Error('pinned runtime Node provenance differs');
|
|
702
|
+
checkAbort(signal);
|
|
703
|
+
}
|
|
704
|
+
//# sourceMappingURL=linux-system-snapshot.js.map
|