146 lines
6.7 KiB
JavaScript
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;
|
|
}
|