fix(policy): read policy.json only from the caller's own working tree
loadPolicy() read .llm-security/policy.json from whatever root it was
given, and every scanner passes the SCANNED TARGET: scan-orchestrator
(policyRoot = resolve(args.target)), entropy-scanner (thresholds and
suppression patterns), signature-scanner (sig.custom_rules_path and
enabled_families), trigger-scanner (phrase lists) and ast-taint-scanner
(enabled, python_path). A foreign/cloned target could raise its own
entropy thresholds, disable SIG families, supply its own SIG ruleset or
name the interpreter the AST scanner spawns — configuring the scan of
itself. Same defect class as S3b's .llm-security-ignore fix.
Chosen: move isOwnWorkingTree() to scanners/lib/own-working-tree.mjs (one
copy, reused by the orchestrator's ignore-file check) and make
loadPolicy() refuse an EXPLICIT root that is not the caller's own tree —
defaults plus one stderr line, same form as S3b — because one rule in one
function covers every scanner and a future call site cannot forget it.
The IMPLICIT root (CLAUDE_PROJECT_ROOT/cwd, what every hook uses) is the
caller's own project by construction and is read as before.
entropy-scanner's calibration.policy_source no longer reports an ignored
file as its source.
New tests/scanners/policy-scope.test.mjs was red on 0d37f5a (foreign
target: entropy finding silenced, custom SIG rule loaded, findings differ
from the same tree without policy.json, no stderr line) and is green now;
its own-tree scenario (known-positive) is green before and after. The 15
existing policy tests that placed own-tree fixtures under os.tmpdir() now
use tests/helpers/own-tree.mjs (fixture under $HOME, cwd set to it).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d37f5a628
commit
6d0f3c31fc
12 changed files with 364 additions and 50 deletions
222
tests/scanners/policy-scope.test.mjs
Normal file
222
tests/scanners/policy-scope.test.mjs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// policy-scope.test.mjs — .llm-security/policy.json (and the custom SIG
|
||||
// ruleset it can point at) must never be honored for a scanned target that is
|
||||
// not the user's own working tree (S3c, v8.1.0, 2026-09-22).
|
||||
//
|
||||
// scan-orchestrator.mjs, entropy-scanner.mjs, signature-scanner.mjs,
|
||||
// trigger-scanner.mjs and ast-taint-scanner.mjs all call
|
||||
// loadPolicy()/getPolicyValue() with the SCANNED TARGET as the root. A
|
||||
// foreign/cloned target could therefore ship its own policy.json that raises
|
||||
// the entropy thresholds past any real value (silencing a known finding) and
|
||||
// points `sig.custom_rules_path` at a ruleset of its own choosing — a hostile
|
||||
// repo configuring the scan of itself. Same defect class as S3b's
|
||||
// .llm-security-ignore fix and the v8.0.0 commons-root fix.
|
||||
//
|
||||
// Scenarios (known finding = a random-bytes base64 blob in a .js file, which
|
||||
// the entropy scanner classifies HIGH — same fixture S3b uses):
|
||||
// FOREIGN target (absolute path under os.tmpdir(), scanned from this repo's
|
||||
// cwd — what git-clone.mjs hands the orchestrator): the envelope with the
|
||||
// hostile policy.json must equal the envelope of the same tree WITHOUT
|
||||
// the policy file — same verdict, same findings. The custom SIG rule must
|
||||
// not fire, the entropy finding must survive, and stderr must say the
|
||||
// policy was not honored.
|
||||
// OWN working tree (cwd === target, arg '.', outside os.tmpdir() and outside
|
||||
// this repo's git tree — see ignore-file-scope.test.mjs for why): the same
|
||||
// policy.json MUST still be honored. This is the known-positive that keeps
|
||||
// the FOREIGN equality from being vacuous: it proves the fixture's policy
|
||||
// really would silence the entropy finding and really would add the
|
||||
// custom SIG finding.
|
||||
// loadPolicy() unit level: an explicit foreign root yields defaults; the
|
||||
// implicit root (CLAUDE_PROJECT_ROOT / cwd — what every hook uses) is
|
||||
// still read, so the hooks are untouched.
|
||||
|
||||
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 } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { loadPolicy, _resetCacheForTest } from '../../scanners/lib/policy-loader.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ORCHESTRATOR = resolve(__dirname, '../../scanners/scan-orchestrator.mjs');
|
||||
const REPO_ROOT = resolve(__dirname, '../..');
|
||||
|
||||
// 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 = 'POLICYSCOPEMARKER_7731';
|
||||
const CUSTOM_RULE_ID = 'CUSTOM-SCOPE-001';
|
||||
|
||||
const UNREACHABLE = { entropy: 99, minLen: 1_000_000 };
|
||||
const HOSTILE_POLICY = {
|
||||
entropy: {
|
||||
thresholds: { critical: UNREACHABLE, high: UNREACHABLE, medium: UNREACHABLE },
|
||||
},
|
||||
sig: { custom_rules_path: 'custom-sigs.json' },
|
||||
};
|
||||
|
||||
function writeFixture(dir, { withPolicy }) {
|
||||
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: 'POLICYSCOPEMARKER_[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(HOSTILE_POLICY));
|
||||
}
|
||||
}
|
||||
|
||||
function runOrchestrator(target, cwd) {
|
||||
return new Promise((resolveP) => {
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
const child = spawn('node', [ORCHESTRATOR, target], {
|
||||
cwd,
|
||||
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,
|
||||
stdout: 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('.llm-security/policy.json is scoped to the user\'s own working tree (S3c)', () => {
|
||||
describe('FOREIGN target: absolute path under os.tmpdir(), scanned from a different cwd', () => {
|
||||
let withDir;
|
||||
let withoutDir;
|
||||
let withRun;
|
||||
let withEnv;
|
||||
let withoutEnv;
|
||||
|
||||
before(async () => {
|
||||
// Both trees share one parent so the only difference the scanners can
|
||||
// see is the presence of .llm-security/policy.json.
|
||||
const parent = mkdtempSync(join(tmpdir(), 'policy-scope-foreign-'));
|
||||
withDir = join(parent, 'target');
|
||||
withoutDir = join(parent, 'target-nopolicy');
|
||||
mkdirSync(withDir);
|
||||
mkdirSync(withoutDir);
|
||||
writeFixture(withDir, { withPolicy: true });
|
||||
writeFixture(withoutDir, { withPolicy: false });
|
||||
withRun = await runOrchestrator(withDir, REPO_ROOT);
|
||||
withEnv = JSON.parse(withRun.stdout);
|
||||
withoutEnv = JSON.parse((await runOrchestrator(withoutDir, REPO_ROOT)).stdout);
|
||||
});
|
||||
|
||||
after(() => { rmSync(dirname(withDir), { recursive: true, force: true }); });
|
||||
|
||||
it('the known HIGH entropy finding survives the target\'s raised thresholds', () => {
|
||||
assert.equal(entropyFindings(withEnv).length, 1,
|
||||
'entropy thresholds from a foreign target\'s policy.json must not be applied');
|
||||
});
|
||||
|
||||
it('the target-supplied custom SIG rule is not loaded', () => {
|
||||
assert.equal(customSigFindings(withEnv).length, 0,
|
||||
'sig.custom_rules_path from a foreign target\'s policy.json must not be loaded');
|
||||
});
|
||||
|
||||
it('same verdict as the same tree without policy.json', () => {
|
||||
assert.equal(withEnv.aggregate.verdict, withoutEnv.aggregate.verdict);
|
||||
});
|
||||
|
||||
it('same findings as the same tree without policy.json', () => {
|
||||
assert.deepEqual(findingKeys(withEnv), findingKeys(withoutEnv));
|
||||
});
|
||||
|
||||
it('entropy calibration reports the defaults as its policy source, not the ignored file', () => {
|
||||
assert.equal(withEnv.scanners.entropy.calibration.policy_source, 'defaults');
|
||||
});
|
||||
|
||||
it('logs a stderr line stating the policy file was not honored, and why', () => {
|
||||
assert.match(withRun.stderr, /policy\.json.*ignored/i,
|
||||
'a foreign-target policy override must be loud, not silent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OWN working tree: `node scanners/scan-orchestrator.mjs .` (known-positive)', () => {
|
||||
let ownDir;
|
||||
let env;
|
||||
|
||||
before(async () => {
|
||||
// Outside os.tmpdir() AND outside this repo's git tree — same placement
|
||||
// rule as ignore-file-scope.test.mjs.
|
||||
ownDir = mkdtempSync(join(homedir(), '.policy-scope-own-'));
|
||||
writeFixture(ownDir, { withPolicy: true });
|
||||
env = JSON.parse((await runOrchestrator('.', ownDir)).stdout);
|
||||
});
|
||||
|
||||
after(() => { rmSync(ownDir, { recursive: true, force: true }); });
|
||||
|
||||
it('the raised thresholds silence the entropy finding', () => {
|
||||
assert.equal(entropyFindings(env).length, 0,
|
||||
'policy.json in the caller\'s own working tree must still be honored');
|
||||
});
|
||||
|
||||
it('the custom SIG rule loads and fires', () => {
|
||||
assert.equal(customSigFindings(env).length, 1,
|
||||
'sig.custom_rules_path in the caller\'s own working tree must still load');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadPolicy() root handling', () => {
|
||||
let dir;
|
||||
let prevRoot;
|
||||
|
||||
before(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'policy-scope-unit-'));
|
||||
writeFixture(dir, { withPolicy: true });
|
||||
prevRoot = process.env.CLAUDE_PROJECT_ROOT;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (prevRoot === undefined) delete process.env.CLAUDE_PROJECT_ROOT;
|
||||
else process.env.CLAUDE_PROJECT_ROOT = prevRoot;
|
||||
_resetCacheForTest();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('an explicit foreign root (under os.tmpdir(), not under cwd) yields the defaults', () => {
|
||||
_resetCacheForTest();
|
||||
const policy = loadPolicy(dir);
|
||||
assert.equal(policy.sig.custom_rules_path, null);
|
||||
assert.equal(policy.entropy.thresholds.high.entropy, 5.1);
|
||||
});
|
||||
|
||||
it('the implicit root (CLAUDE_PROJECT_ROOT, as the hooks use it) is still read', () => {
|
||||
_resetCacheForTest();
|
||||
process.env.CLAUDE_PROJECT_ROOT = dir;
|
||||
const policy = loadPolicy();
|
||||
assert.equal(policy.sig.custom_rules_path, 'custom-sigs.json');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue