ms-ai-architect/tests/kb-update/test-adversarial-scan.test.mjs
Kjell Tore Guttormsen 89dcd80cbc feat(ms-ai-architect): Layer B ingestion-gate — deterministisk adversariell-innhold-skann før skriving/commit (G6 §8 / R6 punkt d, TDD) [skip-docs]
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.
2026-07-04 07:13:43 +02:00

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