clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

feat/site-feedback
nzip / worker / src / observability.ts
observability.ts 7.4K
1import type { Env } from "./env.ts";23const ADDRESS_PATH = /^\/([0-9a-f]{4})(\/.*)?$/;4const EXACT_ADDRESS_PATH = /^\/([0-9a-f]{4})\/?$/;5const UNLOCK_PATH = /^\/([0-9a-f]{4})\/__unlock$/;6const KNOWN_SERVICE_PATHS = new Set(["/", "/favicon.ico", "/robots.txt"]);7const MAX_LOGGED_PATH_LENGTH = 256;8const SCANNER_SAMPLE_RATE = 0.01;9const SAMPLE_SPACE = 0x1_0000;10const SAMPLE_CUTOFF = Math.floor(SAMPLE_SPACE * SCANNER_SAMPLE_RATE);1112type RequestClass = "address" | "address_asset" | "unlock" | "api" | "invalid";1314export interface SecurityRequestEvent {15 event: "security.request";16 sample_rate: number;17 path_class: RequestClass;18 path: string;19 path_truncated?: true;20 method: string;21 status: number;22 result: string;23 address?: string;24 vault_slot?: number;25 site_slot?: number;26}2728function resultForStatus(status: number): string {29 if (status === 401) return "unauthorized";30 if (status === 404) return "not_found";31 if (status === 405) return "method_not_allowed";32 if (status === 410) return "gone";33 if (status === 429) return "rate_limited";34 if (status >= 500) return "server_error";35 if (status >= 400) return "client_error";36 if (status >= 300) return "redirect";37 return "served";38}3940/** Build a bounded, low-cardinality event for requests useful in scan analysis. */41export function buildSecurityRequestEvent(42 req: Request,43 url: URL,44 response: Response,45 siteAddress: string | null = null,46): SecurityRequestEvent | null {47 const path = url.pathname;48 const unlock = path.match(UNLOCK_PATH);49 const exactAddress = path.match(EXACT_ADDRESS_PATH);50 const addressPath = path.match(ADDRESS_PATH);5152 let pathClass: RequestClass;53 if (siteAddress !== null) {54 if (path === "/__unlock") {55 pathClass = "unlock";56 } else if (path === "/") {57 pathClass = "address";58 } else {59 if (response.status < 400) return null;60 pathClass = "address_asset";61 }62 } else if (path === "/api" || path.startsWith("/api/")) {63 if (response.status < 400) return null;64 pathClass = "api";65 } else if (unlock) {66 pathClass = "unlock";67 } else if (exactAddress) {68 // Bare address hits are the enumeration surface, whether they hit or miss.69 pathClass = "address";70 } else if (addressPath) {71 if (response.status < 400) return null;72 pathClass = "address_asset";73 } else {74 if (response.status < 400 || KNOWN_SERVICE_PATHS.has(path)) return null;75 pathClass = "invalid";76 }7778 const address = siteAddress ?? (unlock ?? exactAddress ?? addressPath)?.[1];79 const loggedPath = path.slice(0, MAX_LOGGED_PATH_LENGTH);80 return {81 event: "security.request",82 sample_rate: SCANNER_SAMPLE_RATE,83 path_class: pathClass,84 path: loggedPath,85 ...(loggedPath.length < path.length86 ? { path_truncated: true as const }87 : {}),88 method: req.method,89 status: response.status,90 result: resultForStatus(response.status),91 ...(address92 ? {93 address,94 vault_slot: Number.parseInt(address[0], 16),95 site_slot: Number.parseInt(address.slice(1), 16),96 }97 : {}),98 };99}100101export async function scannerDigest(102 ip: string,103 secret: string,104): Promise<Uint8Array> {105 const encoder = new TextEncoder();106 const key = await crypto.subtle.importKey(107 "raw",108 encoder.encode(secret),109 { name: "HMAC", hash: "SHA-256" },110 false,111 ["sign"],112 );113 return new Uint8Array(114 await crypto.subtle.sign("HMAC", key, encoder.encode(ip)),115 );116}117118export function hex(bytes: Uint8Array): string {119 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(120 "",121 );122}123124/** Persist a bounded, privacy-preserving record for five-minute alert evaluation. */125export async function recordEnumerationProbe(126 req: Request,127 env: Env,128 url: URL,129 response: Response,130 siteAddress: string | null = null,131): Promise<void> {132 const match = url.pathname.match(EXACT_ADDRESS_PATH);133 const addressString = siteAddress !== null && url.pathname === "/"134 ? siteAddress135 : match?.[1];136 if (!addressString) return;137138 try {139 const ip = req.headers.get("cf-connecting-ip") ?? "local";140 const digest = await scannerDigest(ip, env.NZIP_TOKEN);141 const scannerId = hex(digest.slice(0, 8));142 // A rejected enumeration request still carries a useful confirmation that143 // the edge limiter fired, but it must not bypass the persistence budget.144 // Keep confirmations on a separate quota so a scanner cannot turn an145 // unlimited stream of 429 responses into unlimited D1 writes.146 const persistenceLimiter = response.status === 429147 ? env.RL_SIGNAL148 : env.RL_OBSERVE;149 const { success } = await persistenceLimiter.limit({ key: scannerId });150 if (!success) return;151152 const now = Math.floor(Date.now() / 1000);153 const bucket = now - (now % 300);154 const address = Number.parseInt(addressString, 16);155 const cf = req.cf;156 await env.DB.prepare(157 `INSERT OR IGNORE INTO security_probes158 (bucket, scanner_id, address, vault_slot, is_live, country, asn)159 VALUES (?, ?, ?, ?, ?, ?, ?)`,160 ).bind(161 bucket,162 scannerId,163 address,164 address >>> 12,165 response.status < 400 ? 1 : 0,166 cf?.country ?? null,167 typeof cf?.asn === "number" ? cf.asn : null,168 ).run();169170 if (response.status === 429) {171 await env.DB.prepare(172 `INSERT OR IGNORE INTO security_signals (bucket, scanner_id, kind)173 VALUES (?, ?, 'rate_limited')`,174 ).bind(bucket, scannerId).run();175 }176 } catch (error) {177 console.error({178 event: "security.recording_failed",179 error: error instanceof Error ? error.message : String(error),180 });181 }182}183184/**185 * Retain complete sequences for a deterministic ~1% sample of scanner identities.186 * This is more useful for enumeration detection, and much cheaper, than sampling187 * individual requests independently. The raw client IP is never logged.188 */189export async function logSecurityRequest(190 req: Request,191 env: Env,192 url: URL,193 response: Response,194 siteAddress: string | null = null,195): Promise<void> {196 const event = buildSecurityRequestEvent(req, url, response, siteAddress);197 if (!event) return;198199 try {200 const ip = req.headers.get("cf-connecting-ip") ?? "local";201 const digest = await scannerDigest(ip, env.NZIP_TOKEN);202 const bucket = (digest[0] << 8) | digest[1];203 if (bucket >= SAMPLE_CUTOFF) return;204205 // Once a limiter has rejected a client, a flood can continue without bound.206 // Sample those repeated 429s again by request while retaining all allowed207 // probes from the selected scanner identity.208 let effectiveSampleRate = SCANNER_SAMPLE_RATE;209 if (response.status === 429) {210 const requestIdentity = req.headers.get("cf-ray") ?? crypto.randomUUID();211 const requestDigest = await scannerDigest(212 `${ip}:${requestIdentity}`,213 env.NZIP_TOKEN,214 );215 const requestBucket = (requestDigest[0] << 8) | requestDigest[1];216 if (requestBucket >= SAMPLE_CUTOFF) return;217 effectiveSampleRate *= SCANNER_SAMPLE_RATE;218 }219220 const cf = req.cf;221 console.warn({222 ...event,223 sample_rate: effectiveSampleRate,224 scanner_id: hex(digest.slice(0, 8)),225 ...(cf?.country ? { country: cf.country } : {}),226 ...(cf?.colo ? { colo: cf.colo } : {}),227 ...(typeof cf?.asn === "number" ? { asn: cf.asn } : {}),228 });229 } catch (error) {230 console.error({231 event: "security.logging_failed",232 error: error instanceof Error ? error.message : String(error),233 });234 }235}