/** Hidden-markdown scanner: HTML comments, link smuggling, code-block exclusion. */ import { mkFinding, type Finding, type Severity } from "./types.ts"; import { scanRules, scanExternalRefs } from "./rules.ts"; import { makePosIndex } from "./position.ts"; export type HiddenText = { text: string; line: number; column: number }; function blankKeepNewlines(s: string): string { return s.replace(/[^\n]/g, " "); } /** Blank html comments (keeping line structure) so visible text can be scanned. */ export function stripHtmlComments(raw: string): string { return raw.replace(/|$)/g, (m) => blankKeepNewlines(m)); } /** Blank fenced code blocks (keeping line structure) - anti-false-positive. */ export function stripCodeBlocks(raw: string): string { const lines = raw.split("\n"); let inFence = false; for (let i = 0; i < lines.length; i++) { if (/^(```|~~~)/.test(lines[i].trimStart())) { inFence = !inFence; lines[i] = blankKeepNewlines(lines[i]); } else if (inFence) { lines[i] = blankKeepNewlines(lines[i]); } } return lines.join("\n"); } /** Fenced code blocks (``` or ~~~) with content + 1-based start line. `closed` false = fence runs to EOF. */ export function findCodeBlocks( raw: string, ): Array { const out: Array = []; const lines = raw.split("\n"); let i = 0; while (i < lines.length) { const m = /^(```|~~~)/.exec(lines[i].trimStart()); if (!m) { i++; continue; } const ch = m[1][0]; const len = m[1].length; const content: string[] = []; let j = i + 1; let closed = false; while (j < lines.length) { const t = lines[j].trimStart(); if (t.startsWith(ch.repeat(len)) && /^[`~]*$/.test(t)) { closed = true; break; } content.push(lines[j]); j++; } out.push({ text: content.join("\n"), line: i + 1, column: 1, closed }); i = closed ? j + 1 : lines.length; } return out; } /** Html comments with their 1-based position. */ export function findHtmlComments(raw: string): HiddenText[] { const out: HiddenText[] = []; const pos = makePosIndex(raw); const re = /|$)/g; for (const m of raw.matchAll(re)) { const inner = m[0].slice( 4, m[0].endsWith("-->") ? m[0].length - 3 : m[0].length, ); const p = pos(m.index + 4); out.push({ text: inner, line: p.line, column: p.column }); } return out; } /** Markdown links (http/https targets) with their 1-based position. */ export function findMarkdownLinks( raw: string, ): Array { const out: Array = []; const pos = makePosIndex(raw); const re = /\[([^\]]{1,200})\]\((https?:\/\/[^)\s]{1,500})\)/g; for (const m of raw.matchAll(re)) { const p = pos(m.index); out.push({ text: m[1], url: m[2], line: p.line, column: p.column }); } return out; } const RANK: Record = { info: 0, low: 1, medium: 2, high: 3, critical: 4, }; function worstSeverity(fs: Finding[]): Severity { return fs.reduce( (w, f) => (RANK[f.severity] > RANK[w] ? f.severity : w), "info" as Severity, ); } /** Findings for instructions hidden in html comments or markdown link texts. */ export function scanMarkdown(raw: string): Finding[] { const findings: Finding[] = []; for (const c of findHtmlComments(raw)) { const hits = scanRules(c.text, { lineOffset: c.line - 1, columnOffset: c.column - 1, label: "html comment", }); if (hits.length > 0) { const worst = worstSeverity(hits); findings.push( mkFinding( "md-comment-instr", "prompt-injection", worst === "high" || worst === "critical" ? "high" : "medium", worst === "high" ? "high" : "medium", hits[0].evidence, { line: hits[0].line, column: hits[0].column }, ), ); } // F5: external references are analyzed inside comments too — a comment is // rendered nowhere, so URL+instructions there is pure hidden injection. for (const f of scanExternalRefs(c.text, { lineOffset: c.line - 1, label: "html comment", })) { findings.push(f); } } for (const l of findMarkdownLinks(raw)) { const hits = scanRules(l.text, { lineOffset: l.line - 1, columnOffset: l.column - 1, }); if (hits.length === 0) continue; const worst = worstSeverity(hits); findings.push( mkFinding( "md-link-instr", "prompt-injection", worst === "high" || worst === "critical" ? "high" : "medium", worst === "high" ? "high" : "medium", `link to ${l.url}: ${hits[0].evidence}`, { line: hits[0].line, column: hits[0].column }, ), ); } return findings; }