feat(security): add tunnel-denial-log module for attack visibility

Append-only log of tunnel-surface auth denials to
~/.gstack/security/attempts.jsonl. Gives operators visibility into who
is probing tunneled daemons so the next security wave can be driven by
real attack data instead of speculation.

Design notes:
- Async via fs.promises.appendFile. Never appendFileSync — blocking the
  event loop on every denial during a flood is what an attacker wants
  (prior learning: sync-audit-log-io, 10/10 confidence).
- In-process rate cap at 60 writes/minute globally. Excess denials are
  counted in memory but not written to disk — prevents disk DoS.
- Writes to the same ~/.gstack/security/attempts.jsonl used by the
  prompt-injection attempt log. File rotation is handled by the existing
  security pipeline (10MB, 5 generations).

No consumers in this commit; wired up in the dual-listener refactor that
follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-21 20:31:14 -07:00
parent 12fdc6391c
commit f962796f07
+94
View File
@@ -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<void> {
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<string, unknown> = {
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;
}