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

146 lines
6.7 KiB
JavaScript

// adversarial-detect.mjs — the BRIDGE from Layer B to the shared llm-security detectors.
//
// This is the ONE place in ms-ai-architect that reaches into the sibling llm-security plugin.
// It imports the deterministic, ToS-safe (no-Claude) detectors and normalizes their output to a
// uniform raw-finding shape the pure disposition core (adversarial-scan.mjs) consumes:
//
// { class: 'injection'|'unicode'|'encoded', subtype?: string, severity: 'critical'|'high'|'medium'|'low',
// line: number, evidence: string }
//
// House policy (ingen lokale løsninger / no drifting lexicon copies): the injection PATTERN
// lexicon and the unicode charsets are NOT copied here — they are imported from llm-security so
// there is exactly one implementation and one dataset. The near-term consumption is in-process
// import (operator decision 2026-07-04); the target is extraction into a shared
// `llm-ingestion-pipeline-security` library that both this plugin and claude-code-llm-wiki depend
// on (brief §6, two-horizon).
//
// FAIL-CLOSED: if llm-security cannot be resolved/loaded, detectAdversarial THROWS — the caller
// (scan-adversarial-content.mjs) turns that into a BLOCK. A security gate must never silently pass
// when it cannot actually scan. Override the sibling location with env LLM_SECURITY_ROOT.
//
// kb-update / generate-skills are maintainer-side workflows (external users consume the KB, they
// do not regenerate it), so the sibling-path coupling is acceptable — the same maintainer-only
// pattern as scripts/kb-eval/score-skill.mjs.
import { fileURLToPath } from 'node:url';
import { dirname, resolve, basename, join } from 'node:path';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Marketplace layout: …/ms-ai-architect/scripts/kb-update/lib → …/llm-security */
function llmSecurityRoot() {
return process.env.LLM_SECURITY_ROOT || resolve(__dirname, '../../../../llm-security');
}
// Lazily loaded + cached llm-security modules (so import cost is paid once, and absence
// surfaces as a thrown error at scan time — fail closed — not at module load).
let _mods = null;
async function loadDetectors() {
if (_mods) return _mods;
const root = llmSecurityRoot();
try {
const [inj, stru, uni] = await Promise.all([
import(`${root}/scanners/lib/injection-patterns.mjs`),
import(`${root}/scanners/lib/string-utils.mjs`),
import(`${root}/scanners/unicode-scanner.mjs`),
]);
_mods = {
scanForInjection: inj.scanForInjection,
isBase64Like: stru.isBase64Like,
shannonEntropy: stru.shannonEntropy,
redact: stru.redact,
unicodeScan: uni.scan,
};
return _mods;
} catch (err) {
throw new Error(`LLM_SECURITY_UNAVAILABLE at ${root}: ${err.message}`);
}
}
/** Map an llm-security unicode-scanner finding title to our carrier subtype. */
function unicodeSubtype(title = '') {
if (/zero-width/i.test(title)) return 'zero-width';
if (/unicode tag/i.test(title)) return 'unicode-tag';
if (/bidi/i.test(title)) return 'bidi';
if (/homoglyph/i.test(title)) return 'homoglyph';
return 'other';
}
// Encoded-blob thresholds: long enough to hide an instruction payload, high-entropy enough to
// be an encoded blob rather than prose. Tuned so a smuggled base64 instruction (≥ ~30 bytes)
// trips while ordinary identifiers / short tokens in legitimate code samples do not.
const ENCODED_MIN_LEN = 32;
const ENCODED_MIN_ENTROPY = 4.0;
/**
* Detect adversarial content in a candidate KB file. Combines the shared llm-security detectors:
* - injection: per-line scanForInjection (the pattern lexicon) — gives severity + line.
* - encoded: per-line base64/hex-blob detection (isBase64Like + Shannon entropy).
* - unicode: unicode-scanner over the file (zero-width / bidi / unicode-tag / homoglyph),
* driven with a single-file discovery so no charset is re-derived here.
*
* @param {string} content — the candidate file content
* @param {{path?: string}} [opts] — optional on-disk path; when absent the unicode scan runs
* PRE-WRITE against a temp file materialized from `content` (the ingestion gate), so unicode
* coverage no longer depends on the file already existing on disk
* @returns {Promise<Array<{class: string, subtype?: string, severity: string, line: number, evidence: string}>>}
*/
export async function detectAdversarial(content, opts = {}) {
const { scanForInjection, isBase64Like, shannonEntropy, redact, unicodeScan } = await loadDetectors();
const findings = [];
const lines = String(content ?? '').split('\n');
// --- injection (content) + encoded (content), per line for precise line numbers ---
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNo = i + 1;
const inj = scanForInjection(line);
if (inj && inj.found) {
for (const p of inj.patterns || []) {
findings.push({ class: 'injection', severity: p.severity, line: lineNo, evidence: p.label });
}
}
for (const tok of line.split(/[\s"'`,:{}()[\]<>]+/)) {
if (tok.length >= ENCODED_MIN_LEN && isBase64Like(tok) && shannonEntropy(tok) >= ENCODED_MIN_ENTROPY) {
findings.push({ class: 'encoded', severity: 'high', line: lineNo, evidence: `base64-like blob: ${redact(tok)}` });
}
}
}
// --- unicode (disk-based scanner; needs a real path) ---
// The llm-security unicode scanner reads a file off disk. When a caller passes content
// WITHOUT a path — the pre-write ingestion gate: scan the COMPOSED artefact before it is
// ever written — materialize a temp file so the SAME scanner runs pre-write. This closes
// the ingestion-brief §8 acceptance test (zero-width/bidi rejected BEFORE write); the old
// `if (opts.path)` guard skipped unicode entirely on the content path, leaving pre-write
// carriers undetected. Fail-closed posture is unchanged — a scanner error still propagates
// as a throw → the caller BLOCKs.
let scanPath = opts.path;
let tmpDir = null;
try {
if (!scanPath) {
tmpDir = mkdtempSync(join(tmpdir(), 'kb-adv-'));
scanPath = join(tmpDir, 'candidate.md');
writeFileSync(scanPath, String(content ?? ''), 'utf8');
}
const discovery = { files: [{ absPath: resolve(scanPath), relPath: basename(scanPath) }] };
const res = await unicodeScan('.', discovery);
for (const f of res.findings || []) {
findings.push({
class: 'unicode',
subtype: unicodeSubtype(f.title),
severity: f.severity,
line: f.line || 0,
evidence: f.evidence || f.title || 'unicode anomaly',
});
}
} finally {
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
}
return findings;
}