179 lines
7.8 KiB
JavaScript
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 };
|
|
}
|