ms-ai-architect/scripts/kb-update/lib/adversarial-scan.mjs

179 lines
7.8 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, allow: 0 };
/**
* Does an adjudicated allowlist entry cover this finding? (Enhet A2, brief §8.)
* CONTENT-based, not line-number-based: the entry pins the exact TRIMMED content of
* the adjudicated line, so a line that merely moves stays green while a line whose
* content changes RESURFACES as WARN/BLOCK (fail-safe — re-adjudication required).
* All four axes must match: class, evidence, provenance tier, trimmed line content.
* (Path scoping happens in the caller — scanPaths — which filters entries per file.)
* @param {{class: string, evidence: string}} finding
* @param {string} tier — the finding's computed provenance tier
* @param {string} trimmedLine — trimmed content of the finding's line
* @param {Array<object>} allow — allowlist entries scoped to this file
* @returns {boolean}
*/
export function isAllowlisted(finding, tier, trimmedLine, allow) {
return (allow ?? []).some(
(e) =>
e.class === finding.class &&
e.evidence === finding.evidence &&
e.tier === tier &&
typeof e.match === 'string' &&
e.match === trimmedLine,
);
}
/**
* 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, allow?: Array<object>}} [opts] — sourceUrl overrides the in-file
* **Source:** header; allow = adjudicated allowlist entries ALREADY scoped to this file
* @returns {{disposition: 'block'|'warn'|'clean', findings: Array<object>}}
*/
export function classifyFindings(content, rawFindings, opts = {}) {
const ranges = findFencedCodeRanges(content);
const lines = String(content ?? '').split('\n');
const sourceUrl = opts.sourceUrl ?? parseSourceHeader(content) ?? null;
const findings = (rawFindings ?? []).map((f) => {
const tier = provenanceTier({ line: f.line, ranges, sourceUrl });
const trimmedLine = (lines[f.line - 1] ?? '').trim();
const disp = isAllowlisted(f, tier, trimmedLine, opts.allow)
? 'allow'
: disposition(f, tier);
return { ...f, tier, disposition: disp };
});
let worst = 'clean';
for (const f of findings) {
if (RANK[f.disposition] > RANK[worst]) worst = f.disposition;
}
return { disposition: worst, findings };
}