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.
126 lines
5.6 KiB
JavaScript
126 lines
5.6 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 } from 'node:path';
|
|
|
|
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] — path is required for unicode detection (the scanner reads it)
|
|
* @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) ---
|
|
if (opts.path) {
|
|
const discovery = { files: [{ absPath: resolve(opts.path), relPath: basename(opts.path) }] };
|
|
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',
|
|
});
|
|
}
|
|
}
|
|
|
|
return findings;
|
|
}
|