upstream-radar 0.38.0 → 0.40.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/README.md +58 -12
- package/dist/src/cli.js +94 -4
- package/dist/src/cli.js.map +1 -1
- package/dist/src/dsh-install-observation.d.ts +154 -0
- package/dist/src/dsh-install-observation.d.ts.map +1 -0
- package/dist/src/dsh-install-observation.js +877 -0
- package/dist/src/dsh-install-observation.js.map +1 -0
- package/dist/src/dsh-install-plan.d.ts +28 -0
- package/dist/src/dsh-install-plan.d.ts.map +1 -0
- package/dist/src/dsh-install-plan.js +184 -0
- package/dist/src/dsh-install-plan.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/upstream-alignment.d.ts +4 -0
- package/dist/src/upstream-alignment.d.ts.map +1 -1
- package/dist/src/upstream-alignment.js +30 -2
- package/dist/src/upstream-alignment.js.map +1 -1
- package/dist/src/upstream-observer.d.ts +5 -0
- package/dist/src/upstream-observer.d.ts.map +1 -1
- package/dist/src/upstream-observer.js +75 -16
- package/dist/src/upstream-observer.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/docs/README.zh-CN.md +21 -20
- package/package.json +2 -2
- package/schemas/dsh-install-observation.schema.json +218 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, mkdir, mkdtemp, open, readdir, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { parseNpmSpec } from './npm.js';
|
|
8
|
+
import { satisfiesSemverRange } from './semver.js';
|
|
9
|
+
import { parseNpmTarball } from './tar.js';
|
|
10
|
+
import { TOOL_VERSION } from './version.js';
|
|
11
|
+
export const DSH_INSTALL_OBSERVATION_SCHEMA = 'upstream-radar.dsh-install-observation/v1alpha1';
|
|
12
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 180_000;
|
|
14
|
+
const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
const MAX_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
|
16
|
+
const MAX_REPORT_DETAIL_BYTES = 4 * 1024;
|
|
17
|
+
const MAX_TRACE_BYTES = 16 * 1024 * 1024;
|
|
18
|
+
const MAX_TRACE_LINES = 100_000;
|
|
19
|
+
const MAX_TRACE_EVENTS = 512;
|
|
20
|
+
const MAX_SNAPSHOT_ENTRIES = 25_000;
|
|
21
|
+
const MAX_DIFF_PATHS = 512;
|
|
22
|
+
const MAX_ALLOWED_BUILDS = 16;
|
|
23
|
+
const PROFILE = 'headless';
|
|
24
|
+
const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'];
|
|
25
|
+
function normalizeAllowedBuilds(values) {
|
|
26
|
+
if (values === undefined)
|
|
27
|
+
return [];
|
|
28
|
+
if (values.length > MAX_ALLOWED_BUILDS) {
|
|
29
|
+
throw new Error(`DSH install observation accepts at most ${MAX_ALLOWED_BUILDS} approved dependency builds`);
|
|
30
|
+
}
|
|
31
|
+
const names = new Set();
|
|
32
|
+
for (const value of values) {
|
|
33
|
+
if (value.length > 214 || !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value)) {
|
|
34
|
+
throw new Error(`invalid approved dependency build package name: ${JSON.stringify(value)}`);
|
|
35
|
+
}
|
|
36
|
+
names.add(value);
|
|
37
|
+
}
|
|
38
|
+
return [...names].sort();
|
|
39
|
+
}
|
|
40
|
+
function bounded(value, maximum = MAX_REPORT_DETAIL_BYTES) {
|
|
41
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, character => (`\\u${character.codePointAt(0)?.toString(16).padStart(4, '0') ?? '0000'}`));
|
|
42
|
+
return normalized.length <= maximum ? normalized : `${normalized.slice(0, maximum - 1)}…`;
|
|
43
|
+
}
|
|
44
|
+
function decodeStraceString(value) {
|
|
45
|
+
return value
|
|
46
|
+
.replace(/\\"/g, '"')
|
|
47
|
+
.replace(/\\\\/g, '\\')
|
|
48
|
+
.replace(/\\n/g, '\\n')
|
|
49
|
+
.replace(/\\r/g, '\\r')
|
|
50
|
+
.replace(/\\t/g, '\\t');
|
|
51
|
+
}
|
|
52
|
+
function quotedValues(value, maximum = 32) {
|
|
53
|
+
const values = [];
|
|
54
|
+
const expression = /"((?:\\.|[^"\\])*)"/g;
|
|
55
|
+
for (let match = expression.exec(value); match !== null && values.length < maximum; match = expression.exec(value)) {
|
|
56
|
+
values.push(bounded(decodeStraceString(match[1] ?? ''), 512));
|
|
57
|
+
}
|
|
58
|
+
return values;
|
|
59
|
+
}
|
|
60
|
+
function syscallSucceeded(line) {
|
|
61
|
+
const result = line.match(/\)\s+=\s+([^\s]+)/)?.[1];
|
|
62
|
+
return result !== undefined && !result.startsWith('-1');
|
|
63
|
+
}
|
|
64
|
+
function sandboxPath(value, sandboxRoot) {
|
|
65
|
+
const normalizedRoot = resolve(sandboxRoot);
|
|
66
|
+
const normalizedValue = value.startsWith('/') ? resolve(value) : value;
|
|
67
|
+
if (normalizedValue === normalizedRoot)
|
|
68
|
+
return '$SANDBOX';
|
|
69
|
+
if (normalizedValue.startsWith(`${normalizedRoot}${sep}`)) {
|
|
70
|
+
return `$SANDBOX/${normalizedValue.slice(normalizedRoot.length + 1).replaceAll(sep, '/')}`;
|
|
71
|
+
}
|
|
72
|
+
return bounded(value, 1_024);
|
|
73
|
+
}
|
|
74
|
+
function incrementEvent(events, key, event) {
|
|
75
|
+
const existing = events.get(key);
|
|
76
|
+
if (existing !== undefined)
|
|
77
|
+
existing.count += 1;
|
|
78
|
+
else if (events.size < MAX_TRACE_EVENTS)
|
|
79
|
+
events.set(key, event);
|
|
80
|
+
}
|
|
81
|
+
export function parseDshInstallTrace(traces, sandboxRoot, options = {}) {
|
|
82
|
+
if (traces.length === 0)
|
|
83
|
+
return emptyTraceObservation();
|
|
84
|
+
const processes = new Map();
|
|
85
|
+
const network = new Map();
|
|
86
|
+
const fileWrites = new Map();
|
|
87
|
+
let bytes = 0;
|
|
88
|
+
let lines = 0;
|
|
89
|
+
let parsedEvents = 0;
|
|
90
|
+
let otherLines = 0;
|
|
91
|
+
let truncated = options.inputTruncated === true;
|
|
92
|
+
outer: for (const trace of traces) {
|
|
93
|
+
bytes += Buffer.byteLength(trace);
|
|
94
|
+
for (const line of trace.split(/\r?\n/)) {
|
|
95
|
+
if (line === '')
|
|
96
|
+
continue;
|
|
97
|
+
if (lines >= MAX_TRACE_LINES) {
|
|
98
|
+
truncated = true;
|
|
99
|
+
break outer;
|
|
100
|
+
}
|
|
101
|
+
lines += 1;
|
|
102
|
+
const succeeded = syscallSucceeded(line);
|
|
103
|
+
const execMatch = line.match(/\bexecve\("((?:\\.|[^"\\])*)",\s*\[(.*?)\],/)
|
|
104
|
+
?? line.match(/\bexecveat\([^,]+,\s*"((?:\\.|[^"\\])*)",\s*\[(.*?)\],/);
|
|
105
|
+
if (execMatch !== null) {
|
|
106
|
+
const executable = sandboxPath(decodeStraceString(execMatch[1] ?? ''), sandboxRoot);
|
|
107
|
+
const argumentsList = quotedValues(execMatch[2] ?? '', 16);
|
|
108
|
+
const key = `${executable}\u0000${argumentsList.join('\u0000')}\u0000${succeeded}`;
|
|
109
|
+
incrementEvent(processes, key, { executable, arguments: argumentsList, succeeded, count: 1 });
|
|
110
|
+
parsedEvents += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const networkMatch = line.match(/\b(connect|sendto|sendmsg)\(/);
|
|
114
|
+
if (networkMatch !== null) {
|
|
115
|
+
const operation = networkMatch[1];
|
|
116
|
+
const family = line.match(/sa_family=(AF_[A-Z0-9_]+)/)?.[1] ?? 'unknown';
|
|
117
|
+
const address = line.match(/sin_addr=inet_addr\("([^"]+)"\)/)?.[1]
|
|
118
|
+
?? line.match(/inet_pton\(AF_INET6,\s*"([^"]+)"/)?.[1]
|
|
119
|
+
?? line.match(/sun_path="((?:\\.|[^"\\])*)"/)?.[1];
|
|
120
|
+
if (address !== undefined) {
|
|
121
|
+
const decodedAddress = family === 'AF_UNIX'
|
|
122
|
+
? sandboxPath(decodeStraceString(address), sandboxRoot)
|
|
123
|
+
: bounded(decodeStraceString(address), 256);
|
|
124
|
+
const portText = line.match(/sin6?_port=htons\((\d+)\)/)?.[1];
|
|
125
|
+
const port = portText === undefined ? undefined : Number(portText);
|
|
126
|
+
const key = `${operation}\u0000${family}\u0000${decodedAddress}\u0000${port ?? ''}\u0000${succeeded}`;
|
|
127
|
+
incrementEvent(network, key, {
|
|
128
|
+
operation,
|
|
129
|
+
family,
|
|
130
|
+
address: decodedAddress,
|
|
131
|
+
...(port === undefined ? {} : { port }),
|
|
132
|
+
succeeded,
|
|
133
|
+
count: 1,
|
|
134
|
+
});
|
|
135
|
+
parsedEvents += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const operation = line.match(/\b(openat2?|open|creat|renameat2?|rename|unlink(?:at)?|mkdir(?:at)?|rmdir|symlink(?:at)?|link(?:at)?|truncate|ftruncate|chmod|fchmodat|chown|fchownat)\(/)?.[1];
|
|
140
|
+
if (operation !== undefined) {
|
|
141
|
+
const isOpen = operation === 'open' || operation === 'openat' || operation === 'openat2';
|
|
142
|
+
const writes = !isOpen || /\b(?:O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND)\b/.test(line);
|
|
143
|
+
if (writes) {
|
|
144
|
+
const values = quotedValues(line, 4);
|
|
145
|
+
const first = values[0];
|
|
146
|
+
if (first !== undefined) {
|
|
147
|
+
const hasSourceAndDestination = /^(?:rename|renameat|renameat2|symlink|symlinkat|link|linkat)$/.test(operation);
|
|
148
|
+
const destination = hasSourceAndDestination && values[1] !== undefined ? values[1] : first;
|
|
149
|
+
const path = sandboxPath(destination, sandboxRoot);
|
|
150
|
+
const sourcePath = hasSourceAndDestination ? sandboxPath(first, sandboxRoot) : undefined;
|
|
151
|
+
const key = `${operation}\u0000${sourcePath ?? ''}\u0000${path}\u0000${succeeded}`;
|
|
152
|
+
incrementEvent(fileWrites, key, {
|
|
153
|
+
operation,
|
|
154
|
+
path,
|
|
155
|
+
...(sourcePath === undefined ? {} : { sourcePath }),
|
|
156
|
+
succeeded,
|
|
157
|
+
count: 1,
|
|
158
|
+
});
|
|
159
|
+
parsedEvents += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
otherLines += 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (processes.size >= MAX_TRACE_EVENTS || network.size >= MAX_TRACE_EVENTS || fileWrites.size >= MAX_TRACE_EVENTS) {
|
|
168
|
+
truncated = true;
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
coverage: {
|
|
172
|
+
status: truncated ? 'truncated' : 'captured',
|
|
173
|
+
tracer: 'strace',
|
|
174
|
+
traceFiles: traces.length,
|
|
175
|
+
bytes,
|
|
176
|
+
lines,
|
|
177
|
+
parsedEvents,
|
|
178
|
+
otherLines,
|
|
179
|
+
tamperResistance: 'best-effort-same-container',
|
|
180
|
+
},
|
|
181
|
+
processes: [...processes.values()],
|
|
182
|
+
network: [...network.values()],
|
|
183
|
+
fileWrites: [...fileWrites.values()],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function emptyTraceObservation() {
|
|
187
|
+
return {
|
|
188
|
+
coverage: {
|
|
189
|
+
status: 'missing',
|
|
190
|
+
tracer: 'strace',
|
|
191
|
+
traceFiles: 0,
|
|
192
|
+
bytes: 0,
|
|
193
|
+
lines: 0,
|
|
194
|
+
parsedEvents: 0,
|
|
195
|
+
otherLines: 0,
|
|
196
|
+
tamperResistance: 'best-effort-same-container',
|
|
197
|
+
},
|
|
198
|
+
processes: [],
|
|
199
|
+
network: [],
|
|
200
|
+
fileWrites: [],
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function emptyFilesystemDiff() {
|
|
204
|
+
return {
|
|
205
|
+
created: [],
|
|
206
|
+
modified: [],
|
|
207
|
+
deleted: [],
|
|
208
|
+
totals: { created: 0, modified: 0, deleted: 0 },
|
|
209
|
+
truncated: false,
|
|
210
|
+
snapshotErrors: 0,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function controlledEnvironment(root, hostEnvironment) {
|
|
214
|
+
const environment = {};
|
|
215
|
+
for (const name of ['PATH', 'Path', 'SystemRoot', 'WINDIR', 'LANG', 'LC_ALL', 'SSL_CERT_FILE', 'SSL_CERT_DIR']) {
|
|
216
|
+
const value = hostEnvironment[name];
|
|
217
|
+
if (value !== undefined)
|
|
218
|
+
environment[name] = value;
|
|
219
|
+
}
|
|
220
|
+
if (environment.PATH === undefined && environment.Path === undefined)
|
|
221
|
+
environment.PATH = '/usr/local/bin:/usr/bin:/bin';
|
|
222
|
+
environment.HOME = join(root, 'home');
|
|
223
|
+
environment.DSH_HOME = join(root, 'dsh-home');
|
|
224
|
+
environment.XDG_CACHE_HOME = join(root, 'xdg-cache');
|
|
225
|
+
environment.XDG_CONFIG_HOME = join(root, 'xdg-config');
|
|
226
|
+
environment.XDG_DATA_HOME = join(root, 'xdg-data');
|
|
227
|
+
environment.TMPDIR = join(root, 'tmp');
|
|
228
|
+
environment.NPM_CONFIG_CACHE = join(root, 'npm-cache');
|
|
229
|
+
environment.NPM_CONFIG_USERCONFIG = join(root, 'controlled.npmrc');
|
|
230
|
+
environment.NPM_CONFIG_GLOBALCONFIG = join(root, 'controlled-global.npmrc');
|
|
231
|
+
environment.NPM_CONFIG_AUDIT = 'false';
|
|
232
|
+
environment.NPM_CONFIG_FUND = 'false';
|
|
233
|
+
environment.NPM_CONFIG_UPDATE_NOTIFIER = 'false';
|
|
234
|
+
environment.PNPM_HOME = join(root, 'pnpm-home');
|
|
235
|
+
environment.COREPACK_HOME = join(root, 'corepack-home');
|
|
236
|
+
environment.COREPACK_ENABLE_DOWNLOAD_PROMPT = '0';
|
|
237
|
+
environment.GIT_CONFIG_NOSYSTEM = '1';
|
|
238
|
+
environment.GIT_CONFIG_GLOBAL = join(root, 'controlled.gitconfig');
|
|
239
|
+
environment.GIT_TERMINAL_PROMPT = '0';
|
|
240
|
+
environment.DSH_PERMISSION_MODE = 'read-only';
|
|
241
|
+
environment.DSH_TELEMETRY_MODE = 'DISABLED';
|
|
242
|
+
environment.CI = 'true';
|
|
243
|
+
environment.NO_COLOR = '1';
|
|
244
|
+
return environment;
|
|
245
|
+
}
|
|
246
|
+
function scriptPolicy(environment, enabled) {
|
|
247
|
+
const value = enabled ? 'false' : 'true';
|
|
248
|
+
return {
|
|
249
|
+
...environment,
|
|
250
|
+
NPM_CONFIG_IGNORE_SCRIPTS: value,
|
|
251
|
+
npm_config_ignore_scripts: value,
|
|
252
|
+
PNPM_CONFIG_IGNORE_SCRIPTS: value,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function dshArgs(dshVersion, args) {
|
|
256
|
+
return ['dlx', `--package=@deepseek-ai/dsh@${dshVersion}`, 'dsh', ...args];
|
|
257
|
+
}
|
|
258
|
+
function defaultCommandRunner(input) {
|
|
259
|
+
return new Promise(resolveResult => {
|
|
260
|
+
const traced = input.tracePath !== undefined;
|
|
261
|
+
const command = traced ? 'strace' : input.command;
|
|
262
|
+
const args = traced
|
|
263
|
+
? ['-f', '-qq', '-s', '256', '-o', input.tracePath, '-e', 'trace=%process,%file,%network', '--', input.command, ...input.args]
|
|
264
|
+
: input.args;
|
|
265
|
+
const child = spawn(command, args, {
|
|
266
|
+
cwd: input.cwd,
|
|
267
|
+
env: input.env,
|
|
268
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
269
|
+
shell: false,
|
|
270
|
+
detached: true,
|
|
271
|
+
});
|
|
272
|
+
const stdout = [];
|
|
273
|
+
const stderr = [];
|
|
274
|
+
let stdoutBytes = 0;
|
|
275
|
+
let stderrBytes = 0;
|
|
276
|
+
let outputExceeded = false;
|
|
277
|
+
let timedOut = false;
|
|
278
|
+
let launchError;
|
|
279
|
+
let settled = false;
|
|
280
|
+
let timer;
|
|
281
|
+
const terminate = () => {
|
|
282
|
+
try {
|
|
283
|
+
if (child.pid !== undefined)
|
|
284
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
285
|
+
else
|
|
286
|
+
child.kill('SIGKILL');
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
child.kill('SIGKILL');
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
const finish = (code) => {
|
|
293
|
+
if (settled)
|
|
294
|
+
return;
|
|
295
|
+
settled = true;
|
|
296
|
+
if (timer !== undefined)
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
resolveResult({
|
|
299
|
+
code,
|
|
300
|
+
timedOut,
|
|
301
|
+
outputExceeded,
|
|
302
|
+
stdout: Buffer.concat(stdout, stdoutBytes).toString('utf8'),
|
|
303
|
+
stderr: Buffer.concat(stderr, stderrBytes).toString('utf8'),
|
|
304
|
+
...(launchError === undefined ? {} : { launchError }),
|
|
305
|
+
});
|
|
306
|
+
};
|
|
307
|
+
const collect = (target, chunk, stream) => {
|
|
308
|
+
if (stdoutBytes + stderrBytes + chunk.length > MAX_COMMAND_OUTPUT_BYTES) {
|
|
309
|
+
outputExceeded = true;
|
|
310
|
+
terminate();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (stream === 'stdout')
|
|
314
|
+
stdoutBytes += chunk.length;
|
|
315
|
+
else
|
|
316
|
+
stderrBytes += chunk.length;
|
|
317
|
+
target.push(chunk);
|
|
318
|
+
};
|
|
319
|
+
child.stdout.on('data', (chunk) => collect(stdout, chunk, 'stdout'));
|
|
320
|
+
child.stderr.on('data', (chunk) => collect(stderr, chunk, 'stderr'));
|
|
321
|
+
child.once('error', error => {
|
|
322
|
+
launchError = bounded(error.message);
|
|
323
|
+
finish(null);
|
|
324
|
+
});
|
|
325
|
+
child.once('close', code => finish(code));
|
|
326
|
+
timer = setTimeout(() => {
|
|
327
|
+
timedOut = true;
|
|
328
|
+
terminate();
|
|
329
|
+
}, input.timeoutMs);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
function commandStage(result) {
|
|
333
|
+
if (result.code === 0 && !result.timedOut && !result.outputExceeded && result.launchError === undefined) {
|
|
334
|
+
return { status: 'passed', code: result.code };
|
|
335
|
+
}
|
|
336
|
+
const output = [result.launchError, result.stderr, result.stdout].filter(value => value !== undefined && value !== '').join('\n').trim();
|
|
337
|
+
const prefix = result.timedOut
|
|
338
|
+
? 'command timed out'
|
|
339
|
+
: result.outputExceeded
|
|
340
|
+
? 'command exceeded the output budget'
|
|
341
|
+
: result.launchError !== undefined
|
|
342
|
+
? 'command could not start'
|
|
343
|
+
: `command exited with ${result.code}`;
|
|
344
|
+
return {
|
|
345
|
+
status: 'failed',
|
|
346
|
+
code: result.code,
|
|
347
|
+
detail: output === '' ? prefix : `${prefix}: ${bounded(output)}`,
|
|
348
|
+
...(result.timedOut ? { timedOut: true } : {}),
|
|
349
|
+
...(result.outputExceeded ? { outputExceeded: true } : {}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async function runSafely(runner, input) {
|
|
353
|
+
try {
|
|
354
|
+
return await runner(input);
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
return {
|
|
358
|
+
code: null,
|
|
359
|
+
timedOut: false,
|
|
360
|
+
outputExceeded: false,
|
|
361
|
+
stdout: '',
|
|
362
|
+
stderr: '',
|
|
363
|
+
launchError: bounded(error instanceof Error ? error.message : String(error)),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
async function readRegularFileNoFollow(path, maximum) {
|
|
368
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
369
|
+
try {
|
|
370
|
+
const metadata = await handle.stat();
|
|
371
|
+
if (!metadata.isFile())
|
|
372
|
+
throw new Error(`${basename(path)} is not a regular file`);
|
|
373
|
+
if (metadata.size > maximum)
|
|
374
|
+
throw new Error(`${basename(path)} exceeds ${maximum} bytes`);
|
|
375
|
+
const buffer = Buffer.alloc(metadata.size);
|
|
376
|
+
let offset = 0;
|
|
377
|
+
while (offset < buffer.length) {
|
|
378
|
+
const read = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
379
|
+
if (read.bytesRead === 0)
|
|
380
|
+
break;
|
|
381
|
+
offset += read.bytesRead;
|
|
382
|
+
}
|
|
383
|
+
if (offset !== buffer.length)
|
|
384
|
+
throw new Error(`${basename(path)} changed while it was being read`);
|
|
385
|
+
return buffer;
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await handle.close();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function parsePackedArtifact(result, artifactDirectory, expectedName, expectedVersion) {
|
|
392
|
+
if (commandStage(result).status !== 'passed')
|
|
393
|
+
throw new Error(commandStage(result).detail ?? 'npm pack failed');
|
|
394
|
+
let output;
|
|
395
|
+
try {
|
|
396
|
+
output = JSON.parse(result.stdout.trim());
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
throw new Error('npm pack did not return JSON');
|
|
400
|
+
}
|
|
401
|
+
if (!Array.isArray(output) || output.length !== 1 || typeof output[0] !== 'object' || output[0] === null) {
|
|
402
|
+
throw new Error('npm pack returned an unexpected result');
|
|
403
|
+
}
|
|
404
|
+
const item = output[0];
|
|
405
|
+
const filename = item.filename;
|
|
406
|
+
if (typeof filename !== 'string' || filename === '' || filename !== basename(filename) || !filename.endsWith('.tgz')) {
|
|
407
|
+
throw new Error('npm pack returned an unsafe artifact filename');
|
|
408
|
+
}
|
|
409
|
+
const path = resolve(artifactDirectory, filename);
|
|
410
|
+
if (!path.startsWith(`${resolve(artifactDirectory)}${sep}`))
|
|
411
|
+
throw new Error('npm pack artifact escaped its directory');
|
|
412
|
+
const compressed = await readRegularFileNoFollow(path, MAX_ARTIFACT_BYTES);
|
|
413
|
+
const parsed = parseNpmTarball(compressed);
|
|
414
|
+
const manifestEntry = parsed.entries.find(entry => entry.path === 'package.json' && entry.type === 'file');
|
|
415
|
+
if (manifestEntry?.contents === undefined)
|
|
416
|
+
throw new Error('packed artifact has no package.json');
|
|
417
|
+
let manifest;
|
|
418
|
+
try {
|
|
419
|
+
const value = JSON.parse(manifestEntry.contents.toString('utf8'));
|
|
420
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
421
|
+
throw new Error('not an object');
|
|
422
|
+
manifest = value;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
throw new Error('packed artifact package.json is not valid JSON');
|
|
426
|
+
}
|
|
427
|
+
if (manifest.name !== expectedName || manifest.version !== expectedVersion) {
|
|
428
|
+
throw new Error(`packed artifact identity does not match ${expectedName}@${expectedVersion}`);
|
|
429
|
+
}
|
|
430
|
+
const dsh = typeof manifest.dsh === 'object' && manifest.dsh !== null && !Array.isArray(manifest.dsh)
|
|
431
|
+
? manifest.dsh
|
|
432
|
+
: undefined;
|
|
433
|
+
const bundle = typeof dsh?.bundle === 'object' && dsh.bundle !== null && !Array.isArray(dsh.bundle)
|
|
434
|
+
? dsh.bundle
|
|
435
|
+
: undefined;
|
|
436
|
+
const bundlePatchValue = bundle?.patch;
|
|
437
|
+
if (typeof bundlePatchValue !== 'string' || bundlePatchValue === '')
|
|
438
|
+
throw new Error('packed artifact does not declare dsh.bundle.patch');
|
|
439
|
+
const bundlePatch = bundlePatchValue.replace(/^\.\//, '');
|
|
440
|
+
if (bundlePatch.startsWith('/') || bundlePatch.includes('\\') || bundlePatch.split('/').some(part => part === '..' || part === '')) {
|
|
441
|
+
throw new Error('packed artifact declares an unsafe dsh.bundle.patch');
|
|
442
|
+
}
|
|
443
|
+
if (!parsed.entries.some(entry => entry.path === bundlePatch && entry.type === 'file')) {
|
|
444
|
+
throw new Error(`packed artifact is missing ${bundlePatch}`);
|
|
445
|
+
}
|
|
446
|
+
const scripts = typeof manifest.scripts === 'object' && manifest.scripts !== null && !Array.isArray(manifest.scripts)
|
|
447
|
+
? manifest.scripts
|
|
448
|
+
: {};
|
|
449
|
+
const lifecycleScripts = LIFECYCLE_SCRIPTS.filter(name => typeof scripts[name] === 'string');
|
|
450
|
+
const engines = typeof manifest.engines === 'object' && manifest.engines !== null && !Array.isArray(manifest.engines)
|
|
451
|
+
? manifest.engines
|
|
452
|
+
: undefined;
|
|
453
|
+
const rawNodeEngine = typeof engines?.node === 'string' ? engines.node.trim() : undefined;
|
|
454
|
+
if (rawNodeEngine !== undefined && rawNodeEngine.length > 512) {
|
|
455
|
+
throw new Error('packed artifact Node engine requirement exceeds 512 characters');
|
|
456
|
+
}
|
|
457
|
+
const nodeEngine = rawNodeEngine === undefined || rawNodeEngine === '' ? undefined : bounded(rawNodeEngine, 512);
|
|
458
|
+
const integrity = typeof item.integrity === 'string' ? bounded(item.integrity, 1_024) : undefined;
|
|
459
|
+
return {
|
|
460
|
+
path,
|
|
461
|
+
filename,
|
|
462
|
+
sha256: createHash('sha256').update(compressed).digest('hex'),
|
|
463
|
+
...(integrity === undefined ? {} : { integrity }),
|
|
464
|
+
bytes: compressed.length,
|
|
465
|
+
bundlePatch,
|
|
466
|
+
...(nodeEngine === undefined ? {} : { nodeEngine }),
|
|
467
|
+
lifecycleScripts,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
async function snapshotTree(root) {
|
|
471
|
+
const snapshot = { entries: new Map(), truncated: false, errors: 0 };
|
|
472
|
+
const queue = [root];
|
|
473
|
+
while (queue.length > 0) {
|
|
474
|
+
if (snapshot.entries.size >= MAX_SNAPSHOT_ENTRIES) {
|
|
475
|
+
snapshot.truncated = true;
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
const current = queue.shift();
|
|
479
|
+
if (current === undefined)
|
|
480
|
+
break;
|
|
481
|
+
let metadata;
|
|
482
|
+
try {
|
|
483
|
+
metadata = await lstat(current);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
snapshot.errors += 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const path = relative(root, current).replaceAll(sep, '/') || '.';
|
|
490
|
+
const type = metadata.isDirectory()
|
|
491
|
+
? 'directory'
|
|
492
|
+
: metadata.isFile()
|
|
493
|
+
? 'file'
|
|
494
|
+
: metadata.isSymbolicLink()
|
|
495
|
+
? 'symlink'
|
|
496
|
+
: 'other';
|
|
497
|
+
snapshot.entries.set(path, { type, size: metadata.size, mtimeMs: metadata.mtimeMs });
|
|
498
|
+
if (type !== 'directory')
|
|
499
|
+
continue;
|
|
500
|
+
try {
|
|
501
|
+
const children = await readdir(current, { withFileTypes: true });
|
|
502
|
+
children.sort((left, right) => left.name.localeCompare(right.name));
|
|
503
|
+
for (const child of children)
|
|
504
|
+
queue.push(join(current, child.name));
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
snapshot.errors += 1;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return snapshot;
|
|
511
|
+
}
|
|
512
|
+
async function snapshotSandbox(sandboxRoot, environment) {
|
|
513
|
+
const combined = { entries: new Map(), truncated: false, errors: 0 };
|
|
514
|
+
for (const path of [environment.DSH_HOME, environment.HOME]) {
|
|
515
|
+
if (path === undefined)
|
|
516
|
+
continue;
|
|
517
|
+
const snapshot = await snapshotTree(path);
|
|
518
|
+
combined.truncated ||= snapshot.truncated;
|
|
519
|
+
combined.errors += snapshot.errors;
|
|
520
|
+
const prefix = relative(sandboxRoot, path).replaceAll(sep, '/');
|
|
521
|
+
for (const [entryPath, entry] of snapshot.entries) {
|
|
522
|
+
if (combined.entries.size >= MAX_SNAPSHOT_ENTRIES) {
|
|
523
|
+
combined.truncated = true;
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
combined.entries.set(`$SANDBOX/${prefix}${entryPath === '.' ? '' : `/${entryPath}`}`, entry);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return combined;
|
|
530
|
+
}
|
|
531
|
+
function snapshotSignature(entry) {
|
|
532
|
+
return `${entry.type}\u0000${entry.size}\u0000${entry.type === 'directory' ? '' : entry.mtimeMs}`;
|
|
533
|
+
}
|
|
534
|
+
function diffSnapshots(before, after) {
|
|
535
|
+
const createdAll = [];
|
|
536
|
+
const modifiedAll = [];
|
|
537
|
+
const deletedAll = [];
|
|
538
|
+
for (const [path, entry] of after.entries) {
|
|
539
|
+
const previous = before.entries.get(path);
|
|
540
|
+
if (previous === undefined)
|
|
541
|
+
createdAll.push(path);
|
|
542
|
+
else if (snapshotSignature(previous) !== snapshotSignature(entry))
|
|
543
|
+
modifiedAll.push(path);
|
|
544
|
+
}
|
|
545
|
+
for (const path of before.entries.keys()) {
|
|
546
|
+
if (!after.entries.has(path))
|
|
547
|
+
deletedAll.push(path);
|
|
548
|
+
}
|
|
549
|
+
createdAll.sort();
|
|
550
|
+
modifiedAll.sort();
|
|
551
|
+
deletedAll.sort();
|
|
552
|
+
return {
|
|
553
|
+
created: createdAll.slice(0, MAX_DIFF_PATHS),
|
|
554
|
+
modified: modifiedAll.slice(0, MAX_DIFF_PATHS),
|
|
555
|
+
deleted: deletedAll.slice(0, MAX_DIFF_PATHS),
|
|
556
|
+
totals: { created: createdAll.length, modified: modifiedAll.length, deleted: deletedAll.length },
|
|
557
|
+
truncated: before.truncated || after.truncated
|
|
558
|
+
|| createdAll.length > MAX_DIFF_PATHS || modifiedAll.length > MAX_DIFF_PATHS || deletedAll.length > MAX_DIFF_PATHS,
|
|
559
|
+
snapshotErrors: before.errors + after.errors,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
async function readTrace(path, sandboxRoot) {
|
|
563
|
+
try {
|
|
564
|
+
const metadata = await lstat(path);
|
|
565
|
+
if (!metadata.isFile())
|
|
566
|
+
return emptyTraceObservation();
|
|
567
|
+
const truncated = metadata.size > MAX_TRACE_BYTES;
|
|
568
|
+
const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
569
|
+
try {
|
|
570
|
+
const length = Math.min(metadata.size, MAX_TRACE_BYTES);
|
|
571
|
+
const buffer = Buffer.alloc(length);
|
|
572
|
+
let offset = 0;
|
|
573
|
+
while (offset < length) {
|
|
574
|
+
const read = await handle.read(buffer, offset, length - offset, offset);
|
|
575
|
+
if (read.bytesRead === 0)
|
|
576
|
+
break;
|
|
577
|
+
offset += read.bytesRead;
|
|
578
|
+
}
|
|
579
|
+
return parseDshInstallTrace([buffer.subarray(0, offset).toString('utf8')], sandboxRoot, { inputTruncated: truncated });
|
|
580
|
+
}
|
|
581
|
+
finally {
|
|
582
|
+
await handle.close();
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return emptyTraceObservation();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async function registeredBundle(dshHome, packageName) {
|
|
590
|
+
try {
|
|
591
|
+
const contents = await readRegularFileNoFollow(join(dshHome, 'profiles', PROFILE, 'package.json'), 4 * 1024 * 1024);
|
|
592
|
+
const manifest = JSON.parse(contents.toString('utf8'));
|
|
593
|
+
const dsh = typeof manifest.dsh === 'object' && manifest.dsh !== null && !Array.isArray(manifest.dsh)
|
|
594
|
+
? manifest.dsh
|
|
595
|
+
: undefined;
|
|
596
|
+
const profile = typeof dsh?.profile === 'object' && dsh.profile !== null && !Array.isArray(dsh.profile)
|
|
597
|
+
? dsh.profile
|
|
598
|
+
: undefined;
|
|
599
|
+
return Array.isArray(profile?.bundles) && profile.bundles.includes(packageName);
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function finishReport(report, result, reason) {
|
|
606
|
+
report.completedAt = new Date().toISOString();
|
|
607
|
+
report.result = result;
|
|
608
|
+
report.reason = reason;
|
|
609
|
+
return report;
|
|
610
|
+
}
|
|
611
|
+
export async function observeDshPluginInstall(options) {
|
|
612
|
+
const spec = parseNpmSpec(options.packageSpec);
|
|
613
|
+
if (!EXACT_VERSION.test(options.dshVersion))
|
|
614
|
+
throw new Error('DSH version must be an exact semantic version');
|
|
615
|
+
const allowedBuilds = normalizeAllowedBuilds(options.allowedBuilds);
|
|
616
|
+
if (!options.allowExecution)
|
|
617
|
+
throw new Error('DSH install observation requires explicit execution consent');
|
|
618
|
+
if (!['github-actions-hosted-runner', 'firecracker', 'other'].includes(options.isolationProvider)) {
|
|
619
|
+
throw new Error('unsupported isolation provider');
|
|
620
|
+
}
|
|
621
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
622
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 30_000 || timeoutMs > 600_000) {
|
|
623
|
+
throw new Error('DSH install observation timeout must be between 30000 and 600000 milliseconds');
|
|
624
|
+
}
|
|
625
|
+
if (options.runner === undefined) {
|
|
626
|
+
if (process.platform !== 'linux')
|
|
627
|
+
throw new Error('DSH install observation requires Linux strace');
|
|
628
|
+
const declaration = options.hostEnvironment?.UPSTREAM_RADAR_ISOLATED_RUNNER ?? process.env.UPSTREAM_RADAR_ISOLATED_RUNNER;
|
|
629
|
+
if (declaration !== '1')
|
|
630
|
+
throw new Error('DSH install observation requires UPSTREAM_RADAR_ISOLATED_RUNNER=1');
|
|
631
|
+
}
|
|
632
|
+
const startedAt = new Date().toISOString();
|
|
633
|
+
const report = {
|
|
634
|
+
schema: DSH_INSTALL_OBSERVATION_SCHEMA,
|
|
635
|
+
tool: { name: 'upstream-radar', version: TOOL_VERSION },
|
|
636
|
+
probe: 'dsh-install',
|
|
637
|
+
scope: 'install-and-load-behavior',
|
|
638
|
+
startedAt,
|
|
639
|
+
completedAt: startedAt,
|
|
640
|
+
dshVersion: options.dshVersion,
|
|
641
|
+
runtime: {
|
|
642
|
+
platform: process.platform,
|
|
643
|
+
architecture: process.arch,
|
|
644
|
+
nodeVersion: process.version.replace(/^v/, ''),
|
|
645
|
+
packageManager: { name: 'pnpm' },
|
|
646
|
+
},
|
|
647
|
+
artifact: {
|
|
648
|
+
spec: spec.canonical,
|
|
649
|
+
name: spec.name,
|
|
650
|
+
version: spec.version,
|
|
651
|
+
lifecycleScripts: [],
|
|
652
|
+
},
|
|
653
|
+
stages: {
|
|
654
|
+
runtime: { status: 'skipped' },
|
|
655
|
+
artifact: { status: 'skipped' },
|
|
656
|
+
profile: { status: 'skipped' },
|
|
657
|
+
install: { status: 'skipped' },
|
|
658
|
+
registration: { status: 'skipped' },
|
|
659
|
+
load: { status: 'skipped' },
|
|
660
|
+
},
|
|
661
|
+
observations: { install: emptyTraceObservation(), load: emptyTraceObservation() },
|
|
662
|
+
filesystem: { install: emptyFilesystemDiff(), load: emptyFilesystemDiff() },
|
|
663
|
+
result: 'unknown',
|
|
664
|
+
reason: 'observation did not complete',
|
|
665
|
+
boundary: {
|
|
666
|
+
isolationProviderClaim: options.isolationProvider,
|
|
667
|
+
isolationVerifiedByRadar: false,
|
|
668
|
+
disposableEnvironmentRequired: true,
|
|
669
|
+
lifecycleScriptsEnabledForPluginInstall: true,
|
|
670
|
+
pluginCodeMayExecuteDuringLoad: true,
|
|
671
|
+
inheritedHostSecrets: false,
|
|
672
|
+
approvedDependencyBuilds: allowedBuilds,
|
|
673
|
+
note: 'Radar scrubs the child environment and records Linux system-call evidence, but the caller provides and must verify the disposable isolation boundary. Same-container traces are best-effort evidence, not a malicious-code safety certificate.',
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
const sandboxRoot = await mkdtemp(join(tmpdir(), 'upstream-radar-dsh-install-'));
|
|
677
|
+
const artifactDirectory = join(sandboxRoot, 'artifact');
|
|
678
|
+
const traceDirectory = join(sandboxRoot, 'trace');
|
|
679
|
+
const hostEnvironment = options.hostEnvironment ?? process.env;
|
|
680
|
+
const environment = controlledEnvironment(sandboxRoot, hostEnvironment);
|
|
681
|
+
const noScriptsEnvironment = scriptPolicy(environment, false);
|
|
682
|
+
const scriptsEnvironment = scriptPolicy(environment, true);
|
|
683
|
+
const runner = options.runner ?? defaultCommandRunner;
|
|
684
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
685
|
+
const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
|
686
|
+
try {
|
|
687
|
+
await Promise.all([
|
|
688
|
+
mkdir(artifactDirectory, { recursive: true, mode: 0o700 }),
|
|
689
|
+
mkdir(traceDirectory, { recursive: true, mode: 0o700 }),
|
|
690
|
+
mkdir(environment.HOME, { recursive: true, mode: 0o700 }),
|
|
691
|
+
mkdir(environment.DSH_HOME, { recursive: true, mode: 0o700 }),
|
|
692
|
+
mkdir(environment.TMPDIR, { recursive: true, mode: 0o700 }),
|
|
693
|
+
]);
|
|
694
|
+
await Promise.all([
|
|
695
|
+
writeFile(join(sandboxRoot, 'controlled.npmrc'), 'registry=https://registry.npmjs.org/\naudit=false\nfund=false\nupdate-notifier=false\n', { mode: 0o600 }),
|
|
696
|
+
writeFile(join(sandboxRoot, 'controlled-global.npmrc'), '', { mode: 0o600 }),
|
|
697
|
+
writeFile(join(sandboxRoot, 'controlled.gitconfig'), '', { mode: 0o600 }),
|
|
698
|
+
]);
|
|
699
|
+
const runtimeResult = await runSafely(runner, {
|
|
700
|
+
phase: 'runtime',
|
|
701
|
+
command: pnpmCommand,
|
|
702
|
+
args: ['--version'],
|
|
703
|
+
cwd: sandboxRoot,
|
|
704
|
+
env: noScriptsEnvironment,
|
|
705
|
+
timeoutMs,
|
|
706
|
+
sandboxRoot,
|
|
707
|
+
});
|
|
708
|
+
report.stages.runtime = commandStage(runtimeResult);
|
|
709
|
+
const packageManagerVersion = runtimeResult.stdout.trim();
|
|
710
|
+
if (report.stages.runtime.status !== 'passed' || !EXACT_VERSION.test(packageManagerVersion)) {
|
|
711
|
+
report.stages.runtime = {
|
|
712
|
+
...report.stages.runtime,
|
|
713
|
+
status: 'failed',
|
|
714
|
+
detail: report.stages.runtime.detail ?? 'pnpm did not return one exact semantic version',
|
|
715
|
+
};
|
|
716
|
+
return finishReport(report, 'unknown', 'the package-manager runtime could not be established before execution');
|
|
717
|
+
}
|
|
718
|
+
report.runtime.packageManager.version = packageManagerVersion;
|
|
719
|
+
const artifactResult = await runSafely(runner, {
|
|
720
|
+
phase: 'artifact',
|
|
721
|
+
command: npmCommand,
|
|
722
|
+
args: ['pack', `${spec.name}@${spec.version}`, '--ignore-scripts', '--json', '--pack-destination', '.'],
|
|
723
|
+
cwd: artifactDirectory,
|
|
724
|
+
env: noScriptsEnvironment,
|
|
725
|
+
timeoutMs,
|
|
726
|
+
sandboxRoot,
|
|
727
|
+
});
|
|
728
|
+
let artifact;
|
|
729
|
+
try {
|
|
730
|
+
artifact = await parsePackedArtifact(artifactResult, artifactDirectory, spec.name, spec.version);
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
report.stages.artifact = {
|
|
734
|
+
...commandStage(artifactResult),
|
|
735
|
+
status: 'failed',
|
|
736
|
+
detail: bounded(error instanceof Error ? error.message : String(error)),
|
|
737
|
+
};
|
|
738
|
+
return finishReport(report, 'unknown', 'the exact npm artifact could not be established before execution');
|
|
739
|
+
}
|
|
740
|
+
report.artifact = {
|
|
741
|
+
...report.artifact,
|
|
742
|
+
sha256: artifact.sha256,
|
|
743
|
+
...(artifact.integrity === undefined ? {} : { integrity: artifact.integrity }),
|
|
744
|
+
bytes: artifact.bytes,
|
|
745
|
+
bundlePatch: artifact.bundlePatch,
|
|
746
|
+
...(artifact.nodeEngine === undefined ? {} : { nodeEngine: artifact.nodeEngine }),
|
|
747
|
+
lifecycleScripts: artifact.lifecycleScripts,
|
|
748
|
+
};
|
|
749
|
+
report.stages.artifact = { status: 'passed', code: artifactResult.code };
|
|
750
|
+
if (artifact.nodeEngine !== undefined) {
|
|
751
|
+
const runtimeMatches = satisfiesSemverRange(report.runtime.nodeVersion, artifact.nodeEngine);
|
|
752
|
+
if (runtimeMatches === false) {
|
|
753
|
+
return finishReport(report, 'runtime-incompatible', `the plugin declares Node ${artifact.nodeEngine}, but the isolated runtime is Node ${report.runtime.nodeVersion}`);
|
|
754
|
+
}
|
|
755
|
+
if (runtimeMatches === undefined) {
|
|
756
|
+
return finishReport(report, 'unknown', `the observer could not safely evaluate the plugin Node requirement ${artifact.nodeEngine}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
const profileResult = await runSafely(runner, {
|
|
761
|
+
phase: 'profile',
|
|
762
|
+
command: pnpmCommand,
|
|
763
|
+
args: dshArgs(options.dshVersion, ['--profile', PROFILE, '--help']),
|
|
764
|
+
cwd: artifactDirectory,
|
|
765
|
+
env: noScriptsEnvironment,
|
|
766
|
+
timeoutMs,
|
|
767
|
+
sandboxRoot,
|
|
768
|
+
});
|
|
769
|
+
report.stages.profile = commandStage(profileResult);
|
|
770
|
+
if (report.stages.profile.status !== 'passed') {
|
|
771
|
+
return finishReport(report, 'unknown', 'the exact DSH runtime could not initialize the disposable profile');
|
|
772
|
+
}
|
|
773
|
+
const beforeInstall = await snapshotSandbox(sandboxRoot, environment);
|
|
774
|
+
const installTracePath = join(traceDirectory, 'install.strace');
|
|
775
|
+
const installResult = await runSafely(runner, {
|
|
776
|
+
phase: 'install',
|
|
777
|
+
command: pnpmCommand,
|
|
778
|
+
args: dshArgs(options.dshVersion, [
|
|
779
|
+
'plugin', '--profile', PROFILE, 'add', join(artifactDirectory, artifact.filename),
|
|
780
|
+
...allowedBuilds.map(name => `--allow-build=${name}`),
|
|
781
|
+
]),
|
|
782
|
+
cwd: artifactDirectory,
|
|
783
|
+
env: scriptsEnvironment,
|
|
784
|
+
timeoutMs,
|
|
785
|
+
sandboxRoot,
|
|
786
|
+
tracePath: installTracePath,
|
|
787
|
+
});
|
|
788
|
+
const afterInstall = await snapshotSandbox(sandboxRoot, environment);
|
|
789
|
+
report.observations.install = await readTrace(installTracePath, sandboxRoot);
|
|
790
|
+
report.filesystem.install = diffSnapshots(beforeInstall, afterInstall);
|
|
791
|
+
report.stages.install = commandStage(installResult);
|
|
792
|
+
if (installResult.timedOut || installResult.outputExceeded || installResult.launchError !== undefined) {
|
|
793
|
+
return finishReport(report, 'unknown', 'the plugin install did not produce a bounded command result');
|
|
794
|
+
}
|
|
795
|
+
if (report.observations.install.coverage.status === 'missing') {
|
|
796
|
+
report.stages.install = {
|
|
797
|
+
...report.stages.install,
|
|
798
|
+
status: 'failed',
|
|
799
|
+
detail: 'install trace evidence is missing',
|
|
800
|
+
};
|
|
801
|
+
return finishReport(report, 'unknown', 'the install command ran without readable trace evidence');
|
|
802
|
+
}
|
|
803
|
+
if (installResult.code !== 0) {
|
|
804
|
+
return finishReport(report, 'install-failed', 'the traced DSH plugin install command failed');
|
|
805
|
+
}
|
|
806
|
+
const registered = await registeredBundle(environment.DSH_HOME, spec.name);
|
|
807
|
+
report.stages.registration = registered
|
|
808
|
+
? { status: 'passed' }
|
|
809
|
+
: { status: 'failed', detail: `the DSH profile did not register ${spec.name}` };
|
|
810
|
+
if (!registered)
|
|
811
|
+
return finishReport(report, 'install-failed', 'DSH accepted the install command but did not register the plugin bundle');
|
|
812
|
+
const loadTracePath = join(traceDirectory, 'load.strace');
|
|
813
|
+
const loadResult = await runSafely(runner, {
|
|
814
|
+
phase: 'load',
|
|
815
|
+
command: pnpmCommand,
|
|
816
|
+
args: dshArgs(options.dshVersion, ['--profile', PROFILE, '--dump-config']),
|
|
817
|
+
cwd: artifactDirectory,
|
|
818
|
+
env: scriptsEnvironment,
|
|
819
|
+
timeoutMs,
|
|
820
|
+
sandboxRoot,
|
|
821
|
+
tracePath: loadTracePath,
|
|
822
|
+
});
|
|
823
|
+
const afterLoad = await snapshotSandbox(sandboxRoot, environment);
|
|
824
|
+
report.observations.load = await readTrace(loadTracePath, sandboxRoot);
|
|
825
|
+
report.filesystem.load = diffSnapshots(afterInstall, afterLoad);
|
|
826
|
+
report.stages.load = commandStage(loadResult);
|
|
827
|
+
if (loadResult.timedOut || loadResult.outputExceeded || loadResult.launchError !== undefined) {
|
|
828
|
+
return finishReport(report, 'unknown', 'the plugin load did not produce a bounded command result');
|
|
829
|
+
}
|
|
830
|
+
if (report.observations.load.coverage.status === 'missing') {
|
|
831
|
+
report.stages.load = { ...report.stages.load, status: 'failed', detail: 'load trace evidence is missing' };
|
|
832
|
+
return finishReport(report, 'unknown', 'the load command ran without readable trace evidence');
|
|
833
|
+
}
|
|
834
|
+
if (loadResult.code !== 0)
|
|
835
|
+
return finishReport(report, 'load-failed', 'the traced DSH profile load command failed');
|
|
836
|
+
return finishReport(report, 'compatible', 'the exact artifact installed, registered and loaded under the requested DSH version');
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
const detail = bounded(error instanceof Error ? error.message : String(error));
|
|
840
|
+
const currentStage = ['runtime', 'profile', 'install', 'registration', 'load']
|
|
841
|
+
.find(name => report.stages[name].status === 'skipped');
|
|
842
|
+
if (currentStage !== undefined)
|
|
843
|
+
report.stages[currentStage] = { status: 'failed', detail };
|
|
844
|
+
return finishReport(report, 'unknown', `the bounded observer failed while collecting ${currentStage ?? 'runtime'} evidence`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
finally {
|
|
848
|
+
await rm(sandboxRoot, { recursive: true, force: true, maxRetries: 2 }).catch(() => undefined);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
export function renderDshInstallObservation(report) {
|
|
852
|
+
const install = report.observations.install;
|
|
853
|
+
const load = report.observations.load;
|
|
854
|
+
const lifecycle = report.artifact.lifecycleScripts.length === 0 ? 'none' : report.artifact.lifecycleScripts.join(', ');
|
|
855
|
+
const lines = [
|
|
856
|
+
'DSH isolated install observation',
|
|
857
|
+
`Artifact: ${report.artifact.name}@${report.artifact.version}${report.artifact.sha256 === undefined ? '' : ` (sha256:${report.artifact.sha256.slice(0, 12)}…)`}`,
|
|
858
|
+
`DSH: ${report.dshVersion}`,
|
|
859
|
+
`Runtime: Node ${report.runtime.nodeVersion} (${report.runtime.platform}/${report.runtime.architecture}), pnpm ${report.runtime.packageManager.version ?? 'unknown'}`,
|
|
860
|
+
`Plugin Node requirement: ${report.artifact.nodeEngine ?? 'not declared'}`,
|
|
861
|
+
`Isolation claim: ${report.boundary.isolationProviderClaim} (provided externally; not verified by Radar)`,
|
|
862
|
+
`Approved dependency builds: ${report.boundary.approvedDependencyBuilds.length === 0 ? 'none' : report.boundary.approvedDependencyBuilds.join(', ')}`,
|
|
863
|
+
'',
|
|
864
|
+
`Result: ${report.result.toUpperCase()} — ${report.reason}`,
|
|
865
|
+
`Lifecycle scripts declared: ${lifecycle}`,
|
|
866
|
+
`Install evidence: ${install.processes.length} process, ${install.network.length} network, ${install.fileWrites.length} file-write event(s); trace ${install.coverage.status}`,
|
|
867
|
+
`Load evidence: ${load.processes.length} process, ${load.network.length} network, ${load.fileWrites.length} file-write event(s); trace ${load.coverage.status}`,
|
|
868
|
+
`Final filesystem delta: install +${report.filesystem.install.totals.created} ~${report.filesystem.install.totals.modified} -${report.filesystem.install.totals.deleted}; load +${report.filesystem.load.totals.created} ~${report.filesystem.load.totals.modified} -${report.filesystem.load.totals.deleted}`,
|
|
869
|
+
'',
|
|
870
|
+
];
|
|
871
|
+
for (const [name, stage] of Object.entries(report.stages)) {
|
|
872
|
+
lines.push(` ${name}: ${stage.status}${stage.detail === undefined ? '' : ` (${stage.detail})`}`);
|
|
873
|
+
}
|
|
874
|
+
lines.push('', report.boundary.note);
|
|
875
|
+
return `${lines.join('\n')}\n`;
|
|
876
|
+
}
|
|
877
|
+
//# sourceMappingURL=dsh-install-observation.js.map
|