|
| 1 | +/* |
| 2 | + * @file Detector for agent-directed instructions embedded in UNTRUSTED |
| 3 | + * content — an issue body, a PR/review comment, a fetched page, a vendored |
| 4 | + * file. Such text is DATA TO REPORT, never an instruction to follow, and |
| 5 | + * this module is how a guard or a report names what it found. |
| 6 | + * |
| 7 | + * The motivating shape is a "honeypot" comment: an ordinary-looking thank-you |
| 8 | + * posted on every new PR whose raw Markdown hides a block addressed only to |
| 9 | + * machines. The block asks the reader to post a short hex code back, and an |
| 10 | + * account whose own reply carries that code is labelled automated. The token |
| 11 | + * is `randomBytes(6).toString('hex')`, so it is exactly twelve hex |
| 12 | + * characters — `findHoneypotTokens` returns those. |
| 13 | + * |
| 14 | + * Detection is by SHAPE, not by a denylist of vendors. Four families: |
| 15 | + * |
| 16 | + * 1. An HTML comment whose contents address an automated reader — invisible |
| 17 | + * on the rendered page, fully visible to whatever reads the raw body. |
| 18 | + * 2. A directive to emit a verification / acknowledgement code, to reply with |
| 19 | + * "exactly the following", or to post a code with nothing else around it. |
| 20 | + * 3. A "human contributors may skip this" disclaimer, which is the tell that |
| 21 | + * the surrounding block was aimed only at machines. |
| 22 | + * 4. The one literal vendor marker worth keying on, since it names the |
| 23 | + * mechanism outright. |
| 24 | + * |
| 25 | + * Every scan runs over the raw text AND a `normalizeForScan` copy (invisible |
| 26 | + * characters stripped, Unicode Tag block dropped, homoglyphs folded), plus a |
| 27 | + * whole-text pass with newlines folded to spaces so a directive split across |
| 28 | + * lines is still caught. Same three-pass layering as prompt-injection-guard. |
| 29 | + */ |
| 30 | + |
| 31 | +import { normalizeForScan } from './evasion-normalize.mts' |
| 32 | + |
| 33 | +/** |
| 34 | + * One embedded-directive hit: what shape matched, the 1-based line it was found |
| 35 | + * on, and a clipped copy of the offending text. |
| 36 | + */ |
| 37 | +export interface UntrustedFinding { |
| 38 | + readonly label: string |
| 39 | + readonly line: number |
| 40 | + readonly excerpt: string |
| 41 | +} |
| 42 | + |
| 43 | +interface DirectivePattern { |
| 44 | + readonly label: string |
| 45 | + readonly re: RegExp |
| 46 | + // False → skip the whitespace-folded whole-text pass. That pass turns |
| 47 | + // newlines into spaces, which disables the `[^.\n]` proximity brake the |
| 48 | + // looser patterns rely on, so two innocent adjacent lines could read as one |
| 49 | + // sentence. Those patterns stay per-line. |
| 50 | + readonly wholeText?: boolean | undefined |
| 51 | +} |
| 52 | + |
| 53 | +// Cap the bytes scanned so a multi-megabyte fetched page cannot wedge a hook. |
| 54 | +// A real directive lands near the top of the body that carries it. Matches |
| 55 | +// prompt-injection-guard's own cap. |
| 56 | +const MAX_SCAN_BYTES = 512 * 1024 |
| 57 | + |
| 58 | +const DIRECTIVE_PATTERNS: readonly DirectivePattern[] = [ |
| 59 | + { |
| 60 | + label: 'HTML comment addressed to an automated reader', |
| 61 | + // `<!--` then, within 400 characters, a phrase that speaks to a machine |
| 62 | + // rather than to the person reading the rendered page. The lazy |
| 63 | + // `[\s\S]{0,400}?` lets the phrase sit anywhere in a multi-line comment. |
| 64 | + re: /<!--[\s\S]{0,400}?(?:automated\s+contributors?|if\s+you(?:'re|\s+are)\s+(?:an?\s+)?(?:a\.?i\.?|agent|automated|language\s+model|llms?)|llms?\s+reading\s+this|message_for_llms|notice\s+for\s+automated\s+(?:agents?|contributors?))/i, |
| 65 | + }, |
| 66 | + { |
| 67 | + label: 'notice addressed to automated readers', |
| 68 | + // The same machine-addressed phrases standing on their own, outside any |
| 69 | + // HTML comment — a plain-text block, a fenced snippet, a fetched page. |
| 70 | + re: /message_for_llms|\bnotice\s+for\s+automated\s+(?:agents?|contributors?)\b|\bautomated\s+contributors?\b|\bllms?\s+reading\s+this\b|\bif\s+you(?:'re|\s+are)\s+an?\s+ai\s+agent\b/i, |
| 71 | + }, |
| 72 | + { |
| 73 | + label: 'directive to emit a verification code', |
| 74 | + // An emit-style verb, then within 96 characters a |
| 75 | + // verification/confirmation/acknowledgement code. The `[^\n]` window keeps |
| 76 | + // the two halves in the same neighbourhood. |
| 77 | + re: /\b(?:emit|include|paste|post|repeat|reply|respond|return|send|write)\b[^\n]{0,96}\b(?:acknowledge?ment|confirmation|validation|verification)\s+code\b/i, |
| 78 | + }, |
| 79 | + { |
| 80 | + label: 'directive to reply with an exact literal', |
| 81 | + // "must consist of exactly the following", "reply with exactly the text |
| 82 | + // below" — an instruction to echo a literal verbatim. |
| 83 | + re: /\b(?:be|consist\s+of|contain|reply\s+with|respond\s+with)\s+(?:only\s+)?exactly\s+the\s+(?:code|following|string|text)\b/i, |
| 84 | + }, |
| 85 | + { |
| 86 | + label: 'directive to post a code and nothing else', |
| 87 | + // A code/token noun within 40 characters of "and nothing else" (either |
| 88 | + // order) — the bait's signature demand that the reply carry the code alone. |
| 89 | + re: /\b(?:code|string|token|value|word)\b[^.\n]{0,40}\band\s+nothing\s+else\b|\band\s+nothing\s+else\b[^.\n]{0,40}\b(?:code|string|token|value|word)\b/i, |
| 90 | + wholeText: false, |
| 91 | + }, |
| 92 | + { |
| 93 | + label: 'disclaimer marking the block as machine-only', |
| 94 | + // A line excusing people from following the block it sits in. A block that |
| 95 | + // waves off human readers was written for machines. |
| 96 | + re: /\bhuman\s+(?:contributors?|maintainers?|readers?|reviewers?|users?)\b[^.\n]{0,80}\b(?:disregard|do(?:es)?\s+not\s+apply|ignore|skip)\b/i, |
| 97 | + wholeText: false, |
| 98 | + }, |
| 99 | + { |
| 100 | + label: 'honeypot marker literal', |
| 101 | + // The vendor marker that names the mechanism outright. |
| 102 | + re: /agentscan-honeypot/i, |
| 103 | + }, |
| 104 | +] |
| 105 | + |
| 106 | +// A standalone twelve-hex-character run — the exact shape of a honeypot token |
| 107 | +// (`randomBytes(6).toString('hex')`). The word boundaries keep it from firing |
| 108 | +// inside a longer hex run such as a full 40-character SHA. |
| 109 | +const HONEYPOT_TOKEN_RE = /\b[0-9a-f]{12}\b/g |
| 110 | + |
| 111 | +// A canonical 8-4-4-4-12 UUID. Its last group is twelve hex characters between |
| 112 | +// word boundaries, so a UUID pasted into an ordinary comment would otherwise |
| 113 | +// read as a token. Blanked out before the token scan. |
| 114 | +const UUID_RE = |
| 115 | + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi |
| 116 | + |
| 117 | +// Every HTML comment, opening through closing delimiter. The threat matcher |
| 118 | +// (agentscan's hasHoneypotToken) strips comments before testing for the |
| 119 | +// token, so a token split by one — `a1b2c3<!-- x -->d4e5f6` — still reads as |
| 120 | +// one standalone run to the trap even though it never does to a scan that |
| 121 | +// only reads the raw or normalized text. |
| 122 | +const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g |
| 123 | + |
| 124 | +function clipExcerpt(text: string): string { |
| 125 | + const trimmed = text.trim() |
| 126 | + return trimmed.length > 160 ? `${trimmed.slice(0, 157)}...` : trimmed |
| 127 | +} |
| 128 | + |
| 129 | +// Best-effort 1-based line of `fragment`'s first word within `text`. |
| 130 | +function lineOfFragment(text: string, fragment: string): number { |
| 131 | + const firstWord = fragment.trim().split(/\s+/)[0] |
| 132 | + if (!firstWord) { |
| 133 | + return 1 |
| 134 | + } |
| 135 | + const idx = text.toLowerCase().indexOf(firstWord.toLowerCase()) |
| 136 | + if (idx < 0) { |
| 137 | + return 1 |
| 138 | + } |
| 139 | + return text.slice(0, idx).split('\n').length |
| 140 | +} |
| 141 | + |
| 142 | +function matchedLabels(text: string): string[] { |
| 143 | + const out: string[] = [] |
| 144 | + for (let i = 0, { length } = DIRECTIVE_PATTERNS; i < length; i += 1) { |
| 145 | + const pattern = DIRECTIVE_PATTERNS[i]! |
| 146 | + if (pattern.re.test(text)) { |
| 147 | + out.push(pattern.label) |
| 148 | + } |
| 149 | + } |
| 150 | + return out |
| 151 | +} |
| 152 | + |
| 153 | +/** |
| 154 | + * Every agent-directed instruction embedded in `text`, deduplicated by |
| 155 | + * label + line + excerpt. Empty when the text carries none. |
| 156 | + * |
| 157 | + * Three complementary passes, matching prompt-injection-guard: per line on the |
| 158 | + * raw text, per line on a normalized copy, and once over the whole normalized |
| 159 | + * text with runs of whitespace folded to a single space so a directive broken |
| 160 | + * across lines still reads as one sentence. |
| 161 | + */ |
| 162 | +export function findEmbeddedAgentDirectives(text: string): UntrustedFinding[] { |
| 163 | + const scanned = |
| 164 | + text.length > MAX_SCAN_BYTES ? text.slice(0, MAX_SCAN_BYTES) : text |
| 165 | + const findings: UntrustedFinding[] = [] |
| 166 | + const seen = new Set<string>() |
| 167 | + const push = (finding: UntrustedFinding): void => { |
| 168 | + const key = `${finding.label}:${finding.line}:${finding.excerpt}` |
| 169 | + if (!seen.has(key)) { |
| 170 | + seen.add(key) |
| 171 | + findings.push(finding) |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + const lines = scanned.split('\n') |
| 176 | + for (let i = 0, { length } = lines; i < length; i += 1) { |
| 177 | + const raw = lines[i] ?? '' |
| 178 | + const labels = new Set([ |
| 179 | + ...matchedLabels(raw), |
| 180 | + ...matchedLabels(normalizeForScan(raw)), |
| 181 | + ]) |
| 182 | + for (const label of labels) { |
| 183 | + push({ excerpt: clipExcerpt(raw), label, line: i + 1 }) |
| 184 | + } |
| 185 | + } |
| 186 | + |
| 187 | + // An HTML comment spanning several lines only reads as one block here, so the |
| 188 | + // folded pass is where a multi-line bait block is actually caught. |
| 189 | + const folded = normalizeForScan(scanned).replace(/\s+/g, ' ') |
| 190 | + for (let i = 0, { length } = DIRECTIVE_PATTERNS; i < length; i += 1) { |
| 191 | + const pattern = DIRECTIVE_PATTERNS[i]! |
| 192 | + if (pattern.wholeText === false) { |
| 193 | + continue |
| 194 | + } |
| 195 | + const match = pattern.re.exec(folded) |
| 196 | + if (match) { |
| 197 | + push({ |
| 198 | + excerpt: clipExcerpt(match[0]), |
| 199 | + label: `${pattern.label} [multi-line]`, |
| 200 | + line: lineOfFragment(scanned, match[0]), |
| 201 | + }) |
| 202 | + } |
| 203 | + } |
| 204 | + |
| 205 | + return findings |
| 206 | +} |
| 207 | + |
| 208 | +/** |
| 209 | + * Every standalone twelve-hex-character token in `text`, in first-seen order |
| 210 | + * and deduplicated. That is the honeypot token shape; a caller decides which of |
| 211 | + * them are legitimate (an abbreviated commit SHA resolves against the repo, a |
| 212 | + * bait token does not). |
| 213 | + * |
| 214 | + * A UUID's final group is also twelve hex characters, so UUIDs are blanked out |
| 215 | + * first — pasting one into a comment is ordinary, not bait. |
| 216 | + * |
| 217 | + * Scans the raw text, a `normalizeForScan` copy, AND a comment-stripped copy |
| 218 | + * of each — mirroring the upstream matcher's own comment-stripping pass, so a |
| 219 | + * token split across an HTML comment boundary is still caught. |
| 220 | + */ |
| 221 | +export function findHoneypotTokens(text: string): string[] { |
| 222 | + const out: string[] = [] |
| 223 | + const seen = new Set<string>() |
| 224 | + const base = [text, normalizeForScan(text)] |
| 225 | + const variants = [...base, ...base.map(v => v.replace(HTML_COMMENT_RE, ''))] |
| 226 | + for (let i = 0, { length } = variants; i < length; i += 1) { |
| 227 | + const raw = variants[i]! |
| 228 | + const source = raw.replace(UUID_RE, ' ') |
| 229 | + HONEYPOT_TOKEN_RE.lastIndex = 0 |
| 230 | + let match = HONEYPOT_TOKEN_RE.exec(source) |
| 231 | + while (match) { |
| 232 | + const token = match[0].toLowerCase() |
| 233 | + if (!seen.has(token)) { |
| 234 | + seen.add(token) |
| 235 | + out.push(token) |
| 236 | + } |
| 237 | + match = HONEYPOT_TOKEN_RE.exec(source) |
| 238 | + } |
| 239 | + } |
| 240 | + return out |
| 241 | +} |
0 commit comments