// 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 , 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} rawFindings — [{class, subtype?, severity, line, evidence}] * @param {{sourceUrl?: string}} [opts] — sourceUrl overrides the in-file **Source:** header * @returns {{disposition: 'block'|'warn'|'clean', findings: Array}} */ 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 }; }