/** Recursive decoder for base64 / hex / URL-encoded blobs. Never mutates input. */ import { Buffer } from "node:buffer"; import { mkFinding, type Finding } from "./types.ts"; import { makePosIndex } from "./position.ts"; export type DecodedBlob = { from: string; text: string; line?: number; column?: number }; type RawBlob = DecodedBlob & { start: number; end: number }; const B64_RE = /[A-Za-z0-9+/]{16,}={0,2}/g; const HEX_ESC_RE = /(?:\\x[0-9a-fA-F]{2}){2,}/g; const HEX_STR_RE = /(? String.fromCharCode(parseInt(h, 16)), ); } function decodeUrl(candidate: string): string | null { try { return decodeURIComponent(candidate); } catch { return null; // malformed %XX sequence } } function analyzeBlob(b: RawBlob, findings: Finding[]): void { const kind = b.from .split(">") .pop()! .replace("url-encoding", "url") .replace("hex-escapes", "hex"); const excerpt = b.text.trim().replace(/\s+/g, " ").slice(0, 60); const evidence = `decoded ${b.from} (line ${b.line ?? "?"}): ${excerpt}`; if (EXEC_RE.test(b.text)) { findings.push( mkFinding(`obf-${kind}-cmd`, "obfuscation", "high", "high", evidence, { line: b.line, column: b.column, terminal: true, }), ); } else if (CMD_TOKEN_RE.test(b.text)) { findings.push( mkFinding(`obf-${kind}-cmd`, "obfuscation", "medium", "medium", evidence, { line: b.line, column: b.column, }), ); } else { findings.push( mkFinding(`obf-${kind}`, "obfuscation", "info", "medium", evidence, { line: b.line, column: b.column, }), ); } } function extractBlobs(raw: string): RawBlob[] { const pos = makePosIndex(raw); const candidates: RawBlob[] = []; const collect = (re: RegExp, from: string, decode: (s: string) => string | null) => { for (const m of raw.matchAll(re)) { const text = decode(m[0]); if (text === null) continue; const p = pos(m.index); candidates.push({ from, text, line: p.line, column: p.column, start: m.index, end: m.index + m[0].length, }); } }; collect(HEX_STR_RE, "hex", decodeHex); collect(B64_RE, "base64", decodeB64); collect(URL_RE, "url-encoding", decodeUrl); collect(HEX_ESC_RE, "hex-escapes", decodeHexEscapes); // Dedup overlapping spans: stable sort by start (ties keep collection order, // matching the old first-wins rule) then linear sweep — O(k log k) instead // of the old O(k^2) `taken.some` per candidate. candidates.sort((a, b) => a.start - b.start); const out: RawBlob[] = []; let prev: RawBlob | null = null; for (const b of candidates) { if (prev !== null && b.start < prev.end) continue; // overlaps a kept blob out.push(b); prev = b; } return out; } /** * Join base64 wrapped at line breaks (PEM style, 64 columns — what models * emit). Every line must be a real b64 fragment (>= 16 chars) so prose and * hex dumps are untouched. Applied at the top level only; positions inside * the joined regions are approximate (the joined text is shorter). */ function unwrapB64(text: string): string { return text.replace( /(?:^|\n)([A-Za-z0-9+/]{16,}={0,2}(?:\n[A-Za-z0-9+/]{16,}={0,2})+)(?=\n|$)/g, (m) => m.replace(/\n/g, ""), ); } /** * Find decodable blobs in raw, recursing into decoded text up to `depth`. * The original string is never modified; results are returned separately. * F9: depth 4 (was 2) so triple-encoded payloads reach their plaintext; * depth exhaustion is signalled (obf-depth-exhausted) instead of silent. */ export function findDecodedBlobs( raw: string, depth = 4, ): { decoded: DecodedBlob[]; findings: Finding[] } { const decoded: DecodedBlob[] = []; const findings: Finding[] = []; const walk = (text: string, level: number, parentFrom: string): void => { if (level <= 0) { // F9: still encodable at the depth limit — say so instead of letting // the payload degrade to info findings (or none). if (extractBlobs(text).length > 0) { findings.push( mkFinding( "obf-depth-exhausted", "obfuscation", "medium", "medium", "still-encoded content at decode depth limit", ), ); } return; } for (const b of extractBlobs(text)) { const from = parentFrom === "" ? b.from : `${parentFrom}>${b.from}`; decoded.push({ from, text: b.text, line: b.line, column: b.column }); analyzeBlob({ ...b, from }, findings); walk(b.text, level - 1, from); } }; walk(unwrapB64(raw), depth, ""); return { decoded, findings }; }