/** 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;
}