tunnel-denial-log.ts
97 lines · browse/src/tunnel-denial-log.ts at 3f79d81
1/**
2 * Append-only log of tunnel-surface auth denials.
3 *
4 * Records every time a tunneled request is rejected by enforceTunnelPolicy
5 * (root token sent over tunnel, missing scoped token, disallowed command, etc).
6 * Gives operators visibility into who is actually probing their tunneled
7 * daemons so the next security wave can be driven by real attack data.
8 *
9 * Design notes:
10 * - Async via fs.promises.appendFile. NEVER appendFileSync — blocking the event
11 * loop on every denial during a flood is exactly what an attacker wants.
12 * (Prior learning: sync-audit-log-io, 10/10 confidence.)
13 * - Rate-capped at 60 writes/minute globally. Excess denials are counted in
14 * memory but not written to disk — prevents disk DoS.
15 * - Writes to ~/.gstack/security/attempts.jsonl, shared with the prompt-injection
16 * attempt log. File rotation is handled by the existing security pipeline.
17 */
18import { promises as fsp } from 'fs';
19import * as path from 'path';
20import { mkdirSecure } from './file-permissions';
21import { resolveStateRoot } from '../../lib/state-root';
23const RATE_CAP = 60; // writes per minute
24const WINDOW_MS = 60_000;
26const writeTimestamps: number[] = [];
27let droppedSinceLastWrite = 0;
28let ensuredDir: string | null = null;
30async function ensureDir(dir: string): Promise<void> {
31 if (ensuredDir === dir) return;
32 try {
33 // Sync mkdir is fine here — runs once per process at first denial. The
34 // (OI)(CI) inheritance set on Windows means subsequent fsp.appendFile
35 // writes pick up the owner-only ACL automatically.
36 mkdirSecure(dir);
37 ensuredDir = dir;
38 } catch {
39 // Swallow — log writes are best-effort. Failure to mkdir just means
40 // subsequent appends will also fail and be caught below.
41 }
42}
44export interface TunnelDenialEntry {
45 reason: string;
46 path: string;
47 method: string;
48 sourceIp: string;
49}
51export function logTunnelDenial(req: Request, url: URL, reason: string): void {
52 const now = Date.now();
53 // Drop stale timestamps
54 while (writeTimestamps.length && writeTimestamps[0] < now - WINDOW_MS) {
55 writeTimestamps.shift();
56 }
57 if (writeTimestamps.length >= RATE_CAP) {
58 droppedSinceLastWrite += 1;
59 return;
60 }
61 writeTimestamps.push(now);
63 const sourceIp =
64 req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
66 const entry: Record<string, unknown> = {
67 ts: new Date(now).toISOString(),
68 kind: 'tunnel_auth_denial',
69 reason,
70 path: url.pathname,
71 method: req.method,
72 sourceIp,
73 };
74 if (droppedSinceLastWrite > 0) {
75 entry.droppedSinceLastWrite = droppedSinceLastWrite;
76 droppedSinceLastWrite = 0;
77 }
79 // Fire and forget. Never await, never block the request path.
80 void (async () => {
81 try {
82 const dir = path.join(resolveStateRoot(), 'security');
83 await ensureDir(dir);
84 await fsp.appendFile(path.join(dir, 'attempts.jsonl'), JSON.stringify(entry) + '\n');
85 } catch {
86 // Swallow — log writes are best-effort. If disk is full or ACLs block
87 // us, we don't want to crash the server.
88 }
89 })();
90}
92// Test-only reset. Never called in production.
93export function __resetTunnelDenialLog(): void {
94 writeTimestamps.length = 0;
95 droppedSinceLastWrite = 0;
96 ensuredDir = null;
97}