← Public packages
@kentcdodds/cloudflare
Cloudflare helpers: API v4, static site publishing to Pages, analytics, WAF rules, logs, and docs.
src/scan.ts
147 lines · 5.4 KB · TypeScript/**
* 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.'
)
}