/** * Preflight scan for forbidden content in a static site about to go public. * Pure: no network access. */ import { isTextContentType, type SiteFile } from './static-site.ts' /** * A forbidden pattern. Strings shaped like `/source/flags` are regular * expressions (so JSON callers can pass regex); any other string is a literal, * case-sensitive substring. RegExp objects work from code. */ export type ForbidPattern = string | RegExp export type ScanInput = { /** Extra patterns that must not appear in any text file. */ forbid?: ForbidPattern[] /** * Include the built-in credential patterns ({@link DEFAULT_FORBID}). * Defaults to `true`; set `false` only when a known-safe file trips one. */ defaults?: boolean } export type ScanFinding = { path: string /** Pattern label (`id` for built-ins, the caller's string otherwise). */ pattern: string line: number /** Context with the matched text masked so secrets are not echoed back. */ preview: string } export type ScanResult = { ok: boolean scannedFiles: number skippedBinaryFiles: number patterns: string[] findings: ScanFinding[] } /** Built-in credential patterns checked on every publish unless `defaults: false`. */ export const DEFAULT_FORBID: ReadonlyArray<{ id: string; re: RegExp }> = [ { id: 'kody-secret-placeholder', re: /\{\{secret[:/][^}]*\}\}/ }, { id: 'kody-token', re: /\bkody_(?:at|bc)_[A-Za-z0-9_-]{12,}/ }, { id: 'private-key-block', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----/ }, { id: 'aws-access-key-id', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, { id: 'github-token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/ }, { id: 'slack-token', re: /\bxox[abposr]-[A-Za-z0-9-]{10,}/ }, { id: 'stripe-secret-key', re: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}/ }, { id: 'openai-or-anthropic-key', re: /\bsk-(?:proj-|ant-)[A-Za-z0-9_-]{20,}/ }, { id: 'cloudflare-artifacts-token', re: /\bart_v\d+_[A-Za-z0-9_]{20,}/ }, ] const REGEX_LITERAL = /^\/(.+)\/([dgimsuvy]*)$/s function compile(pattern: ForbidPattern, index: number): { label: string; re: RegExp } { if (pattern instanceof RegExp) { return { label: String(pattern), re: new RegExp(pattern.source, pattern.flags.replace('g', '')) } } if (typeof pattern !== 'string' || pattern === '') { throw new Error(`scan.forbid[${index}] must be a non-empty string or RegExp.`) } const literal = REGEX_LITERAL.exec(pattern) if (literal) { try { return { label: pattern, re: new RegExp(literal[1], literal[2].replace('g', '')) } } catch (error) { throw new Error(`scan.forbid[${index}] is not a valid regex: ${(error as Error).message}`) } } const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') return { label: pattern, re: new RegExp(escaped) } } function mask(match: string): string { if (match.length <= 6) return '*'.repeat(match.length) return match.slice(0, 3) + '*'.repeat(Math.min(match.length - 3, 12)) } function preview(text: string, index: number, match: string): string { const lineStart = text.lastIndexOf('\n', index - 1) + 1 const lineEndRaw = text.indexOf('\n', index + match.length) const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw const before = text.slice(Math.max(lineStart, index - 30), index) const after = text.slice(index + match.length, Math.min(lineEnd, index + match.length + 30)) return (before + mask(match) + after).replace(/\s+/g, ' ').trim() } /** Resolve scan options into labeled regexes (built-ins first). */ export function compileScanPatterns(scan: ScanInput = {}): Array<{ label: string; re: RegExp }> { if (scan.forbid !== undefined && !Array.isArray(scan.forbid)) { throw new Error('scan.forbid must be an array of strings or RegExp.') } const patterns = (scan.forbid ?? []).map(compile) if (scan.defaults !== false) { patterns.unshift(...DEFAULT_FORBID.map(({ id, re }) => ({ label: id, re }))) } return patterns } /** * Scan text files (by content type) for forbidden patterns. Binary files are * skipped and counted. Reports the first match per file per pattern. */ export function scanSiteFiles(files: SiteFile[], scan: ScanInput = {}): ScanResult { const patterns = compileScanPatterns(scan) const findings: ScanFinding[] = [] let scannedFiles = 0 let skippedBinaryFiles = 0 const decoder = new TextDecoder('utf-8', { fatal: false }) for (const file of files) { if (!isTextContentType(file.contentType)) { skippedBinaryFiles += 1 continue } scannedFiles += 1 const text = decoder.decode(file.bytes) for (const { label, re } of patterns) { const match = re.exec(text) if (!match) continue const line = text.slice(0, match.index).split('\n').length findings.push({ path: file.path, pattern: label, line, preview: preview(text, match.index, match[0]) }) } } return { ok: findings.length === 0, scannedFiles, skippedBinaryFiles, patterns: patterns.map((pattern) => pattern.label), findings, } } /** Human-readable refusal message for a failed scan. */ export function formatScanRefusal(result: ScanResult): string { const lines = result.findings .slice(0, 20) .map((finding) => `- ${finding.path}:${finding.line} matched ${finding.pattern} (${finding.preview})`) const more = result.findings.length > 20 ? `\n…and ${result.findings.length - 20} more.` : '' return ( `Refusing to publish: forbidden content found in ${result.findings.length} place(s).\n` + lines.join('\n') + more + '\nRemove the content (or narrow scan.forbid) and retry. Nothing was uploaded.' ) }