VSIX fetch + extract for URL targets now runs in a sub-process wrapped by
sandbox-exec (macOS) or bwrap (Linux), reusing the same primitives proven
by the v5.1 git-clone sandbox. Defense-in-depth — even if our own
zip-extract.mjs ever has a bypass, the kernel refuses any write outside
the per-scan temp directory.
New files:
- scanners/lib/vsix-fetch-worker.mjs — sub-process worker. Argv: --url
--tmpdir; emits one JSON line on stdout (ok/sha256/size/source/extRoot
or ok:false/error/code). Silent on stderr. Exit 0/1.
- scanners/lib/vsix-sandbox.mjs — wrapper. Exports buildSandboxProfile,
buildBwrapArgs, buildSandboxedWorker, runVsixWorker. 35s timeout, 1 MB
stdout cap.
Changes:
- scanners/ide-extension-scanner.mjs: fetchAndExtractVsixUrl is now
sandbox-aware (useSandbox option, default true). In-process logic
preserved as fallback. New meta.source.sandbox field:
'sandbox-exec' | 'bwrap' | 'none' | 'in-process'.
- scan(target, { useSandbox }) defaults to true; tests pass false because
globalThis.fetch mocks do not cross process boundaries.
- Windows fallback: in-process with meta.warnings advisory.
Tests:
- 8 new tests in tests/scanners/vsix-sandbox.test.mjs (per-platform
profile generation, worker arg construction, live worker exit
behavior on invalid URLs — no network).
- Existing URL tests updated to opt out of sandbox (useSandbox: false).
- 1344 → 1352 tests, all green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
76 lines
2.5 KiB
JavaScript
76 lines
2.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// vsix-fetch-worker.mjs — Sub-process worker that fetches a VSIX URL and extracts
|
|
// it to a writable directory. Designed to be spawned under sandbox-exec (macOS),
|
|
// bwrap (Linux), or directly (Windows fallback).
|
|
//
|
|
// Contract:
|
|
// stdin: none
|
|
// argv: --url <url> --tmpdir <writable-dir>
|
|
// stdout: single JSON line {ok:true, sha256, size, finalUrl, source, extRoot}
|
|
// on success, or {ok:false, error:"<msg>", code?:"<ZIP_CODE>"} on failure
|
|
// stderr: never (silent — all errors via JSON on stdout)
|
|
// exit: 0 on success, 1 on any failure (caller still parses stdout)
|
|
//
|
|
// Why a worker: the parent process can wrap this command in sandbox-exec / bwrap
|
|
// so any filesystem write the ZIP extractor performs is restricted to <tmpdir>.
|
|
// Defense-in-depth — even if our own zip-slip / symlink validation has a bug,
|
|
// the OS sandbox cannot let bytes land outside <tmpdir>.
|
|
|
|
import { writeFileSync, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { fetchVsixFromUrl } from './vsix-fetch.mjs';
|
|
import { extractToDir, ZipError } from './zip-extract.mjs';
|
|
|
|
function emit(obj) {
|
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { url: null, tmpdir: null };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] === '--url' && i + 1 < argv.length) out.url = argv[++i];
|
|
else if (argv[i] === '--tmpdir' && i + 1 < argv.length) out.tmpdir = argv[++i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function main() {
|
|
const { url, tmpdir: dir } = parseArgs(process.argv.slice(2));
|
|
if (!url || !dir) {
|
|
emit({ ok: false, error: 'missing --url or --tmpdir' });
|
|
process.exit(1);
|
|
}
|
|
let fetched;
|
|
try {
|
|
fetched = await fetchVsixFromUrl(url);
|
|
} catch (err) {
|
|
emit({ ok: false, error: `fetch failed: ${err.message}` });
|
|
process.exit(1);
|
|
}
|
|
try {
|
|
await extractToDir(fetched.buffer, dir);
|
|
} catch (err) {
|
|
if (err instanceof ZipError) {
|
|
emit({ ok: false, error: `malformed VSIX (${err.code}): ${err.message}`, code: err.code });
|
|
} else {
|
|
emit({ ok: false, error: `extract failed: ${err.message}` });
|
|
}
|
|
process.exit(1);
|
|
}
|
|
const nested = join(dir, 'extension');
|
|
const extRoot = existsSync(nested) ? nested : dir;
|
|
emit({
|
|
ok: true,
|
|
sha256: fetched.sha256,
|
|
size: fetched.size,
|
|
finalUrl: fetched.finalUrl,
|
|
source: fetched.source,
|
|
extRoot,
|
|
});
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
emit({ ok: false, error: `worker crashed: ${err.message || String(err)}` });
|
|
process.exit(1);
|
|
});
|