Skip to content

src/scan.ts

147 lines · 5.4 KB · TypeScript
Raw
/**
 * 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.'
	)
}