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.
149 lines
6.4 KiB
JavaScript
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 };
|
|
}
|