ms-ai-architect/scripts/kb-update/lib/adversarial-scan.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

149 lines
6.4 KiB
JavaScript

// adversarial-scan.mjs — Layer B (G6 §8 / R6 punkt d): the PURE disposition core of
// the ingestion security gate. It is the ms-ai-architect-specific brain that decides,
// for each raw adversarial-content finding, whether it BLOCKs the write (hard-fail,
// never committed), WARNs (flag → same human-in-loop as a status-claim flag), or passes.
//
// Design:
// - Detection (the injection lexicon, unicode charsets, base64/entropy) is the SHARED
// llm-security asset — imported by the bridge (adversarial-detect.mjs), never copied
// here (house policy: no drifting lexicon copies). This module receives already-detected
// raw findings and is therefore pure + sync + trivially unit-testable.
// - Disposition is PROVENANCE-TIERED (brief §5): source trust is a first-class input.
// A payload in a low-trust surface (fenced code sample, localized string) is far more
// likely a real attack → hard-fail; a payload-looking string in authored, en-locale
// prose is more likely a legitimate doc artifact → WARN + human review, not a silent block.
// - Orthogonal to the correctness judge: a factually-correct file that carries a payload
// is still blocked. This gate answers "is this trying to inject / smuggle?", not "is
// this claim true?".
//
// Never writes. Mirrors verify-out.mjs / transform.mjs: a pure classifier.
import { parseSourceHeader } from './kb-headers.mjs';
const FENCE_RE = /^\s*(```|~~~)/;
/**
* Line spans (1-indexed, inclusive of both fence lines) of every fenced code block.
* An unterminated fence treats the remainder of the file as code (fail-safe: we would
* rather over-classify a region as low-trust code than let a smuggled payload ride in
* an "open" fence and be treated as authored prose).
* @param {string} content
* @returns {Array<[number, number]>}
*/
export function findFencedCodeRanges(content) {
const lines = String(content ?? '').split('\n');
const ranges = [];
let open = null;
for (let i = 0; i < lines.length; i++) {
if (FENCE_RE.test(lines[i])) {
if (open === null) open = i + 1;
else {
ranges.push([open, i + 1]);
open = null;
}
}
}
if (open !== null) ranges.push([open, lines.length]);
return ranges;
}
/** Is a 1-indexed line inside any fenced code range? */
export function lineInCode(line, ranges) {
return (ranges ?? []).some(([s, e]) => line >= s && line <= e);
}
/**
* The Microsoft Learn locale segment of a Source URL, lowercased, or null.
* e.g. https://learn.microsoft.com/nb-no/azure/x → "nb-no".
* @param {string|null} sourceUrl
* @returns {string|null}
*/
export function localeFromSource(sourceUrl) {
if (!sourceUrl) return null;
const m = String(sourceUrl).match(/learn\.microsoft\.com\/([a-z]{2}(?:-[a-z]{2,4})?)\//i);
return m ? m[1].toLowerCase() : null;
}
/**
* Provenance trust tier for a finding's line. Low-trust surfaces (adversary-reachable):
* fenced code samples and localized (non-English) strings — the surfaces the threat
* model (brief §3) calls community-contributable / machine-ingested. High-trust:
* authored, English-locale prose.
* @param {{line: number, ranges: Array<[number,number]>, sourceUrl: string|null}} args
* @returns {'code-sample'|'localized'|'authored-doc'}
*/
export function provenanceTier({ line, ranges, sourceUrl }) {
if (lineInCode(line, ranges)) return 'code-sample';
const loc = localeFromSource(sourceUrl);
if (loc && !loc.startsWith('en')) return 'localized';
return 'authored-doc';
}
/** Low-trust tiers are the adversary-reachable surfaces. */
export function isLowTrust(tier) {
return tier === 'code-sample' || tier === 'localized';
}
/** Invisible-carrier unicode subtypes — never legitimate in a KB reference file. */
const CARRIER_SUBTYPES = new Set(['zero-width', 'bidi', 'unicode-tag']);
/**
* Disposition for a single finding given its provenance tier.
* block → hard-fail, never written / never committed.
* warn → flag for human review (same human-in-loop as a status-claim flag).
* pass → benign.
*
* Rationale (brief §5, §8):
* - Invisible unicode carriers (zero-width / bidi / unicode-tag) have NO legitimate
* reason to appear in authored Microsoft Learn content → block in any tier.
* - Encoded blobs (base64/hex): block on a low-trust surface (the "base64 inside a
* code sample" vector); WARN if they surface in authored prose (rarer, likelier FP).
* - Injection patterns are provenance-tiered: critical (spoofed <system>, override+
* identity) is unambiguous → block anywhere; high blocks on a low-trust surface but
* WARNs in authored prose (could be a doc literally discussing the pattern);
* medium/low → WARN.
* @param {{class: string, subtype?: string, severity: string}} finding
* @param {string} tier
* @returns {'block'|'warn'|'pass'}
*/
export function disposition(finding, tier) {
const cls = finding.class;
const sev = finding.severity;
if (cls === 'unicode') {
return CARRIER_SUBTYPES.has(finding.subtype) ? 'block' : 'warn';
}
if (cls === 'encoded') {
return isLowTrust(tier) ? 'block' : 'warn';
}
if (cls === 'injection') {
if (sev === 'critical') return 'block';
if (sev === 'high') return isLowTrust(tier) ? 'block' : 'warn';
return 'warn';
}
// Unknown finding classes (e.g. read/scanner errors surfaced as findings) → block:
// fail closed, never let an unclassifiable signal pass silently.
return 'block';
}
const RANK = { block: 2, warn: 1, clean: 0 };
/**
* Classify a batch of raw findings against the file content. Pure + sync.
* @param {string} content — the full candidate file content (for code-fence + Source tiering)
* @param {Array<object>} rawFindings — [{class, subtype?, severity, line, evidence}]
* @param {{sourceUrl?: string}} [opts] — sourceUrl overrides the in-file **Source:** header
* @returns {{disposition: 'block'|'warn'|'clean', findings: Array<object>}}
*/
export function classifyFindings(content, rawFindings, opts = {}) {
const ranges = findFencedCodeRanges(content);
const sourceUrl = opts.sourceUrl ?? parseSourceHeader(content) ?? null;
const findings = (rawFindings ?? []).map((f) => {
const tier = provenanceTier({ line: f.line, ranges, sourceUrl });
return { ...f, tier, disposition: disposition(f, tier) };
});
let worst = 'clean';
for (const f of findings) {
if (RANK[f.disposition] > RANK[worst]) worst = f.disposition;
}
return { disposition: worst, findings };
}