fix(scope): node_modules and the Claude Code plugin dir are foreign
A target with no .git of its own under cwd shares cwd's git root, so the v8.1.1 rule alone called it own: an installed package under a repo and a plugin-cache copy still had their .llm-security-ignore / policy.json read. Chosen (PM rule, order 20260922T192716Z): a target is additionally foreign when the path from cwd to it has a node_modules segment, or when it lies under $CLAUDE_CONFIG_DIR/plugins (default ~/.claude/plugins). Because those are the two concrete places foreign code lands under a user's working directory, the failure direction is safe (foreign means more findings), and a general "no .git of its own" rule would shut out ordinary subdirectories of the caller's own repo. git archive exports stay indistinguishable from own subdirectories; documented as a known limit in the module header. Red first: 6 unit + 6 orchestrator assertions failed before the fix; known-positives (plain subdir, workspace package dir, rest of config dir) passed before and after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
931466cffa
commit
a61c1c9648
3 changed files with 323 additions and 3 deletions
|
|
@ -100,3 +100,97 @@ describe('isOwnWorkingTree(): same git root as cwd (v8.1.1)', () => {
|
|||
assert.equal(ownFrom(join(repo, 'sub'), repo), false);
|
||||
});
|
||||
});
|
||||
|
||||
// v8.1.2: a target with no `.git` of its own under cwd shares cwd's git root,
|
||||
// so the v8.1.1 rule alone calls it own. Two concrete places foreign code lands
|
||||
// under a user's working directory without a `.git`: an installed package
|
||||
// (a `node_modules` segment on the path from cwd to the target) and Claude
|
||||
// Code's plugin directory (`$CLAUDE_CONFIG_DIR/plugins`, default
|
||||
// `~/.claude/plugins` — cache/ and marketplaces/). Both are foreign now.
|
||||
describe('isOwnWorkingTree(): node_modules and the plugin dir are foreign (v8.1.2)', () => {
|
||||
let root;
|
||||
let repo;
|
||||
let savedConfigDir;
|
||||
let savedHome;
|
||||
|
||||
before(() => {
|
||||
root = mkOwnTreeDir('owt-812-');
|
||||
repo = join(root, 'repo');
|
||||
gitInit(repo);
|
||||
mkdirSync(join(repo, 'node_modules', 'evil-pkg', 'lib'), { recursive: true });
|
||||
mkdirSync(join(repo, 'node_modules', '@scope', 'pkg'), { recursive: true });
|
||||
mkdirSync(join(repo, 'packages', 'app', 'node_modules', 'dep'), { recursive: true });
|
||||
mkdirSync(join(repo, 'my-node_modules-notes'), { recursive: true });
|
||||
// A config dir with a plugin-cache copy (no `.git`, like 234/235 real ones)
|
||||
// and a plugin config dir sitting in a plain (no git root) parent.
|
||||
mkdirSync(join(root, 'cfg', 'plugins', 'cache', 'mkt', 'evil', '1.0.0'), { recursive: true });
|
||||
mkdirSync(join(root, 'cfg', 'plugins', 'marketplaces', 'mkt'), { recursive: true });
|
||||
mkdirSync(join(root, 'cfg', 'projects'), { recursive: true });
|
||||
mkdirSync(join(root, 'fakehome', '.claude', 'plugins', 'cache', 'mkt', 'p', '2.0.0'), { recursive: true });
|
||||
savedConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
savedHome = process.env.HOME;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (savedConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = savedConfigDir;
|
||||
process.env.HOME = savedHome;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('(a) an installed package under the repo is foreign', () => {
|
||||
assert.equal(ownFrom(repo, join(repo, 'node_modules', 'evil-pkg')), false);
|
||||
assert.equal(ownFrom(repo, join(repo, 'node_modules', 'evil-pkg', 'lib')), false);
|
||||
assert.equal(ownFrom(repo, join(repo, 'node_modules', '@scope', 'pkg')), false);
|
||||
assert.equal(ownFrom(repo, join(repo, 'node_modules')), false);
|
||||
});
|
||||
|
||||
it('(a) a nested node_modules (workspace package) is foreign', () => {
|
||||
assert.equal(ownFrom(repo, join(repo, 'packages', 'app', 'node_modules', 'dep')), false);
|
||||
});
|
||||
|
||||
it('(c) the workspace package itself and a look-alike name stay own (known-positive)', () => {
|
||||
assert.equal(ownFrom(repo, join(repo, 'packages', 'app')), true);
|
||||
assert.equal(ownFrom(repo, join(repo, 'my-node_modules-notes')), true);
|
||||
});
|
||||
|
||||
it('a package the user has cd\'d into is own (only the path from cwd counts)', () => {
|
||||
assert.equal(ownFrom(join(repo, 'node_modules', 'evil-pkg'), '.'), true);
|
||||
assert.equal(ownFrom(join(repo, 'node_modules', 'evil-pkg'), 'lib'), true);
|
||||
});
|
||||
|
||||
it('(b) a copy under $CLAUDE_CONFIG_DIR/plugins/cache is foreign', () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = join(root, 'cfg');
|
||||
const target = join(root, 'cfg', 'plugins', 'cache', 'mkt', 'evil', '1.0.0');
|
||||
assert.equal(ownFrom(root, target), false);
|
||||
assert.equal(ownFrom(join(root, 'cfg'), target), false);
|
||||
assert.equal(ownFrom(target, '.'), false);
|
||||
});
|
||||
|
||||
it('(b) a clone under $CLAUDE_CONFIG_DIR/plugins/marketplaces is foreign', () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = join(root, 'cfg');
|
||||
assert.equal(ownFrom(join(root, 'cfg'), join(root, 'cfg', 'plugins', 'marketplaces', 'mkt')), false);
|
||||
});
|
||||
|
||||
it('(b) a relative CLAUDE_CONFIG_DIR is resolved against cwd', () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = 'cfg';
|
||||
assert.equal(ownFrom(root, join(root, 'cfg', 'plugins', 'cache', 'mkt', 'evil', '1.0.0')), false);
|
||||
});
|
||||
|
||||
it('(c) the rest of the config dir stays own (known-positive)', () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = join(root, 'cfg');
|
||||
assert.equal(ownFrom(join(root, 'cfg'), join(root, 'cfg', 'projects')), true);
|
||||
});
|
||||
|
||||
it('(b) without CLAUDE_CONFIG_DIR the default is ~/.claude/plugins', () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.HOME = join(root, 'fakehome');
|
||||
try {
|
||||
const home = join(root, 'fakehome');
|
||||
assert.equal(ownFrom(home, join(home, '.claude', 'plugins', 'cache', 'mkt', 'p', '2.0.0')), false);
|
||||
assert.equal(ownFrom(home, join(home, '.claude')), true);
|
||||
} finally {
|
||||
process.env.HOME = savedHome;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
192
tests/scanners/foreign-under-cwd-scope.test.mjs
Normal file
192
tests/scanners/foreign-under-cwd-scope.test.mjs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// 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 (`<repo>/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue