// foreign-under-cwd-scope.test.mjs — foreign code with NO `.git` of its own // under the caller's cwd must not configure its own scan (v8.1.2). // // v8.1.1 made a target own only when it shares cwd's git root. A target // without a `.git` of its own therefore still counted as own: an installed // package (`/node_modules/evil-pkg`) and a Claude Code plugin-cache copy // (`$CLAUDE_CONFIG_DIR/plugins/cache/...`). v8.1.2: a `node_modules` segment on // the path from cwd to the target, or a target under the plugin dir, is foreign. // // Same fixture as nested-clone-scope.test.mjs (entropy blob + custom SIG rule // + policy raising the thresholds + a `**` ignore file): // (a) cwd = a repo, target = repo/node_modules/evil-pkg WITH the config: // findings identical to a sibling package WITHOUT the config files. // (b) cwd = a $HOME-like dir, CLAUDE_CONFIG_DIR under it, target = a copy // under plugins/cache WITH the config: identical to one without it. // (c) cwd = the repo, target = a plain subdir of it with the policy: still // honored (known-positive — keeps (a) and (b) from being vacuous). // All under $HOME: outside os.tmpdir() (always foreign) and this repo's tree. import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { resolve, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import crypto from 'node:crypto'; import { mkOwnTreeDir } from '../helpers/own-tree.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ORCHESTRATOR = resolve(__dirname, '../../scanners/scan-orchestrator.mjs'); // Not a real credential — a known-positive blob for the entropy scanner only. const HIGH_ENTROPY_BLOB = crypto.randomBytes(72).toString('base64'); const CUSTOM_MARKER = 'FOREIGNSCOPEMARKER_8112'; const CUSTOM_RULE_ID = 'CUSTOM-FOREIGN-812'; const UNREACHABLE = { entropy: 99, minLen: 1_000_000 }; function writeFixture(dir, { withPolicy, withIgnore }) { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'config.js'), `const payload = "${HIGH_ENTROPY_BLOB}";\nmodule.exports = { payload };\n`); writeFileSync(join(dir, 'notes.txt'), `prefix ${CUSTOM_MARKER} suffix\n`); writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({ rules: [{ id: CUSTOM_RULE_ID, family: 'webshell', severity: 'high', pattern: 'FOREIGNSCOPEMARKER_[0-9]+', description: 'Target-supplied custom rule (must only load for the own working tree)', }], })); if (withPolicy) { mkdirSync(join(dir, '.llm-security'), { recursive: true }); writeFileSync(join(dir, '.llm-security', 'policy.json'), JSON.stringify({ entropy: { thresholds: { critical: UNREACHABLE, high: UNREACHABLE, medium: UNREACHABLE } }, sig: { custom_rules_path: 'custom-sigs.json' }, })); } if (withIgnore) writeFileSync(join(dir, '.llm-security-ignore'), '**\n'); } function gitInit(dir) { const r = spawnSync('git', ['init', '-q', dir], { encoding: 'utf8' }); assert.equal(r.status, 0, `git init failed: ${r.stderr}`); } function runOrchestrator(target, cwd, env = process.env) { return new Promise((resolveP) => { const stdout = []; const stderr = []; const child = spawn('node', [ORCHESTRATOR, target], { cwd, env, timeout: 180_000, stdio: ['ignore', 'pipe', 'pipe'] }); child.stdout.on('data', (c) => stdout.push(c)); child.stderr.on('data', (c) => stderr.push(c)); child.on('close', (code) => { resolveP({ code: code ?? 1, env: JSON.parse(Buffer.concat(stdout).toString('utf8')), stderr: Buffer.concat(stderr).toString('utf8'), }); }); }); } const entropyFindings = (env) => env?.scanners?.entropy?.findings || []; const customSigFindings = (env) => (env?.scanners?.sig?.findings || []) .filter((f) => String(f.evidence || '').includes(CUSTOM_RULE_ID)); /** Order-independent, id-independent fingerprint of every finding. */ function findingKeys(env) { const keys = []; for (const [name, result] of Object.entries(env?.scanners || {})) { for (const f of result.findings || []) { keys.push(`${name}|${f.severity}|${f.title}|${f.file}|${f.line ?? ''}`); } } return keys.sort(); } describe('foreign code without its own .git under cwd is foreign (v8.1.2)', () => { let root; let work; let homeish; before(() => { root = mkOwnTreeDir('foreign-under-cwd-'); work = join(root, 'work'); mkdirSync(work, { recursive: true }); gitInit(work); homeish = join(root, 'homeish'); mkdirSync(homeish, { recursive: true }); }); after(() => { rmSync(root, { recursive: true, force: true }); }); describe('(a) cwd = repo, target = node_modules/evil-pkg with policy.json + `**` ignore', () => { let withRun; let withoutRun; before(async () => { writeFixture(join(work, 'node_modules', 'evil-pkg'), { withPolicy: true, withIgnore: true }); writeFixture(join(work, 'node_modules', 'plain-pkg'), { withPolicy: false, withIgnore: false }); withRun = await runOrchestrator(join(work, 'node_modules', 'evil-pkg'), work); withoutRun = await runOrchestrator(join(work, 'node_modules', 'plain-pkg'), work); }); it('the known HIGH entropy finding survives', () => { assert.equal(entropyFindings(withRun.env).length, 1); }); it('the package-supplied custom SIG rule is not loaded', () => { assert.equal(customSigFindings(withRun.env).length, 0); }); it('nothing is suppressed', () => { assert.ok(!withRun.env.suppressed, `suppressed must be falsy, got ${withRun.env.suppressed}`); }); it('same verdict and same findings as the package without the config files', () => { assert.equal(withRun.env.aggregate.verdict, withoutRun.env.aggregate.verdict); assert.deepEqual(findingKeys(withRun.env), findingKeys(withoutRun.env)); }); it('stderr says both config files were not honored', () => { assert.match(withRun.stderr, /\.llm-security-ignore.*ignored/i); assert.match(withRun.stderr, /policy\.json.*ignored/i); }); }); describe('(b) cwd = $HOME-like dir, target = plugin-cache copy under CLAUDE_CONFIG_DIR', () => { let withRun; let withoutRun; before(async () => { const cache = join(homeish, '.claude-cfg', 'plugins', 'cache', 'mkt'); writeFixture(join(cache, 'evil', '1.0.0'), { withPolicy: true, withIgnore: true }); writeFixture(join(cache, 'plain', '1.0.0'), { withPolicy: false, withIgnore: false }); const env = { ...process.env, CLAUDE_CONFIG_DIR: join(homeish, '.claude-cfg') }; withRun = await runOrchestrator(join(cache, 'evil', '1.0.0'), homeish, env); withoutRun = await runOrchestrator(join(cache, 'plain', '1.0.0'), homeish, env); }); it('the known HIGH entropy finding survives and nothing is suppressed', () => { assert.equal(entropyFindings(withRun.env).length, 1); assert.ok(!withRun.env.suppressed); }); it('the copy-supplied custom SIG rule is not loaded', () => { assert.equal(customSigFindings(withRun.env).length, 0); }); it('same verdict and same findings as the copy without the config files', () => { assert.equal(withRun.env.aggregate.verdict, withoutRun.env.aggregate.verdict); assert.deepEqual(findingKeys(withRun.env), findingKeys(withoutRun.env)); }); }); describe('(c) cwd = repo, target = plain subdir of the SAME repo (known-positive)', () => { let run; before(async () => { writeFixture(join(work, 'inner'), { withPolicy: true, withIgnore: false }); run = await runOrchestrator(join(work, 'inner'), work); }); it('the raised thresholds silence the entropy finding', () => { assert.equal(entropyFindings(run.env).length, 0); }); it('the custom SIG rule loads and fires', () => { assert.equal(customSigFindings(run.env).length, 1); }); }); });