diff --git a/browse/src/tunnel-denial-log.ts b/browse/src/tunnel-denial-log.ts new file mode 100644 index 00000000..26765940 --- /dev/null +++ b/browse/src/tunnel-denial-log.ts @@ -0,0 +1,94 @@ +/** + * Append-only log of tunnel-surface auth denials. + * + * Records every time a tunneled request is rejected by enforceTunnelPolicy + * (root token sent over tunnel, missing scoped token, disallowed command, etc). + * Gives operators visibility into who is actually probing their tunneled + * daemons so the next security wave can be driven by real attack data. + * + * Design notes: + * - Async via fs.promises.appendFile. NEVER appendFileSync — blocking the event + * loop on every denial during a flood is exactly what an attacker wants. + * (Prior learning: sync-audit-log-io, 10/10 confidence.) + * - Rate-capped at 60 writes/minute globally. Excess denials are counted in + * memory but not written to disk — prevents disk DoS. + * - Writes to ~/.gstack/security/attempts.jsonl, shared with the prompt-injection + * attempt log. File rotation is handled by the existing security pipeline. + */ +import { promises as fsp } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const LOG_DIR = path.join(os.homedir(), '.gstack', 'security'); +const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl'); +const RATE_CAP = 60; // writes per minute +const WINDOW_MS = 60_000; + +const writeTimestamps: number[] = []; +let droppedSinceLastWrite = 0; +let dirEnsured = false; + +async function ensureDir(): Promise { + if (dirEnsured) return; + try { + await fsp.mkdir(LOG_DIR, { recursive: true, mode: 0o700 }); + dirEnsured = true; + } catch { + // Swallow — log writes are best-effort. Failure to mkdir just means + // subsequent appends will also fail and be caught below. + } +} + +export interface TunnelDenialEntry { + reason: string; + path: string; + method: string; + sourceIp: string; +} + +export function logTunnelDenial(req: Request, url: URL, reason: string): void { + const now = Date.now(); + // Drop stale timestamps + while (writeTimestamps.length && writeTimestamps[0] < now - WINDOW_MS) { + writeTimestamps.shift(); + } + if (writeTimestamps.length >= RATE_CAP) { + droppedSinceLastWrite += 1; + return; + } + writeTimestamps.push(now); + + const sourceIp = + req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'; + + const entry: Record = { + ts: new Date(now).toISOString(), + kind: 'tunnel_auth_denial', + reason, + path: url.pathname, + method: req.method, + sourceIp, + }; + if (droppedSinceLastWrite > 0) { + entry.droppedSinceLastWrite = droppedSinceLastWrite; + droppedSinceLastWrite = 0; + } + + // Fire and forget. Never await, never block the request path. + void (async () => { + try { + await ensureDir(); + await fsp.appendFile(LOG_PATH, JSON.stringify(entry) + '\n'); + } catch { + // Swallow — log writes are best-effort. If disk is full or ACLs block + // us, we don't want to crash the server. + } + })(); +} + +// Test-only reset. Never called in production. +export function __resetTunnelDenialLog(): void { + writeTimestamps.length = 0; + droppedSinceLastWrite = 0; + dirEnsured = false; +}