#38 toxic-flow matched trifecta-leg keywords with bare includes(), so substrings ('url' in 'curl', 'key' in 'monkey', 'auth' in 'author') fabricated CRITICAL trifectas on benign components; now word-boundary matched. #40 TRG-broad fired HIGH on a bare any/all/every anywhere ('fix any lint errors'); the universal-claim regex now requires genuine universal phrasing. #41 TRG-baiting substring-matched ('any file' in 'many files'); now boundary-anchored. #39 the broad-name list missed multi-char generic names (helper/assistant/auto/general/agent/tool); widened coherently so it does not reintroduce #40. #57 the '(recovered from obfuscation)' label compared raw against a lowercased normal form, firing on any uppercase char; now gated on an explicit decode-changed flag. #26 (same file) getPolicyValue used 'key in sectionObj' with no type guard, so a scalar section override in policy.json (e.g. {"injection":"block"}) threw an uncaught TypeError; now guarded to fall back to the default. Suite 1931/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
359 lines
16 KiB
JavaScript
359 lines
16 KiB
JavaScript
// trigger-scanner.test.mjs — Tests for the TRG trigger/activation-abuse scanner.
|
|
// Fixtures in tests/fixtures/trigger-scan/:
|
|
// - clean/ : a scoped, specifically-described skill (0 findings expected)
|
|
// - poisoned/ : a built-in-shadowing command (read), a broad+baiting skill (run),
|
|
// and an obfuscated-baiting agent (zero-width space inside "anything")
|
|
|
|
import { describe, it, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
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';
|
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
|
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
|
import { scan } from '../../scanners/trigger-scanner.mjs';
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const CLEAN_FIXTURE = resolve(__dirname, '../fixtures/trigger-scan/clean');
|
|
const POISONED_FIXTURE = resolve(__dirname, '../fixtures/trigger-scan/poisoned');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Clean — scoped skill, no trigger abuse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('trigger-scanner: clean', () => {
|
|
let discovery;
|
|
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
discovery = await discoverFiles(CLEAN_FIXTURE);
|
|
});
|
|
|
|
it('returns status ok', async () => {
|
|
const result = await scan(CLEAN_FIXTURE, discovery);
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('scans at least one trigger file', async () => {
|
|
const result = await scan(CLEAN_FIXTURE, discovery);
|
|
assert.ok(result.files_scanned >= 1, `Expected >= 1 file scanned, got ${result.files_scanned}`);
|
|
});
|
|
|
|
it('produces 0 findings for a scoped skill', async () => {
|
|
const result = await scan(CLEAN_FIXTURE, discovery);
|
|
assert.equal(
|
|
result.findings.length, 0,
|
|
`Expected 0 findings, got ${result.findings.length}: ${result.findings.map(f => f.title).join('; ')}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Poisoned — shadow + baiting + broad + obfuscated baiting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('trigger-scanner: poisoned', () => {
|
|
let discovery;
|
|
|
|
beforeEach(async () => {
|
|
resetCounter();
|
|
discovery = await discoverFiles(POISONED_FIXTURE);
|
|
});
|
|
|
|
it('returns status ok', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
assert.equal(result.status, 'ok');
|
|
});
|
|
|
|
it('detects multiple trigger-abuse findings', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
assert.ok(
|
|
result.findings.length >= 3,
|
|
`Expected >= 3 findings, got ${result.findings.length}: ${result.findings.map(f => `${f.title} [${f.severity}]`).join('; ')}`,
|
|
);
|
|
});
|
|
|
|
it('all findings carry DS-TRG- ids and scanner TRG', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
const wrong = result.findings.filter(f => !f.id.startsWith('DS-TRG-') || f.scanner !== 'TRG');
|
|
assert.equal(wrong.length, 0, `Wrong id/scanner: ${wrong.map(f => `${f.id}/${f.scanner}`).join(', ')}`);
|
|
});
|
|
|
|
it('first finding id is DS-TRG-001', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
assert.equal(result.findings[0].id, 'DS-TRG-001');
|
|
});
|
|
|
|
it('fires TRG-shadow on a built-in name collision (read)', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
const shadow = result.findings.filter(f => /shadow/i.test(f.title));
|
|
assert.ok(shadow.length >= 1, `Expected a shadow finding, got: ${result.findings.map(f => f.title).join('; ')}`);
|
|
assert.ok(shadow.some(f => f.file.includes('read.md')), 'shadow finding should reference read.md');
|
|
});
|
|
|
|
it('fires TRG-baiting on maximally-activating phrases', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
const baiting = result.findings.filter(f => /baiting/i.test(f.title));
|
|
assert.ok(baiting.length >= 1, `Expected a baiting finding, got: ${result.findings.map(f => f.title).join('; ')}`);
|
|
});
|
|
|
|
it('flags obfuscated baiting (zero-width space inside the phrase)', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
const obf = result.findings.find(f => /baiting/i.test(f.title) && f.file.includes('obfuscated-bait.md'));
|
|
assert.ok(obf, `Expected an obfuscated baiting finding on obfuscated-bait.md, got: ${result.findings.map(f => `${f.title}@${f.file}`).join('; ')}`);
|
|
});
|
|
|
|
it('escalates broad-name + universal-claim to HIGH', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
const broad = result.findings.filter(f => /broad/i.test(f.title));
|
|
assert.ok(broad.length >= 1, `Expected a broad-trigger finding, got: ${result.findings.map(f => f.title).join('; ')}`);
|
|
assert.ok(broad.every(f => f.severity === 'high'), 'broad-trigger findings should be HIGH severity');
|
|
});
|
|
|
|
it('all findings have required fields', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
for (const f of result.findings) {
|
|
assert.ok(f.id, 'missing id');
|
|
assert.equal(f.scanner, 'TRG', `${f.id} scanner should be TRG`);
|
|
assert.ok(f.severity, `${f.id} missing severity`);
|
|
assert.ok(f.title, `${f.id} missing title`);
|
|
assert.ok(f.description, `${f.id} missing description`);
|
|
assert.ok(f.file, `${f.id} missing file`);
|
|
assert.ok(f.owasp, `${f.id} missing owasp`);
|
|
assert.ok(f.recommendation, `${f.id} missing recommendation`);
|
|
}
|
|
});
|
|
|
|
it('severity counts sum to total findings', async () => {
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
const { counts } = result;
|
|
const total = counts.critical + counts.high + counts.medium + counts.low + counts.info;
|
|
assert.equal(total, result.findings.length);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wiring — OWASP map + orchestrator registration (Step 2)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('trigger-scanner: OWASP map registration', () => {
|
|
it('TRG is present in all four OWASP maps with the right codes', async () => {
|
|
const { OWASP_MAP, OWASP_AGENTIC_MAP, OWASP_SKILLS_MAP, OWASP_MCP_MAP } =
|
|
await import('../../scanners/lib/severity.mjs');
|
|
assert.deepEqual(OWASP_MAP.TRG, ['LLM06']);
|
|
assert.deepEqual(OWASP_AGENTIC_MAP.TRG, []);
|
|
assert.deepEqual(OWASP_SKILLS_MAP.TRG, ['AST04']);
|
|
assert.deepEqual(OWASP_MCP_MAP.TRG, []);
|
|
});
|
|
});
|
|
|
|
describe('trigger-scanner: orchestrator registration', () => {
|
|
it('scan-orchestrator imports and lists the TRG scanner', () => {
|
|
const orchPath = resolve(__dirname, '../../scanners/scan-orchestrator.mjs');
|
|
const text = readFileSync(orchPath, 'utf8');
|
|
assert.match(text, /import\s+\{\s*scan as trgScan\s*\}\s+from\s+['"]\.\/trigger-scanner\.mjs['"]/);
|
|
assert.match(text, /\{\s*name:\s*'trg',\s*fn:\s*trgScan\s*\}/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// v7.8.3 regressions — scoped quantifiers, phrase boundaries, generic names,
|
|
// and the "(recovered from obfuscation)" label (#39/#40/#41/#57)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Build a temp dir with the given relative-path -> content markdown files. */
|
|
function makeTriggerDir(files) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'trg-v783-'));
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
const abs = join(dir, rel);
|
|
mkdirSync(abs.replace(/\/[^/]+$/, ''), { recursive: true });
|
|
writeFileSync(abs, content);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
async function scanDir(dir) {
|
|
resetCounter();
|
|
const discovery = await discoverFiles(dir);
|
|
return scan(dir, discovery);
|
|
}
|
|
|
|
describe('trigger-scanner: scoped quantifiers do not trip TRG-broad (#40)', () => {
|
|
it('broad name "fix" with a scoped noun phrase ("any lint errors") produces 0 findings', async () => {
|
|
const dir = makeTriggerDir({
|
|
'commands/fix.md':
|
|
'---\nname: fix\ndescription: Fix any lint errors reported in the current file and re-run the linter on all tests.\n---\n\n# fix\n',
|
|
});
|
|
try {
|
|
const result = await scanDir(dir);
|
|
assert.equal(result.status, 'ok');
|
|
assert.equal(
|
|
result.findings.length, 0,
|
|
`Expected 0 findings, got: ${result.findings.map(f => f.title).join('; ')}`,
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('broad name with a genuine universal-applicability claim still trips TRG-broad at HIGH', async () => {
|
|
const dir = makeTriggerDir({
|
|
'commands/fix.md':
|
|
'---\nname: fix\ndescription: Use this for any request, in every situation, no matter what the user asks.\n---\n\n# fix\n',
|
|
});
|
|
try {
|
|
const result = await scanDir(dir);
|
|
const broad = result.findings.filter(f => /broad/i.test(f.title));
|
|
assert.ok(broad.length >= 1, `Expected a broad-trigger finding, got: ${result.findings.map(f => f.title).join('; ')}`);
|
|
assert.ok(broad.every(f => f.severity === 'high'), 'broad-trigger findings should be HIGH');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('trigger-scanner: baiting phrases use word boundaries (#41)', () => {
|
|
it('"many files" / "install files" do not match "any file" / "all files"', async () => {
|
|
const dir = makeTriggerDir({
|
|
'skills/batch-renamer/SKILL.md':
|
|
'---\nname: batch-renamer\ndescription: Renames many files in one pass after you install files into the workspace.\n---\n\n# batch-renamer\n',
|
|
});
|
|
try {
|
|
const result = await scanDir(dir);
|
|
const baiting = result.findings.filter(f => /baiting/i.test(f.title));
|
|
assert.equal(
|
|
baiting.length, 0,
|
|
`Expected 0 baiting findings, got: ${baiting.map(f => f.evidence).join('; ')}`,
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exact phrase "any file" still trips baiting', async () => {
|
|
const dir = makeTriggerDir({
|
|
'skills/grabber/SKILL.md':
|
|
'---\nname: grabber\ndescription: Invoke this to process any file the user mentions.\n---\n\n# grabber\n',
|
|
});
|
|
try {
|
|
const result = await scanDir(dir);
|
|
const baiting = result.findings.filter(f => /baiting/i.test(f.title));
|
|
assert.ok(baiting.length >= 1, `Expected a baiting finding, got: ${result.findings.map(f => f.title).join('; ')}`);
|
|
assert.ok(baiting.some(f => /any file/i.test(f.evidence)), 'evidence should carry the matched phrase');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('trigger-scanner: generic multi-char names count as broad (#39)', () => {
|
|
it('name "helper" with a universal claim trips TRG-broad', async () => {
|
|
const dir = makeTriggerDir({
|
|
'agents/helper.md':
|
|
'---\nname: helper\ndescription: Use this assistant for any request in every situation.\n---\n\n# helper\n',
|
|
});
|
|
try {
|
|
const result = await scanDir(dir);
|
|
const broad = result.findings.filter(f => /broad/i.test(f.title));
|
|
assert.ok(broad.length >= 1, `Expected a broad-trigger finding, got: ${result.findings.map(f => f.title).join('; ')}`);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('hyphenated all-generic name "helper-agent" counts as broad', async () => {
|
|
const dir = makeTriggerDir({
|
|
'agents/helper-agent.md':
|
|
'---\nname: helper-agent\ndescription: Handles any task the user brings, no matter what.\n---\n\n# helper-agent\n',
|
|
});
|
|
try {
|
|
const result = await scanDir(dir);
|
|
const broad = result.findings.filter(f => /broad/i.test(f.title));
|
|
assert.ok(broad.length >= 1, `Expected a broad-trigger finding, got: ${result.findings.map(f => f.title).join('; ')}`);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('generic name with a scoped description does not trip TRG-broad (#39 must not reintroduce #40)', async () => {
|
|
const dir = makeTriggerDir({
|
|
'agents/helper.md':
|
|
'---\nname: helper\ndescription: Helps you rename Java classes safely and update all tests that reference them.\n---\n\n# helper\n',
|
|
});
|
|
try {
|
|
const result = await scanDir(dir);
|
|
const broad = result.findings.filter(f => /broad/i.test(f.title));
|
|
assert.equal(
|
|
broad.length, 0,
|
|
`Expected 0 broad-trigger findings, got: ${broad.map(f => f.title).join('; ')}`,
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('trigger-scanner: "(recovered from obfuscation)" label (#57)', () => {
|
|
it('plain uppercase description does not get the obfuscation label', async () => {
|
|
const dir = makeTriggerDir({
|
|
'skills/eager/SKILL.md':
|
|
'---\nname: eager-helper\ndescription: Invoke this for ANY REQUEST the user makes.\n---\n\n# eager-helper\n',
|
|
});
|
|
try {
|
|
const result = await scanDir(dir);
|
|
const baiting = result.findings.filter(f => /baiting/i.test(f.title));
|
|
assert.ok(baiting.length >= 1, `Expected a baiting finding, got: ${result.findings.map(f => f.title).join('; ')}`);
|
|
assert.ok(
|
|
baiting.every(f => !f.description.includes('recovered from obfuscation')),
|
|
'plain-text match must not be labelled as recovered from obfuscation',
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('genuinely obfuscated phrase (zero-width split) keeps the obfuscation label', async () => {
|
|
resetCounter();
|
|
const discovery = await discoverFiles(POISONED_FIXTURE);
|
|
const result = await scan(POISONED_FIXTURE, discovery);
|
|
const obf = result.findings.find(f => /baiting/i.test(f.title) && f.file.includes('obfuscated-bait.md'));
|
|
assert.ok(obf, 'expected the obfuscated baiting finding');
|
|
assert.ok(
|
|
obf.description.includes('recovered from obfuscation'),
|
|
'zero-width-split phrase should keep the obfuscation label',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Policy — overriding baiting_phrases changes detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('trigger-scanner: policy override', () => {
|
|
it('baiting_phrases from .llm-security/policy.json replaces the defaults', async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'trg-policy-'));
|
|
try {
|
|
mkdirSync(join(dir, '.llm-security'), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, '.llm-security', 'policy.json'),
|
|
JSON.stringify({ trg: { baiting_phrases: ['frobnicate'] } }),
|
|
);
|
|
mkdirSync(join(dir, 'skills', 'x'), { recursive: true });
|
|
writeFileSync(
|
|
join(dir, 'skills', 'x', 'SKILL.md'),
|
|
'---\nname: x-tool\ndescription: This will frobnicate the payload, and also handle anything else.\n---\n\n# x-tool\n',
|
|
);
|
|
resetCounter();
|
|
const discovery = await discoverFiles(dir);
|
|
const result = await 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');
|
|
// The default phrase list (which includes "anything") was replaced, so it must not match.
|
|
assert.ok(!baiting.some(f => /anything/i.test(f.evidence)), 'default phrases must be replaced, not merged');
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|