feat(llm-security): sandboxed remote cloning v5.1.0
Harden git clone attack surface for remote scans with defense-in-depth: Layer 1 (all platforms): 8 git config flags disable hooks, symlinks, filter/smudge drivers, fsmonitor, local file protocol. 4 env vars isolate from system/user git config and block interactive prompts. Layer 2 (OS sandbox): macOS sandbox-exec and Linux bubblewrap (bwrap) restrict file writes to only the specific temp directory. bwrap probe-tests availability before use. Graceful fallback on Windows and Ubuntu 24.04+ (git config hardening only). Additional: post-clone 100MB size check, UUID-unique evidence filenames, evidence file cleanup, cleanup guarantee in scan/plugin-audit commands. 32 new tests (1147 total). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5c1ceaa567
commit
708c898754
11 changed files with 487 additions and 12 deletions
|
|
@ -19,7 +19,7 @@ import { scan } from './posture-scanner.mjs';
|
|||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VERSION = '5.0.0';
|
||||
const VERSION = '5.1.0';
|
||||
|
||||
/** Cache location */
|
||||
const CACHE_DIR = join(homedir(), '.cache', 'llm-security');
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import { cpSync, rmSync, renameSync, existsSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const [,, command, ...args] = process.argv;
|
||||
|
||||
|
|
@ -50,8 +51,12 @@ switch (command) {
|
|||
}
|
||||
|
||||
case 'tmppath': {
|
||||
const filename = args[0] || 'llm-security-temp.json';
|
||||
process.stdout.write(join(tmpdir(), filename) + '\n');
|
||||
const base = args[0] || 'llm-security-temp.json';
|
||||
const dotIdx = base.lastIndexOf('.');
|
||||
const name = dotIdx > 0 ? base.slice(0, dotIdx) : base;
|
||||
const ext = dotIdx > 0 ? base.slice(dotIdx) : '.json';
|
||||
const unique = `${name}-${randomUUID().slice(0, 8)}${ext}`;
|
||||
process.stdout.write(join(tmpdir(), unique) + '\n');
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
#!/usr/bin/env node
|
||||
// git-clone.mjs — Clone GitHub repos to temp dirs for security scanning
|
||||
// Usage:
|
||||
// node git-clone.mjs clone <url> [--branch <name>] → shallow clone, prints tmpdir path
|
||||
// node git-clone.mjs clone <url> [--branch <name>] → sandboxed shallow clone, prints tmpdir path
|
||||
// node git-clone.mjs cleanup <dir> → removes temp directory
|
||||
// node git-clone.mjs validate <url> → exits 0 if valid GitHub URL, 1 if not
|
||||
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const GITHUB_URL_RE = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(\.git)?\/?$/;
|
||||
const GITHUB_SSH_RE = /^git@github\.com:[\w.-]+\/[\w.-]+(\.git)?$/;
|
||||
const MAX_CLONE_SIZE_MB = 100;
|
||||
|
||||
function isValidUrl(url) {
|
||||
return GITHUB_URL_RE.test(url) || GITHUB_SSH_RE.test(url);
|
||||
|
|
@ -29,6 +30,109 @@ function parseArgs(argv) {
|
|||
return args;
|
||||
}
|
||||
|
||||
/** Git config flags that neutralize known attack vectors */
|
||||
const GIT_SANDBOX_CONFIG = [
|
||||
'-c', 'core.hooksPath=/dev/null',
|
||||
'-c', 'core.symlinks=false',
|
||||
'-c', 'core.fsmonitor=false',
|
||||
'-c', 'filter.lfs.process=',
|
||||
'-c', 'filter.lfs.smudge=',
|
||||
'-c', 'filter.lfs.clean=',
|
||||
'-c', 'protocol.file.allow=never',
|
||||
'-c', 'transfer.fsckObjects=true',
|
||||
];
|
||||
|
||||
/** Environment that isolates git from system/user config */
|
||||
const GIT_SANDBOX_ENV = {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1',
|
||||
GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
GIT_ATTR_NOSYSTEM: '1',
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build sandbox-exec profile restricting file writes to a single directory.
|
||||
* macOS only — returns null on other platforms.
|
||||
*/
|
||||
function buildSandboxProfile(allowedWritePath) {
|
||||
if (process.platform !== 'darwin') return null;
|
||||
const check = spawnSync('which', ['sandbox-exec'], { encoding: 'utf8' });
|
||||
if (check.status !== 0) return null;
|
||||
|
||||
const realPath = realpathSync(allowedWritePath);
|
||||
return [
|
||||
'(version 1)',
|
||||
'(allow default)',
|
||||
'(deny file-write*)',
|
||||
`(allow file-write* (subpath "${realPath}"))`,
|
||||
'(allow file-write* (literal "/dev/null"))',
|
||||
'(allow file-write* (literal "/dev/tty"))',
|
||||
].join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build bwrap args restricting writes to a single directory.
|
||||
* Linux only — returns null if bwrap is not installed or fails.
|
||||
*/
|
||||
function buildBwrapArgs(allowedWritePath, innerArgs) {
|
||||
if (process.platform !== 'linux') return null;
|
||||
const check = spawnSync('which', ['bwrap'], { encoding: 'utf8' });
|
||||
if (check.status !== 0) return null;
|
||||
|
||||
// Test that bwrap actually works (fails on Ubuntu 24.04+ without admin config)
|
||||
const probe = spawnSync('bwrap', ['--ro-bind', '/', '/', '--dev', '/dev', '/bin/true'], {
|
||||
stdio: 'ignore', timeout: 5000,
|
||||
});
|
||||
if (probe.status !== 0) return null;
|
||||
|
||||
return [
|
||||
'--ro-bind', '/', '/', // read-only root
|
||||
'--bind', allowedWritePath, allowedWritePath, // writable clone dir
|
||||
'--dev', '/dev', // /dev/null etc.
|
||||
'--unshare-all', // isolate namespaces
|
||||
'--new-session', // prevent tty hijack
|
||||
'--die-with-parent', // cleanup on parent exit
|
||||
...innerArgs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full sandboxed command + args for the current platform.
|
||||
* Returns { cmd, args } — either wrapped in sandbox or plain git.
|
||||
*/
|
||||
function buildSandboxedClone(tmpDir, gitArgs) {
|
||||
const innerGitArgs = [...GIT_SANDBOX_CONFIG, ...gitArgs];
|
||||
|
||||
// macOS: sandbox-exec
|
||||
const profile = buildSandboxProfile(tmpDir);
|
||||
if (profile) {
|
||||
return { cmd: 'sandbox-exec', args: ['-p', profile, 'git', ...innerGitArgs], sandbox: 'sandbox-exec' };
|
||||
}
|
||||
|
||||
// Linux: bwrap
|
||||
const bwrapArgs = buildBwrapArgs(tmpDir, ['git', ...innerGitArgs]);
|
||||
if (bwrapArgs) {
|
||||
return { cmd: 'bwrap', args: bwrapArgs, sandbox: 'bwrap' };
|
||||
}
|
||||
|
||||
// Fallback: git with config flags only
|
||||
return { cmd: 'git', args: innerGitArgs, sandbox: null };
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
export {
|
||||
GIT_SANDBOX_CONFIG, GIT_SANDBOX_ENV, buildSandboxProfile, buildBwrapArgs,
|
||||
buildSandboxedClone, MAX_CLONE_SIZE_MB,
|
||||
};
|
||||
|
||||
// CLI entry point — only run when invoked directly
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const isDirectRun = process.argv[1] === __filename;
|
||||
|
||||
if (isDirectRun) {
|
||||
|
||||
const [,, command, ...rest] = process.argv;
|
||||
|
||||
switch (command) {
|
||||
|
|
@ -52,9 +156,17 @@ switch (command) {
|
|||
if (branch) gitArgs.push('--branch', branch);
|
||||
gitArgs.push(url, tmpDir);
|
||||
|
||||
const result = spawnSync('git', gitArgs, {
|
||||
// Build sandboxed clone command (macOS: sandbox-exec, Linux: bwrap, fallback: git only)
|
||||
const { cmd: cloneCmd, args: cloneArgs, sandbox } = buildSandboxedClone(tmpDir, gitArgs);
|
||||
|
||||
if (!sandbox) {
|
||||
console.error('clone: WARN: no OS sandbox available, running with git config hardening only');
|
||||
}
|
||||
|
||||
const result = spawnSync(cloneCmd, cloneArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 60_000,
|
||||
env: GIT_SANDBOX_ENV,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
|
|
@ -65,6 +177,17 @@ switch (command) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Post-clone size check
|
||||
const duResult = spawnSync('du', ['-sm', tmpDir], { encoding: 'utf8' });
|
||||
if (duResult.status === 0) {
|
||||
const sizeMb = parseInt(duResult.stdout.split('\t')[0], 10);
|
||||
if (sizeMb > MAX_CLONE_SIZE_MB) {
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||
console.error(`clone: repo too large (${sizeMb}MB, max ${MAX_CLONE_SIZE_MB}MB)`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(tmpDir + '\n');
|
||||
break;
|
||||
}
|
||||
|
|
@ -100,3 +223,5 @@ switch (command) {
|
|||
console.error('Usage: node git-clone.mjs <clone|cleanup|validate> [args...]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} // end isDirectRun
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { finding, scannerResult, resetCounter } from './lib/output.mjs';
|
|||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VERSION = '5.0.0';
|
||||
const VERSION = '5.1.0';
|
||||
|
||||
/** Minimum lines for a hook script to be considered non-stub */
|
||||
const NON_STUB_THRESHOLD = 5;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue