scanners/lib/git-clone.mjs accepted only github.com, so the README's
Forgejo example (and /security scan on any Forgejo, Codeberg or self-hosted
URL) failed validation; scan.md and plugin-audit.md also routed only
https://github.com/ to the clone path.
Chose generic HTTPS over adding one host to an allowlist because there is
no documented security reason for the host check. Read before choosing:
git log --follow (import + E12 only), the file's comments, CHANGELOG v2.4.0
("GitHub repo URL support", feature framing), security-hardening-guide §2
and §7 (the sandbox is the defence against filter/smudge drivers), and
review-2026-06-20, which lists "vsix-fetch (HTTPS host allowlist)" as a
protection but no host check for git-clone. What protects a clone is the
OS sandbox, GIT_SANDBOX_CONFIG and GIT_SANDBOX_ENV, for every host alike.
Measured: with GIT_SANDBOX_ENV, git reads 0 config lines outside a repo;
without it, osxkeychain comes from Xcode's system gitconfig. So no stored
credential is offered to an unknown host.
The shape stays strict: https only, no userinfo, host starts and ends
alphanumeric (no leading -), optional port, exactly owner/repo, no query
or fragment. SSH stays GitHub-only: ssh uses the user's own keys and
~/.ssh/config, which the git environment does not isolate.
Tests (red first): Forgejo and any-host accept 2 failing -> green; guards
for http, userinfo, leading -, extra path, query, ext:: and file:: and
non-GitHub SSH hold. The old "rejects non-GitHub URL" case still passes
(it has no owner/repo) and is renamed to say so. Regex timed linear
(< 1 ms at 200k chars). Live: a sandboxed clone of
https://git.fromaitochitta.com/open/llm-security.git exits 0 at 5208420,
cleaned up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
// git-clone-sandbox.test.mjs — Tests for sandboxed git clone + fs-utils tmppath
|
|
// Zero external dependencies: node:test + node:assert only.
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, rmSync, readFileSync, realpathSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const LIB_DIR = join(__dirname, '..', '..', 'scanners', 'lib');
|
|
const GIT_CLONE = join(LIB_DIR, 'git-clone.mjs');
|
|
const FS_UTILS = join(LIB_DIR, 'fs-utils.mjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import sandbox exports for unit testing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const {
|
|
GIT_SANDBOX_CONFIG, GIT_SANDBOX_ENV, buildSandboxProfile, buildBwrapArgs,
|
|
buildSandboxedClone, MAX_CLONE_SIZE_MB,
|
|
} = await import('../../scanners/lib/git-clone.mjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GIT_SANDBOX_CONFIG
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GIT_SANDBOX_CONFIG', () => {
|
|
it('disables hooks', () => {
|
|
const idx = GIT_SANDBOX_CONFIG.indexOf('core.hooksPath=/dev/null');
|
|
assert.ok(idx > 0, 'core.hooksPath=/dev/null must be in config flags');
|
|
});
|
|
|
|
it('disables symlinks', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('core.symlinks=false'));
|
|
});
|
|
|
|
it('disables fsmonitor', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('core.fsmonitor=false'));
|
|
});
|
|
|
|
it('disables LFS filter drivers', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.process='));
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.smudge='));
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('filter.lfs.clean='));
|
|
});
|
|
|
|
it('blocks local file protocol', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('protocol.file.allow=never'));
|
|
});
|
|
|
|
it('enables fsck on transfer', () => {
|
|
assert.ok(GIT_SANDBOX_CONFIG.includes('transfer.fsckObjects=true'));
|
|
});
|
|
|
|
it('has 8 -c flag pairs (16 elements)', () => {
|
|
const cCount = GIT_SANDBOX_CONFIG.filter(f => f === '-c').length;
|
|
assert.equal(cCount, 8, 'Should have exactly 8 -c flags');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GIT_SANDBOX_ENV
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GIT_SANDBOX_ENV', () => {
|
|
it('sets GIT_CONFIG_NOSYSTEM', () => {
|
|
assert.equal(GIT_SANDBOX_ENV.GIT_CONFIG_NOSYSTEM, '1');
|
|
});
|
|
|
|
it('sets GIT_CONFIG_GLOBAL to /dev/null', () => {
|
|
assert.equal(GIT_SANDBOX_ENV.GIT_CONFIG_GLOBAL, '/dev/null');
|
|
});
|
|
|
|
it('sets GIT_ATTR_NOSYSTEM', () => {
|
|
assert.equal(GIT_SANDBOX_ENV.GIT_ATTR_NOSYSTEM, '1');
|
|
});
|
|
|
|
it('sets GIT_TERMINAL_PROMPT to 0', () => {
|
|
assert.equal(GIT_SANDBOX_ENV.GIT_TERMINAL_PROMPT, '0');
|
|
});
|
|
|
|
it('preserves existing PATH', () => {
|
|
assert.ok(GIT_SANDBOX_ENV.PATH, 'PATH must be preserved from process.env');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildSandboxProfile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('buildSandboxProfile', () => {
|
|
it('returns a profile string on macOS', () => {
|
|
if (process.platform !== 'darwin') return;
|
|
// Use tmpdir() which always exists — realpathSync needs an existing path
|
|
const profile = buildSandboxProfile(tmpdir());
|
|
assert.ok(profile !== null, 'Should return a profile on macOS');
|
|
assert.ok(profile.includes('(version 1)'), 'Profile must start with version');
|
|
assert.ok(profile.includes('(deny file-write*)'), 'Must deny writes by default');
|
|
});
|
|
|
|
it('includes the resolved real path in the profile', () => {
|
|
if (process.platform !== 'darwin') return;
|
|
const realPath = realpathSync(tmpdir());
|
|
const profile = buildSandboxProfile(tmpdir());
|
|
assert.ok(profile.includes(realPath), `Profile must contain resolved path: ${realPath}`);
|
|
});
|
|
|
|
it('allows /dev/null and /dev/tty writes', () => {
|
|
if (process.platform !== 'darwin') return;
|
|
const profile = buildSandboxProfile(tmpdir());
|
|
assert.ok(profile.includes('/dev/null'), 'Must allow /dev/null');
|
|
assert.ok(profile.includes('/dev/tty'), 'Must allow /dev/tty');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildBwrapArgs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('buildBwrapArgs', () => {
|
|
it('returns null on non-Linux platforms', () => {
|
|
if (process.platform === 'linux') return;
|
|
const result = buildBwrapArgs('/tmp/test', ['git', 'clone']);
|
|
assert.equal(result, null, 'Should return null on non-Linux');
|
|
});
|
|
|
|
it('on Linux: returns args array if bwrap is available', () => {
|
|
if (process.platform !== 'linux') return;
|
|
const check = spawnSync('which', ['bwrap'], { encoding: 'utf8' });
|
|
if (check.status !== 0) return; // bwrap not installed, skip
|
|
const result = buildBwrapArgs('/tmp/test-bwrap', ['git', 'clone']);
|
|
if (result === null) return; // bwrap installed but fails (Ubuntu 24.04+)
|
|
assert.ok(Array.isArray(result), 'Should return an array');
|
|
assert.ok(result.includes('--ro-bind'), 'Should include --ro-bind');
|
|
assert.ok(result.includes('--unshare-all'), 'Should include --unshare-all');
|
|
assert.ok(result.includes('/tmp/test-bwrap'), 'Should include the allowed write path');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildSandboxedClone
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('buildSandboxedClone', () => {
|
|
it('returns cmd, args, and sandbox properties', () => {
|
|
const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
|
|
assert.ok(result.cmd, 'Must have cmd');
|
|
assert.ok(Array.isArray(result.args), 'args must be an array');
|
|
assert.ok('sandbox' in result, 'Must have sandbox property');
|
|
});
|
|
|
|
it('uses sandbox-exec on macOS', () => {
|
|
if (process.platform !== 'darwin') return;
|
|
const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
|
|
assert.equal(result.sandbox, 'sandbox-exec');
|
|
assert.equal(result.cmd, 'sandbox-exec');
|
|
});
|
|
|
|
it('includes git config flags in args regardless of platform', () => {
|
|
const result = buildSandboxedClone(tmpdir(), ['clone', '--depth', '1', 'url', tmpdir()]);
|
|
const argsStr = result.args.join(' ');
|
|
assert.ok(argsStr.includes('core.hooksPath=/dev/null'), 'Must include hooksPath');
|
|
assert.ok(argsStr.includes('core.symlinks=false'), 'Must include symlinks=false');
|
|
});
|
|
|
|
it('falls back gracefully with sandbox=null when no OS sandbox', () => {
|
|
// This test verifies the structure — on macOS/Linux with sandbox available,
|
|
// it will have a sandbox. The key assertion is structural.
|
|
const result = buildSandboxedClone(tmpdir(), ['clone', 'url', tmpdir()]);
|
|
if (result.sandbox === null) {
|
|
assert.equal(result.cmd, 'git', 'Fallback must use git directly');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MAX_CLONE_SIZE_MB
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('MAX_CLONE_SIZE_MB', () => {
|
|
it('is 100', () => {
|
|
assert.equal(MAX_CLONE_SIZE_MB, 100);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// fs-utils tmppath uniqueness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('fs-utils tmppath', () => {
|
|
it('generates unique paths for the same base name', () => {
|
|
const paths = new Set();
|
|
for (let i = 0; i < 5; i++) {
|
|
const result = spawnSync('node', [FS_UTILS, 'tmppath', 'content-extract.json'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0, `tmppath should exit 0, got: ${result.stderr}`);
|
|
paths.add(result.stdout.trim());
|
|
}
|
|
assert.equal(paths.size, 5, 'All 5 paths should be unique');
|
|
});
|
|
|
|
it('preserves file extension', () => {
|
|
const result = spawnSync('node', [FS_UTILS, 'tmppath', 'test-file.json'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.ok(result.stdout.trim().endsWith('.json'), 'Should preserve .json extension');
|
|
});
|
|
|
|
it('preserves base name prefix', () => {
|
|
const result = spawnSync('node', [FS_UTILS, 'tmppath', 'my-evidence.json'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.ok(result.stdout.trim().includes('my-evidence-'), 'Should contain base name prefix');
|
|
});
|
|
|
|
it('paths are under tmpdir', () => {
|
|
const result = spawnSync('node', [FS_UTILS, 'tmppath', 'test.json'], {
|
|
encoding: 'utf8',
|
|
});
|
|
const path = result.stdout.trim();
|
|
assert.ok(path.startsWith(tmpdir()), `Path should be under tmpdir: ${path}`);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// git-clone CLI: validate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('git-clone validate', () => {
|
|
it('accepts valid HTTPS GitHub URL', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://github.com/org/repo'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0);
|
|
});
|
|
|
|
it('accepts valid SSH GitHub URL', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'validate', 'git@github.com:org/repo.git'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0);
|
|
});
|
|
|
|
it('rejects an HTTPS URL without an owner/repo path', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://evil.com/repo'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 1);
|
|
});
|
|
|
|
// v8.1.0 S3: any HTTPS host. The host was never the boundary — the sandbox,
|
|
// the git config flags and the isolated environment are, for every host.
|
|
const validate = (url) => spawnSync('node', [GIT_CLONE, 'validate', url], { encoding: 'utf8' }).status;
|
|
|
|
it('accepts an HTTPS Forgejo URL', () => {
|
|
assert.equal(validate('https://git.fromaitochitta.com/open/llm-security.git'), 0);
|
|
assert.equal(validate('https://git.fromaitochitta.com/open/llm-security'), 0);
|
|
});
|
|
|
|
it('accepts an HTTPS URL on any host, with or without a port', () => {
|
|
assert.equal(validate('https://codeberg.org/org/repo'), 0);
|
|
assert.equal(validate('https://git.example.com:3000/org/repo.git'), 0);
|
|
});
|
|
|
|
it('keeps rejecting shapes that are not a plain HTTPS owner/repo URL', () => {
|
|
assert.equal(validate('http://git.example.com/org/repo'), 1, 'plain http');
|
|
assert.equal(validate('https://user:pass@git.example.com/org/repo'), 1, 'userinfo');
|
|
assert.equal(validate('https://-oProxyCommand=x/org/repo'), 1, 'host starting with -');
|
|
assert.equal(validate('https://git.example.com/org/repo/src/branch/main'), 1, 'extra path');
|
|
assert.equal(validate('https://git.example.com/org/repo?x=1'), 1, 'query');
|
|
assert.equal(validate('ext::sh -c touch% /tmp/x'), 1, 'ext transport');
|
|
assert.equal(validate('file:///etc/org/repo'), 1, 'file transport');
|
|
});
|
|
|
|
it('keeps SSH GitHub-only (ssh uses the user\'s own keys and config)', () => {
|
|
assert.equal(validate('git@git.example.com:org/repo.git'), 1);
|
|
});
|
|
|
|
it('rejects URL with tree path', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'validate', 'https://github.com/org/repo/tree/main/dir'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// git-clone CLI: cleanup safety
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('git-clone cleanup', () => {
|
|
it('refuses to remove paths outside tmpdir', () => {
|
|
const result = spawnSync('node', [GIT_CLONE, 'cleanup', '/home/user/important'], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 1);
|
|
assert.ok(result.stderr.includes('refusing to remove'));
|
|
});
|
|
|
|
it('handles non-existent tmpdir path gracefully', () => {
|
|
const fakePath = join(tmpdir(), 'llm-sec-nonexistent-test-' + Date.now());
|
|
const result = spawnSync('node', [GIT_CLONE, 'cleanup', fakePath], {
|
|
encoding: 'utf8',
|
|
});
|
|
assert.equal(result.status, 0, 'Should exit 0 for non-existent path in tmpdir');
|
|
});
|
|
});
|