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.
163 lines
7.3 KiB
JavaScript
163 lines
7.3 KiB
JavaScript
// tests/kb-update/test-adversarial-scan.test.mjs
|
|
// Layer B (G6 §8 / R6 punkt d) — the PURE disposition core of the ingestion
|
|
// security gate. `classifyFindings` takes already-detected raw findings + the file
|
|
// content and decides, per provenance tier, whether each finding BLOCKS (hard-fail,
|
|
// never written), WARNs (flag → human review), or passes. It is orthogonal to the
|
|
// correctness judge: a factually-correct file with a payload is still blocked.
|
|
//
|
|
// These tests feed SYNTHETIC raw findings — they never touch disk nor llm-security,
|
|
// so they isolate the ms-ai-architect-specific brain (tiering + matrix) from the
|
|
// shared llm-security detector (integration-tested separately).
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
findFencedCodeRanges,
|
|
lineInCode,
|
|
localeFromSource,
|
|
provenanceTier,
|
|
disposition,
|
|
classifyFindings,
|
|
} from '../../scripts/kb-update/lib/adversarial-scan.mjs';
|
|
|
|
// A KB-style doc: header (authored prose) + a fenced code block.
|
|
// Line map (1-indexed):
|
|
// 1 # Title
|
|
// 2 (blank)
|
|
// 3 **Source:** https://learn.microsoft.com/azure/x
|
|
// 4 (blank)
|
|
// 5 Authored prose line.
|
|
// 6 (blank)
|
|
// 7 ```json
|
|
// 8 { "payload": "..." }
|
|
// 9 ```
|
|
// 10 More prose.
|
|
const DOC =
|
|
'# Title\n\n**Source:** https://learn.microsoft.com/azure/x\n\n' +
|
|
'Authored prose line.\n\n```json\n{ "payload": "..." }\n```\nMore prose.\n';
|
|
|
|
test('findFencedCodeRanges — locates the ``` fenced block line span', () => {
|
|
const ranges = findFencedCodeRanges(DOC);
|
|
assert.equal(ranges.length, 1);
|
|
assert.deepEqual(ranges[0], [7, 9]); // opening fence line 7 .. closing fence line 9
|
|
});
|
|
|
|
test('findFencedCodeRanges — unterminated fence treats the rest as code', () => {
|
|
const ranges = findFencedCodeRanges('a\n```\nb\nc');
|
|
assert.equal(ranges.length, 1);
|
|
assert.deepEqual(ranges[0], [2, 4]);
|
|
});
|
|
|
|
test('lineInCode — inside vs outside the fence', () => {
|
|
const ranges = findFencedCodeRanges(DOC);
|
|
assert.equal(lineInCode(8, ranges), true); // inside the code block
|
|
assert.equal(lineInCode(5, ranges), false); // authored prose
|
|
});
|
|
|
|
test('localeFromSource — extracts a Microsoft Learn locale segment', () => {
|
|
assert.equal(localeFromSource('https://learn.microsoft.com/nb-no/azure/x'), 'nb-no');
|
|
assert.equal(localeFromSource('https://learn.microsoft.com/en-us/azure/x'), 'en-us');
|
|
assert.equal(localeFromSource('https://learn.microsoft.com/azure/x'), null); // no locale segment
|
|
assert.equal(localeFromSource(null), null);
|
|
});
|
|
|
|
test('provenanceTier — code line is code-sample (low trust)', () => {
|
|
const ranges = findFencedCodeRanges(DOC);
|
|
assert.equal(provenanceTier({ line: 8, ranges, sourceUrl: 'https://learn.microsoft.com/azure/x' }), 'code-sample');
|
|
});
|
|
|
|
test('provenanceTier — prose in an en-us/no-locale doc is authored-doc (high trust)', () => {
|
|
const ranges = findFencedCodeRanges(DOC);
|
|
assert.equal(provenanceTier({ line: 5, ranges, sourceUrl: 'https://learn.microsoft.com/azure/x' }), 'authored-doc');
|
|
});
|
|
|
|
test('provenanceTier — prose in a localized (non-en) doc is localized (low trust)', () => {
|
|
const ranges = findFencedCodeRanges(DOC);
|
|
assert.equal(provenanceTier({ line: 5, ranges, sourceUrl: 'https://learn.microsoft.com/nb-no/azure/x' }), 'localized');
|
|
});
|
|
|
|
// --- Disposition matrix -----------------------------------------------------
|
|
|
|
test('disposition — invisible unicode carriers BLOCK in any tier (never legitimate)', () => {
|
|
for (const subtype of ['zero-width', 'bidi', 'unicode-tag']) {
|
|
const f = { class: 'unicode', subtype, severity: 'high' };
|
|
assert.equal(disposition(f, 'authored-doc'), 'block', `${subtype} in prose`);
|
|
assert.equal(disposition(f, 'code-sample'), 'block', `${subtype} in code`);
|
|
}
|
|
});
|
|
|
|
test('disposition — unicode homoglyph WARNs (may be legitimate multilingual text)', () => {
|
|
const f = { class: 'unicode', subtype: 'homoglyph', severity: 'medium' };
|
|
assert.equal(disposition(f, 'authored-doc'), 'warn');
|
|
assert.equal(disposition(f, 'code-sample'), 'warn');
|
|
});
|
|
|
|
test('disposition — encoded blob BLOCKs in low-trust (code/localized), WARNs in authored prose', () => {
|
|
const f = { class: 'encoded', severity: 'high' };
|
|
assert.equal(disposition(f, 'code-sample'), 'block');
|
|
assert.equal(disposition(f, 'localized'), 'block');
|
|
assert.equal(disposition(f, 'authored-doc'), 'warn');
|
|
});
|
|
|
|
test('disposition — critical injection BLOCKs regardless of tier', () => {
|
|
const f = { class: 'injection', severity: 'critical' };
|
|
assert.equal(disposition(f, 'authored-doc'), 'block');
|
|
assert.equal(disposition(f, 'code-sample'), 'block');
|
|
});
|
|
|
|
test('disposition — provenance tiering, both directions (brief §8): high injection blocks in code, warns in prose', () => {
|
|
const f = { class: 'injection', severity: 'high' };
|
|
assert.equal(disposition(f, 'code-sample'), 'block'); // low-trust surface → hard-fail
|
|
assert.equal(disposition(f, 'authored-doc'), 'warn'); // high-trust surface → flag, not silent block
|
|
});
|
|
|
|
test('disposition — medium/low injection WARNs (flag for human)', () => {
|
|
assert.equal(disposition({ class: 'injection', severity: 'medium' }, 'code-sample'), 'warn');
|
|
assert.equal(disposition({ class: 'injection', severity: 'low' }, 'authored-doc'), 'warn');
|
|
});
|
|
|
|
// --- Overall verdict --------------------------------------------------------
|
|
|
|
test('classifyFindings — clean content (no findings) → clean, findings empty', () => {
|
|
const r = classifyFindings(DOC, [], {});
|
|
assert.equal(r.disposition, 'clean');
|
|
assert.deepEqual(r.findings, []);
|
|
});
|
|
|
|
test('classifyFindings — a critical injection anywhere → block verdict', () => {
|
|
const raw = [{ class: 'injection', severity: 'critical', line: 5, evidence: 'spoofed tag: <system>' }];
|
|
const r = classifyFindings(DOC, raw, {});
|
|
assert.equal(r.disposition, 'block');
|
|
assert.equal(r.findings[0].disposition, 'block');
|
|
assert.equal(r.findings[0].tier, 'authored-doc');
|
|
});
|
|
|
|
test('classifyFindings — base64 on a code-block line → block (uses in-file Source header for tier)', () => {
|
|
const raw = [{ class: 'encoded', severity: 'high', line: 8, evidence: 'base64 blob' }];
|
|
const r = classifyFindings(DOC, raw, {});
|
|
assert.equal(r.disposition, 'block');
|
|
assert.equal(r.findings[0].tier, 'code-sample');
|
|
});
|
|
|
|
test('classifyFindings — a high injection only in authored prose → warn verdict (not block)', () => {
|
|
const raw = [{ class: 'injection', severity: 'high', line: 5, evidence: 'imperative to reader' }];
|
|
const r = classifyFindings(DOC, raw, {});
|
|
assert.equal(r.disposition, 'warn');
|
|
});
|
|
|
|
test('classifyFindings — block wins over warn when both present', () => {
|
|
const raw = [
|
|
{ class: 'injection', severity: 'high', line: 5, evidence: 'warn-level in prose' },
|
|
{ class: 'unicode', subtype: 'zero-width', severity: 'high', line: 10, evidence: 'U+200B' },
|
|
];
|
|
const r = classifyFindings(DOC, raw, {});
|
|
assert.equal(r.disposition, 'block');
|
|
});
|
|
|
|
test('classifyFindings — explicit sourceUrl opt overrides the in-file header for tiering', () => {
|
|
// Same prose finding, but caller declares a localized source → low trust → high injection blocks.
|
|
const raw = [{ class: 'injection', severity: 'high', line: 5, evidence: 'x' }];
|
|
const r = classifyFindings(DOC, raw, { sourceUrl: 'https://learn.microsoft.com/nb-no/azure/x' });
|
|
assert.equal(r.disposition, 'block');
|
|
assert.equal(r.findings[0].tier, 'localized');
|
|
});
|