feat(llm-security): OS sandbox for /security ide-scan <url> (v6.5.0)
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>
This commit is contained in:
parent
fe0193956d
commit
9f893c3858
11 changed files with 434 additions and 24 deletions
|
|
@ -54,7 +54,7 @@ describe('ide-extension-scanner — URL mode', () => {
|
|||
|
||||
it('rejects unsupported URL with a warning, no extensions scanned', async () => {
|
||||
installFetchRouter(() => null);
|
||||
const env = await scan('https://example.com/random.zip', { vscodeOnly: true });
|
||||
const env = await scan('https://example.com/random.zip', { vscodeOnly: true, useSandbox: false });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /unsupported URL/i.test(w)));
|
||||
assert.equal(env.meta.source, null);
|
||||
|
|
@ -62,7 +62,7 @@ describe('ide-extension-scanner — URL mode', () => {
|
|||
|
||||
it('reports github URL as unsupported in v6.4.0', async () => {
|
||||
installFetchRouter(() => null);
|
||||
const env = await scan('https://github.com/anthropic/claude-code', { vscodeOnly: true });
|
||||
const env = await scan('https://github.com/anthropic/claude-code', { vscodeOnly: true, useSandbox: false });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /GitHub repo URLs/i.test(w)));
|
||||
});
|
||||
|
|
@ -83,7 +83,7 @@ describe('ide-extension-scanner — URL mode', () => {
|
|||
return null;
|
||||
});
|
||||
|
||||
const env = await scan('https://open-vsx.org/extension/anthropic/claude-code', { vscodeOnly: true });
|
||||
const env = await scan('https://open-vsx.org/extension/anthropic/claude-code', { vscodeOnly: true, useSandbox: false });
|
||||
assert.ok(metaCalled, 'expected metadata fetch for latest version');
|
||||
assert.ok(downloadCalled, 'expected VSIX download');
|
||||
assert.equal(env.extensions.length, 1);
|
||||
|
|
@ -109,7 +109,7 @@ describe('ide-extension-scanner — URL mode', () => {
|
|||
return null;
|
||||
});
|
||||
|
||||
const env = await scan('https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code', { vscodeOnly: true });
|
||||
const env = await scan('https://marketplace.visualstudio.com/items?itemName=anthropic.claude-code', { vscodeOnly: true, useSandbox: false });
|
||||
assert.equal(downloads, 1);
|
||||
assert.equal(env.extensions.length, 1);
|
||||
assert.equal(env.extensions[0].id, 'anthropic.claude-code');
|
||||
|
|
@ -120,7 +120,7 @@ describe('ide-extension-scanner — URL mode', () => {
|
|||
it('cleans up temp dir even when extraction fails', async () => {
|
||||
// Return a non-zip body so extract throws.
|
||||
installFetchRouter(() => mockResponse(Buffer.from('not a zip at all')));
|
||||
const env = await scan('https://example.com/bad.vsix', { vscodeOnly: true });
|
||||
const env = await scan('https://example.com/bad.vsix', { vscodeOnly: true, useSandbox: false });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /malformed VSIX/.test(w)));
|
||||
});
|
||||
|
|
@ -131,14 +131,14 @@ describe('ide-extension-scanner — URL mode', () => {
|
|||
{ name: '../escape.txt', data: 'pwned' },
|
||||
]);
|
||||
installFetchRouter(() => mockResponse(evil));
|
||||
const env = await scan('https://example.com/evil.vsix', { vscodeOnly: true });
|
||||
const env = await scan('https://example.com/evil.vsix', { vscodeOnly: true, useSandbox: false });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /malformed VSIX/.test(w) && /traversal/.test(w)));
|
||||
});
|
||||
|
||||
it('handles fetch network failure cleanly', async () => {
|
||||
installFetchRouter(() => { throw new Error('ECONNREFUSED'); });
|
||||
const env = await scan('https://open-vsx.org/extension/foo/bar', { vscodeOnly: true });
|
||||
const env = await scan('https://open-vsx.org/extension/foo/bar', { vscodeOnly: true, useSandbox: false });
|
||||
assert.equal(env.extensions.length, 0);
|
||||
assert.ok(env.meta.warnings.some(w => /URL fetch\/extract failed/.test(w)));
|
||||
});
|
||||
|
|
|
|||
112
plugins/llm-security/tests/scanners/vsix-sandbox.test.mjs
Normal file
112
plugins/llm-security/tests/scanners/vsix-sandbox.test.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// vsix-sandbox.test.mjs — Tests for the VSIX sandbox wrapper and worker.
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
buildSandboxProfile,
|
||||
buildBwrapArgs,
|
||||
buildSandboxedWorker,
|
||||
runVsixWorker,
|
||||
__testing,
|
||||
} from '../../scanners/lib/vsix-sandbox.mjs';
|
||||
|
||||
describe('vsix-sandbox — buildSandboxProfile', () => {
|
||||
it('returns null on non-darwin', () => {
|
||||
if (process.platform === 'darwin') return; // Not applicable here.
|
||||
const profile = buildSandboxProfile('/tmp');
|
||||
assert.equal(profile, null);
|
||||
});
|
||||
|
||||
it('returns a valid profile string on macOS when sandbox-exec exists', () => {
|
||||
if (process.platform !== 'darwin') return;
|
||||
const has = spawnSync('which', ['sandbox-exec'], { encoding: 'utf8' });
|
||||
if (has.status !== 0) return;
|
||||
const profile = buildSandboxProfile('/tmp');
|
||||
assert.ok(profile, 'expected profile string on macOS');
|
||||
assert.match(profile, /\(version 1\)/);
|
||||
assert.match(profile, /\(deny file-write\*\)/);
|
||||
assert.match(profile, /\(allow file-write\* \(subpath /);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vsix-sandbox — buildBwrapArgs', () => {
|
||||
it('returns null on non-linux', () => {
|
||||
if (process.platform === 'linux') return;
|
||||
const args = buildBwrapArgs('/tmp', ['/bin/true']);
|
||||
assert.equal(args, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vsix-sandbox — buildSandboxedWorker', () => {
|
||||
it('returns sandbox-exec on macOS, bwrap on Linux, or null fallback', () => {
|
||||
const { cmd, args, sandbox } = buildSandboxedWorker('/tmp', ['--url', 'https://x', '--tmpdir', '/tmp']);
|
||||
assert.ok(cmd);
|
||||
assert.ok(Array.isArray(args));
|
||||
if (process.platform === 'darwin') {
|
||||
const has = spawnSync('which', ['sandbox-exec'], { encoding: 'utf8' });
|
||||
if (has.status === 0) {
|
||||
assert.equal(sandbox, 'sandbox-exec');
|
||||
assert.equal(cmd, 'sandbox-exec');
|
||||
assert.equal(args[0], '-p');
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
// Could be 'bwrap' or null depending on availability — both are valid.
|
||||
assert.ok(sandbox === 'bwrap' || sandbox === null);
|
||||
} else {
|
||||
assert.equal(sandbox, null);
|
||||
assert.equal(cmd, 'node');
|
||||
}
|
||||
});
|
||||
|
||||
it('always includes the worker path and forwarded args', () => {
|
||||
const { args } = buildSandboxedWorker('/tmp', ['--url', 'https://example/', '--tmpdir', '/tmp']);
|
||||
const joined = args.join(' ');
|
||||
assert.match(joined, /vsix-fetch-worker\.mjs/);
|
||||
assert.match(joined, /--url https:\/\/example\//);
|
||||
assert.match(joined, /--tmpdir \/tmp/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vsix-sandbox — runVsixWorker (live worker, no network)', () => {
|
||||
it('handles non-HTTPS URL: worker exits with ok:false and a fetch error', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'llm-sec-vsix-test-'));
|
||||
try {
|
||||
const { ok, payload, sandbox } = await runVsixWorker('http://example.com/foo.vsix', dir);
|
||||
assert.equal(ok, false);
|
||||
assert.ok(payload.error, 'expected error message');
|
||||
assert.match(payload.error, /fetch failed|HTTPS|unsupported/i);
|
||||
// Sandbox may be 'sandbox-exec', 'bwrap', or null on Windows. All valid.
|
||||
assert.ok(sandbox === 'sandbox-exec' || sandbox === 'bwrap' || sandbox === null);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('handles unsupported URL kind: worker exits with ok:false', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'llm-sec-vsix-test-'));
|
||||
try {
|
||||
const { ok, payload } = await runVsixWorker('https://example.com/random.zip', dir);
|
||||
assert.equal(ok, false);
|
||||
assert.match(payload.error, /unsupported URL|fetch failed/i);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when no --url or --tmpdir is provided (worker arg validation)', async () => {
|
||||
// Construct a minimal direct worker call without any args.
|
||||
const { spawn } = await import('node:child_process');
|
||||
const child = spawn('node', [__testing.WORKER_PATH], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = '';
|
||||
child.stdout.on('data', (c) => { out += c.toString('utf8'); });
|
||||
const code = await new Promise((resolve) => child.on('close', resolve));
|
||||
assert.equal(code, 1);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
assert.equal(parsed.ok, false);
|
||||
assert.match(parsed.error, /missing --url or --tmpdir/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue