Load-bearing gaten i den to-lags ingestion-sikkerheten: en deterministisk, alltid-på node-skann (unicode/injection/base64, prosa + fenced code blocks) over kandidat skills/**/*.md, wiret inn som sibling til validate-kb-file.mjs ved det ENESTE skrive-chokepunktet — dekker kb-update + generate-skills + fremtidig R7. - lib/adversarial-scan.mjs: ren disposition-kjerne (provenance-tiering + BLOCK/ WARN-matrise). Ortogonal til korrekthets-judgen. - lib/adversarial-detect.mjs: bro til de DELTE llm-security-detektorene (scanForInjection-lexikon + unicode-scanner + base64/entropi) — ingen kopi av lexikonet. Fail-closed hvis llm-security fraværende. - scan-adversarial-content.mjs: CLI (speiler validate-kb-file.mjs); exit 1=BLOCK (aldri skriv), 2=WARN (flagg → menneske), 0=ren. - 30 tester (19 kjerne + 7 CLI + 4 integrasjon mot ekte llm-security). Suite 692/0. Premiss-verifisert mot live kode: research/research-agent skriver ingenting (kun Layer A); R7-judge re-bruker samme create-guard; CLI-scan alene misset injection+base64 for markdown → importerer rene primitiver i stedet.
82 lines
4 KiB
JavaScript
82 lines
4 KiB
JavaScript
// tests/kb-update/test-scan-adversarial-content.test.mjs
|
|
// Layer B create-guard CLI (G6 §8 / R6 punkt d): scripts/kb-update/scan-adversarial-content.mjs
|
|
// is the sibling to validate-kb-file.mjs — the gate any generator (kb-update, generate-skills)
|
|
// calls over candidate files. A file that BLOCKs is never written / never committed; a file
|
|
// that WARNs is flagged for the operator (never auto-committed). Exit: 0 clean, 1 block, 2 warn.
|
|
//
|
|
// scanPaths is dependency-injected (readFile + detect), so these tests feed synthetic content
|
|
// and a fake detector — they never touch disk nor llm-security. FAIL-CLOSED behaviour (a read
|
|
// error or an unavailable detector BLOCKS, never silently passes) is asserted here because it
|
|
// is the whole point of a security gate.
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { scanPaths } from '../../scripts/kb-update/scan-adversarial-content.mjs';
|
|
|
|
const CLEAN = '# T\n\n**Source:** https://learn.microsoft.com/azure/x\n\nProse.\n';
|
|
|
|
function reader(map) {
|
|
return (p) => {
|
|
if (!(p in map)) throw new Error(`ENOENT: ${p}`);
|
|
return map[p];
|
|
};
|
|
}
|
|
// Fake detectors keyed by returning canned raw findings regardless of content.
|
|
const detectClean = async () => [];
|
|
const detectBlock = async () => [{ class: 'injection', severity: 'critical', line: 5, evidence: 'spoofed tag: <system>' }];
|
|
const detectWarn = async () => [{ class: 'injection', severity: 'high', line: 5, evidence: 'imperative in prose' }];
|
|
|
|
test('scanPaths — all clean → ok:true, no block, no warn', async () => {
|
|
const r = await scanPaths(['a.md', 'b.md'], { readFile: reader({ 'a.md': CLEAN, 'b.md': CLEAN }), detect: detectClean });
|
|
assert.equal(r.ok, true);
|
|
assert.equal(r.blocked, false);
|
|
assert.equal(r.warned, false);
|
|
assert.equal(r.results.length, 2);
|
|
assert.ok(r.results.every((x) => x.disposition === 'clean'));
|
|
});
|
|
|
|
test('scanPaths — a blocked file fails the gate (ok:false, blocked:true)', async () => {
|
|
const r = await scanPaths(['bad.md'], { readFile: reader({ 'bad.md': CLEAN }), detect: detectBlock });
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.blocked, true);
|
|
assert.equal(r.results[0].disposition, 'block');
|
|
});
|
|
|
|
test('scanPaths — a warn-only file is flagged, not clean (warned:true, blocked:false)', async () => {
|
|
const r = await scanPaths(['warn.md'], { readFile: reader({ 'warn.md': CLEAN }), detect: detectWarn });
|
|
assert.equal(r.ok, false);
|
|
assert.equal(r.blocked, false);
|
|
assert.equal(r.warned, true);
|
|
assert.equal(r.results[0].disposition, 'warn');
|
|
});
|
|
|
|
test('scanPaths — one blocked file in a batch fails the whole gate', async () => {
|
|
const detect = async (content) => (content === 'POISON' ? detectBlock() : []);
|
|
const r = await scanPaths(['ok.md', 'bad.md'], { readFile: reader({ 'ok.md': CLEAN, 'bad.md': 'POISON' }), detect });
|
|
assert.equal(r.blocked, true);
|
|
assert.equal(r.results.find((x) => x.path === 'ok.md').disposition, 'clean');
|
|
assert.equal(r.results.find((x) => x.path === 'bad.md').disposition, 'block');
|
|
});
|
|
|
|
test('scanPaths — an unreadable path FAILS CLOSED (blocked), not thrown', async () => {
|
|
const r = await scanPaths(['missing.md'], { readFile: () => { throw new Error('ENOENT'); }, detect: detectClean });
|
|
assert.equal(r.blocked, true);
|
|
assert.equal(r.results[0].disposition, 'block');
|
|
assert.ok(r.results[0].findings.some((f) => /read|enoent/i.test(f.evidence || '')));
|
|
});
|
|
|
|
test('scanPaths — a detector that throws (llm-security unavailable) FAILS CLOSED (blocked)', async () => {
|
|
const r = await scanPaths(['x.md'], {
|
|
readFile: reader({ 'x.md': CLEAN }),
|
|
detect: async () => { throw new Error('LLM_SECURITY_UNAVAILABLE'); },
|
|
});
|
|
assert.equal(r.blocked, true);
|
|
assert.equal(r.results[0].disposition, 'block');
|
|
assert.ok(r.results[0].findings.some((f) => /llm_security|scanner/i.test(f.evidence || '')));
|
|
});
|
|
|
|
test('scanPaths — no paths → ok:true (nothing to gate)', async () => {
|
|
const r = await scanPaths([], { readFile: reader({}), detect: detectClean });
|
|
assert.equal(r.ok, true);
|
|
assert.deepEqual(r.results, []);
|
|
});
|