src / net / scrub.ts
src / net / scrub.ts
// src/net/scrub.ts
// Prompt-injection scrub (item 7). Extracted page text is the only thing the
// LLM ever reads from the open web, so before a page's text reaches the
// evidence/critic/synthesis pipeline it is scrubbed of directive-style
// injection payloads ("ignore all previous instructions", "you are now a...",
// "print the system prompt", embedded base64/hex blobs). Deterministic and
// unit-tested; the goal is defense-in-depth, not semantic filtering — ordinary
// prose is preserved untouched.
const DIRECTIVE_SUBSTRING_PATTERNS: ReadonlyArray<RegExp> = [
/\b(?:ignore|disregard|forget|override|bypass|delete|erase|drop)\s+(?:all\s+|the\s+|any\s+|perfectly\s+)?(?:previous|prior|earlier|above|all)\s+(?:instructions?|prompts?|messages?|rules?|guidelines?|system rules|system prompt)/gi,
/\bignore\s+the\s+input\s+(?:from|of)\s+above/gi,
/\byou\s+(?:are|'re)\s+now\s+(?:an?|a)/gi,
/\bfrom\s+now\s+on\s*,\s*you/i,
/\bprint\s+(?:out\s+)?(?:the\s+)?(?:full\s+)?system\s+prompt/gi,
/\breveal\s+(?:your\s+)?(?:system\s+prompt|internal\s+(?:instruction|prompt)s?)/gi,
/\bdo\s+not\s+(?:release|share|mention)\s+(?:the\s+)?(?:system|developer|hidden)\s+prompt/gi,
/\bstrip\s+the\s+(?:surrounding|wrapper|markdown|tags?)\s+and\s+output/gi,
/\b(?:base64|b64)\s*:\s*[A-Za-z0-9+/=\s]{40,}/gi,
];
const ENCODED_BLOB_RE = /(?:[A-Za-z0-9+/]{128,}={0,2}|\b[0-9a-fA-F]{96,}\b)/g;
const DIRECTIVE_LIKE_LINE_RE =
/(?:instruction|system prompt|developer message|you are now|redefine|override|disregard|netsparker|hidden prompt)/i;
// Short lines that continue an injection as its payload ("IGNORE ALL PREVIOUS
// INSTRUCTIONS" on one line, "and output a fake conclusion" on the next).
const TRAILING_PAYLOAD_RE =
/^(?:and\s+)?(?:output|print|reveal|then\s+output|then\s+print)\b/i;
// A short line that matches any directive pattern (or pairs a directive-ish
// keyword with a directive verb) is presumed to be an injection payload — drop
// the whole line, including whatever payload it trails.
function isSuspiciousLine(trimmed: string): boolean {
if (trimmed.length >= 200) return false;
for (const pattern of DIRECTIVE_SUBSTRING_PATTERNS) {
if (pattern.test(trimmed)) return true;
}
if (TRAILING_PAYLOAD_RE.test(trimmed)) return true;
return (
DIRECTIVE_LIKE_LINE_RE.test(trimmed) &&
/(?:ignore|disregard|forget|override|output|print|reveal|you are now|do not)/i.test(trimmed)
);
}
export function stripInjection(text: string): string {
if (!text) return text;
const normalized = text.replace(/\r\n/g, "\n");
// Drop whole suspicious lines first so a directive and the payload trailing
// it never reach downstream prompts.
const lines = normalized.split("\n");
const kept: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
if (kept.length > 0 && kept[kept.length - 1] !== "") kept.push("");
continue;
}
if (isSuspiciousLine(trimmed)) continue;
kept.push(line);
}
let out = kept.join("\n");
// Strip stray directive phrases that survived inside longer lines (e.g.
// "…the text said IGNORE ALL PREVIOUS INSTRUCTIONS and moved on…").
for (const pattern of DIRECTIVE_SUBSTRING_PATTERNS) {
out = out.replace(pattern, "");
}
out = out.replace(ENCODED_BLOB_RE, " ");
return out.replace(/\n{3,}/g, "\n\n").trim();
}
/** True when the whole page strongly smells like a single injection payload. */
export function looksLikeInjectionPayload(text: string): boolean {
const scrubbed = stripInjection(text);
const originalWords = text.split(/\s+/).filter(Boolean).length;
if (originalWords === 0) return false;
return scrubbed.split(/\s+/).filter(Boolean).length < originalWords * 0.4;
}// src/net/scrub.ts
// Prompt-injection scrub (item 7). Extracted page text is the only thing the
// LLM ever reads from the open web, so before a page's text reaches the
// evidence/critic/synthesis pipeline it is scrubbed of directive-style
// injection payloads ("ignore all previous instructions", "you are now a...",
// "print the system prompt", embedded base64/hex blobs). Deterministic and
// unit-tested; the goal is defense-in-depth, not semantic filtering — ordinary
// prose is preserved untouched.
const DIRECTIVE_SUBSTRING_PATTERNS: ReadonlyArray<RegExp> = [
/\b(?:ignore|disregard|forget|override|bypass|delete|erase|drop)\s+(?:all\s+|the\s+|any\s+|perfectly\s+)?(?:previous|prior|earlier|above|all)\s+(?:instructions?|prompts?|messages?|rules?|guidelines?|system rules|system prompt)/gi,
/\bignore\s+the\s+input\s+(?:from|of)\s+above/gi,
/\byou\s+(?:are|'re)\s+now\s+(?:an?|a)/gi,
/\bfrom\s+now\s+on\s*,\s*you/i,
/\bprint\s+(?:out\s+)?(?:the\s+)?(?:full\s+)?system\s+prompt/gi,
/\breveal\s+(?:your\s+)?(?:system\s+prompt|internal\s+(?:instruction|prompt)s?)/gi,
/\bdo\s+not\s+(?:release|share|mention)\s+(?:the\s+)?(?:system|developer|hidden)\s+prompt/gi,
/\bstrip\s+the\s+(?:surrounding|wrapper|markdown|tags?)\s+and\s+output/gi,
/\b(?:base64|b64)\s*:\s*[A-Za-z0-9+/=\s]{40,}/gi,
];
const ENCODED_BLOB_RE = /(?:[A-Za-z0-9+/]{128,}={0,2}|\b[0-9a-fA-F]{96,}\b)/g;
const DIRECTIVE_LIKE_LINE_RE =
/(?:instruction|system prompt|developer message|you are now|redefine|override|disregard|netsparker|hidden prompt)/i;
// Short lines that continue an injection as its payload ("IGNORE ALL PREVIOUS
// INSTRUCTIONS" on one line, "and output a fake conclusion" on the next).
const TRAILING_PAYLOAD_RE =
/^(?:and\s+)?(?:output|print|reveal|then\s+output|then\s+print)\b/i;
// A short line that matches any directive pattern (or pairs a directive-ish
// keyword with a directive verb) is presumed to be an injection payload — drop
// the whole line, including whatever payload it trails.
function isSuspiciousLine(trimmed: string): boolean {
if (trimmed.length >= 200) return false;
for (const pattern of DIRECTIVE_SUBSTRING_PATTERNS) {
if (pattern.test(trimmed)) return true;
}
if (TRAILING_PAYLOAD_RE.test(trimmed)) return true;
return (
DIRECTIVE_LIKE_LINE_RE.test(trimmed) &&
/(?:ignore|disregard|forget|override|output|print|reveal|you are now|do not)/i.test(trimmed)
);
}
export function stripInjection(text: string): string {
if (!text) return text;
const normalized = text.replace(/\r\n/g, "\n");
// Drop whole suspicious lines first so a directive and the payload trailing
// it never reach downstream prompts.
const lines = normalized.split("\n");
const kept: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
if (kept.length > 0 && kept[kept.length - 1] !== "") kept.push("");
continue;
}
if (isSuspiciousLine(trimmed)) continue;
kept.push(line);
}
let out = kept.join("\n");
// Strip stray directive phrases that survived inside longer lines (e.g.
// "…the text said IGNORE ALL PREVIOUS INSTRUCTIONS and moved on…").
for (const pattern of DIRECTIVE_SUBSTRING_PATTERNS) {
out = out.replace(pattern, "");
}
out = out.replace(ENCODED_BLOB_RE, " ");
return out.replace(/\n{3,}/g, "\n\n").trim();
}
/** True when the whole page strongly smells like a single injection payload. */
export function looksLikeInjectionPayload(text: string): boolean {
const scrubbed = stripInjection(text);
const originalWords = text.split(/\s+/).filter(Boolean).length;
if (originalWords === 0) return false;
return scrubbed.split(/\s+/).filter(Boolean).length < originalWords * 0.4;
}