From d7869f2ce19001d818771d80ae883cfe89a9b33d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 4 Oct 2026 08:04:16 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(tui):=20watchdog=20mode=20=E2=80=94=20?= =?UTF-8?q?log=20every=20attack,=20triage=20it,=20hand=20holes=20to=20a=20?= =?UTF-8?q?PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ban says who attacked, not whether it worked. Watchdog mode keeps every attack the dashboard sees so each one can be verified afterwards and, when it is a real hole, fixed in a PR. - `threatcrush tui --watchdog`, or `w` in the dashboard, appends attacks to /watchdog/attacks.jsonl (owner-only, buffered, rotated at 50 MB, deduped across reconnect backfills). Turning it on also sweeps what is already on screen. Header badge: `◉ WATCHDOG 42 · 3 answered · 1 5xx`. Demo mode refuses, so canned events never reach the real log. - An attack is any CRS-matched web request (even below the ban threshold), or any medium+ detection from another module; firewall notices, heartbeats and plain 4xx/5xx are not. - log-watcher now puts the HTTP status, method and full request target in web event details. Status is what separates a probe that was refused (4xx) from one the app answered (2xx) or crashed on (5xx). - `threatcrush watchdog [list|show|brief|mark|path]`: findings grouped by vhost+method+path+attack type, worst outcome first; `brief` prints a Markdown hand-off (samples, repro curl, verify-then-fix steps) for an issue, PR body or agent; `mark hole|fp|fixed --note --pr` records the verdict in triage.json. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 + apps/cli/src/commands/monitor.ts | 4 +- apps/cli/src/commands/watchdog.ts | 165 +++++++ apps/cli/src/core/__tests__/watchdog.test.ts | 194 ++++++++ apps/cli/src/core/watchdog.ts | 487 +++++++++++++++++++ apps/cli/src/daemon/watchers/log-watcher.ts | 6 + apps/cli/src/index.ts | 67 ++- apps/cli/src/tui/__tests__/view.test.ts | 15 + apps/cli/src/tui/dashboard.ts | 94 +++- apps/cli/src/tui/state.ts | 21 + apps/cli/src/tui/view.ts | 17 + 11 files changed, 1088 insertions(+), 5 deletions(-) create mode 100644 apps/cli/src/commands/watchdog.ts create mode 100644 apps/cli/src/core/__tests__/watchdog.test.ts create mode 100644 apps/cli/src/core/watchdog.ts diff --git a/README.md b/README.md index f7140624..b7ff9f06 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,29 @@ threatcrush update # Upgrade the CLI using the supported path Full command reference: [threatcrush.com/docs](https://threatcrush.com/docs). +### Watchdog mode: was that attack a real hole? + +A ban answers "who attacked", not "did it work". Watchdog mode keeps every +attack the dashboard sees, so each one can be verified afterwards and, when it +turns out to be a hole, fixed in a PR. + +```bash +threatcrush tui --watchdog # or press w in the dashboard +threatcrush watchdog # what to triage, worst first +threatcrush watchdog show # samples, sources, status codes, a repro curl +threatcrush watchdog brief # Markdown hand-off for an issue, PR or agent +threatcrush watchdog mark fp --note "static route, no DB" +threatcrush watchdog mark fixed --pr https://github.com/org/repo/pull/123 +``` + +Attacks are appended to `/watchdog/attacks.jsonl` (rotated at 50 MB) +and grouped into findings by vhost, method, path and attack type, so ten +thousand scanner hits on one endpoint are one row. Findings sort by how the +target answered: **answered** (2xx: the app served the payload) first, then +**errored** (5xx: it crashed on it), then non-HTTP detections, redirects, and +**refused** (4xx) last. Refused and already-triaged findings are hidden unless +you pass `--all`. Verdicts live in `triage.json` next to the log. + ## Features | Feature | Description | diff --git a/apps/cli/src/commands/monitor.ts b/apps/cli/src/commands/monitor.ts index 1a352b5c..85e0ad21 100644 --- a/apps/cli/src/commands/monitor.ts +++ b/apps/cli/src/commands/monitor.ts @@ -12,6 +12,8 @@ interface MonitorOptions { module?: string; tui?: boolean; demo?: boolean; + watchdog?: boolean; + watchdogDir?: string; } interface LogWatcher { @@ -31,7 +33,7 @@ export async function monitorCommand(options: MonitorOptions): Promise { if (options.tui || options.demo) { // Loaded on demand so the plain monitor never pays for the dashboard. const { startDashboard } = await import('../tui/dashboard.js'); - await startDashboard({ demo: options.demo }); + await startDashboard({ demo: options.demo, watchdog: options.watchdog, watchdogDir: options.watchdogDir }); return; } diff --git a/apps/cli/src/commands/watchdog.ts b/apps/cli/src/commands/watchdog.ts new file mode 100644 index 00000000..5fc7ba4b --- /dev/null +++ b/apps/cli/src/commands/watchdog.ts @@ -0,0 +1,165 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import chalk from 'chalk'; +import { PATHS } from '../daemon/paths.js'; +import { + WATCHDOG_DIRNAME, + TRIAGE_STATUSES, + findFinding, + findingBrief, + groupFindings, + readAttacks, + readTriage, + reproCommand, + setTriage, + type Finding, + type Outcome, + type TriageStatus, +} from '../core/watchdog.js'; + +export interface WatchdogCommonOptions { + dir?: string; +} + +/** + * The log a root dashboard wrote lives under /var/lib, a user one under + * ~/.threatcrush. Without `--dir`, use this process's own and fall back to the + * system one when only that exists, so `sudo threatcrush tui --watchdog` and a + * later unprivileged `threatcrush watchdog list` meet in the same place. + */ +export function resolveWatchdogDir(dir?: string): string { + if (dir) return dir; + if (process.env.THREATCRUSH_WATCHDOG_DIR) return process.env.THREATCRUSH_WATCHDOG_DIR; + const own = join(PATHS.stateDir, WATCHDOG_DIRNAME); + const system = join('/var/lib/threatcrush', WATCHDOG_DIRNAME); + if (!existsSync(own) && existsSync(system)) return system; + return own; +} + +function load(dir: string): Finding[] { + return groupFindings(readAttacks(dir), readTriage(dir)); +} + +const OUTCOME_COLOR: Record string> = { + answered: chalk.red.bold, + errored: chalk.magenta.bold, + unknown: chalk.yellow, + redirected: chalk.cyan, + refused: chalk.gray, +}; + +const TRIAGE_COLOR: Record string> = { + new: chalk.white, + hole: chalk.red.bold, + fp: chalk.gray, + fixed: chalk.green, +}; + +function where(f: Finding): string { + if (f.path) return `${f.host ? `${f.host} ` : ''}${f.method ?? ''} ${f.path}`.trim(); + return f.title; +} + +export interface WatchdogListOptions extends WatchdogCommonOptions { + status?: string; + outcome?: string; + all?: boolean; + limit?: string; + json?: boolean; +} + +export function watchdogListCommand(opts: WatchdogListOptions): void { + const dir = resolveWatchdogDir(opts.dir); + let findings = load(dir); + + if (opts.status) findings = findings.filter((f) => f.triage.status === opts.status); + if (opts.outcome) findings = findings.filter((f) => f.outcome === opts.outcome); + // By default, hide what triage is done with and what was simply refused: + // the list is a to-do, not an archive. `--all` shows everything. + if (!opts.all && !opts.status && !opts.outcome) { + findings = findings.filter((f) => (f.triage.status === 'new' || f.triage.status === 'hole') && f.outcome !== 'refused'); + } + const limit = opts.limit ? Math.max(1, parseInt(opts.limit, 10) || 50) : 50; + + if (opts.json) { + console.log(JSON.stringify(findings.slice(0, limit), null, 2)); + return; + } + + if (findings.length === 0) { + const empty = existsSync(join(dir, 'attacks.jsonl')) + ? 'Nothing to triage.' + (opts.all ? '' : ' (refused and triaged findings hidden; --all shows them)') + : `No watchdog log at ${dir}. Start one with: threatcrush tui --watchdog (or press w in the dashboard).`; + console.log(chalk.dim(empty)); + return; + } + + console.log(chalk.dim(`${dir} · ${findings.length} finding(s)\n`)); + for (const f of findings.slice(0, limit)) { + const statuses = Object.keys(f.statuses).join(',') || '—'; + console.log( + `${chalk.bold(f.id)} ${OUTCOME_COLOR[f.outcome](f.outcome.padEnd(10))} ` + + `${TRIAGE_COLOR[f.triage.status](f.triage.status.padEnd(5))} ` + + `${f.severity.padEnd(8)} ${String(f.count).padStart(5)}× ${chalk.dim(statuses.padEnd(9))} ` + + `${chalk.cyan(f.attack_type ?? f.rule_id ?? f.module)} ${where(f)}`, + ); + } + if (findings.length > limit) console.log(chalk.dim(`\n… ${findings.length - limit} more (--limit)`)); + console.log(chalk.dim('\nthreatcrush watchdog show · brief · mark hole|fp|fixed')); +} + +function resolve(dir: string, id: string): Finding { + const found = findFinding(load(dir), id); + if (!found) { + console.error(chalk.red(`No single finding matches "${id}" in ${dir}.`)); + process.exit(1); + } + return found; +} + +export function watchdogShowCommand(id: string, opts: WatchdogCommonOptions & { json?: boolean }): void { + const dir = resolveWatchdogDir(opts.dir); + const f = resolve(dir, id); + if (opts.json) { + console.log(JSON.stringify(f, null, 2)); + return; + } + console.log(`${chalk.bold(f.id)} ${OUTCOME_COLOR[f.outcome](f.outcome)} ${TRIAGE_COLOR[f.triage.status](f.triage.status)}`); + console.log(`${chalk.cyan(f.attack_type ?? f.rule_id ?? f.module)} ${where(f)}`); + console.log(chalk.dim(`${f.count} hit(s) from ${f.source_count} source(s) · ${f.first_seen} → ${f.last_seen}`)); + const statuses = Object.entries(f.statuses).map(([s, n]) => `${s}×${n}`).join(' '); + if (statuses) console.log(chalk.dim(`status codes: ${statuses}`)); + if (f.triage.note) console.log(`note: ${f.triage.note}`); + if (f.triage.pr) console.log(`pr: ${f.triage.pr}`); + console.log('\nsamples:'); + for (const s of f.samples) { + console.log(` ${chalk.dim(s.at)} ${s.ip ?? '?'} → ${s.status ?? '—'} ${s.method ? `${s.method} ` : ''}${s.url ?? s.message}`); + } + console.log(`\nsources: ${f.sources.join(', ')}${f.source_count > f.sources.length ? ` (+${f.source_count - f.sources.length})` : ''}`); + const repro = f.samples.map(reproCommand).find(Boolean); + if (repro) console.log(`\nreproduce: ${repro}`); +} + +export function watchdogBriefCommand(id: string, opts: WatchdogCommonOptions): void { + const dir = resolveWatchdogDir(opts.dir); + process.stdout.write(findingBrief(resolve(dir, id))); +} + +export function watchdogMarkCommand( + id: string, + status: string, + opts: WatchdogCommonOptions & { note?: string; pr?: string }, +): void { + if (!TRIAGE_STATUSES.includes(status as TriageStatus)) { + console.error(chalk.red(`Status must be one of: ${TRIAGE_STATUSES.join(', ')}`)); + process.exit(1); + } + const dir = resolveWatchdogDir(opts.dir); + const f = resolve(dir, id); + const entry = setTriage(dir, f.id, { status: status as TriageStatus, note: opts.note, pr: opts.pr }); + console.log(`${chalk.bold(f.id)} → ${TRIAGE_COLOR[entry.status](entry.status)}${entry.pr ? ` (${entry.pr})` : ''}`); +} + +export function watchdogPathCommand(opts: WatchdogCommonOptions): void { + console.log(resolveWatchdogDir(opts.dir)); +} diff --git a/apps/cli/src/core/__tests__/watchdog.test.ts b/apps/cli/src/core/__tests__/watchdog.test.ts new file mode 100644 index 00000000..ed605c8d --- /dev/null +++ b/apps/cli/src/core/__tests__/watchdog.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ThreatEvent } from '../../types/events.js'; +import { + WatchdogLog, + findFinding, + findingBrief, + groupFindings, + isAttack, + outcomeFor, + readAttacks, + readTriage, + reproCommand, + setTriage, + toRecord, +} from '../watchdog.js'; + +function web(overrides: Partial & { status?: number; url?: string; attack?: string } = {}): ThreatEvent { + const url = overrides.url ?? "/search?q=1' OR '1'='1"; + const { status = 200, attack = 'sqli', ...rest } = overrides; + return { + timestamp: new Date('2026-10-04T10:00:00Z'), + module: 'log-watcher', + category: 'web', + severity: 'high', + message: `Attack [${attack.toUpperCase()}]: GET ${url}`, + source_ip: '203.0.113.9', + details: { + host: 'example.com', + path: url.split('?')[0], + url, + method: 'GET', + status, + attack_type: attack, + crs_score: 10, + ua: 'sqlmap/1.8', + }, + ...rest, + }; +} + +describe('isAttack', () => { + it('keeps CRS-matched web requests, even below the ban threshold', () => { + expect(isAttack(web())).toBe(true); + expect(isAttack(web({ severity: 'low' }))).toBe(true); + }); + + it('skips plain 4xx/5xx with no attack signature', () => { + const e: ThreatEvent = { + timestamp: new Date(), module: 'log-watcher', category: 'web', severity: 'medium', + message: 'Server error 502: GET /', details: { status: 502, path: '/' }, + }; + expect(isAttack(e)).toBe(false); + }); + + it('keeps medium+ detections from other modules', () => { + expect(isAttack({ + timestamp: new Date(), module: 'rule-engine', category: 'auth', severity: 'high', + message: '[DETECTION] SSH brute force', source_ip: '198.51.100.4', details: { rule_id: 'ssh-brute' }, + })).toBe(true); + }); + + it('skips firewall notices, heartbeats and low-severity chatter', () => { + expect(isAttack({ timestamp: new Date(), module: 'firewall-rules', category: 'network', severity: 'high', message: 'Banned 1.2.3.4' })).toBe(false); + expect(isAttack({ timestamp: new Date(), module: 'network-monitor', category: 'network', severity: 'medium', message: 'alive', details: { heartbeat: true } })).toBe(false); + expect(isAttack({ timestamp: new Date(), module: 'user-journal', category: 'system', severity: 'info', message: 'hello' })).toBe(false); + }); +}); + +describe('outcomeFor', () => { + it('maps status classes', () => { + expect(outcomeFor(200)).toBe('answered'); + expect(outcomeFor(302)).toBe('redirected'); + expect(outcomeFor(403)).toBe('refused'); + expect(outcomeFor(500)).toBe('errored'); + expect(outcomeFor(undefined)).toBe('unknown'); + }); +}); + +describe('toRecord', () => { + it('falls back to the message for events that predate details.url', () => { + const e = web(); + delete e.details!.url; + delete e.details!.method; + const r = toRecord(e); + expect(r.method).toBe('GET'); + expect(r.url).toBe("/search?q=1'"); + }); + + it('gives repeats of the same endpoint+attack one id, and different endpoints different ids', () => { + const a = toRecord(web({ url: '/search?q=1' })); + const b = toRecord(web({ url: '/search?q=2', source_ip: '192.0.2.1' })); + const c = toRecord(web({ url: '/login?u=1' })); + expect(a.id).toBe(b.id); + expect(a.id).not.toBe(c.id); + }); + + it('groups non-web events by message shape, ignoring addresses and counts', () => { + const msg = (ip: string, n: number) => ({ + timestamp: new Date(), module: 'network-monitor', category: 'network' as const, severity: 'high' as const, + message: `Port scan from ${ip}: ${n} ports`, source_ip: ip, + }); + expect(toRecord(msg('1.2.3.4', 12)).id).toBe(toRecord(msg('5.6.7.8', 40)).id); + }); +}); + +describe('WatchdogLog + triage', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'tc-watchdog-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('appends attacks, counts outcomes, and ignores non-attacks', () => { + const log = new WatchdogLog(dir); + expect(log.record(web({ status: 200 }))).not.toBeNull(); + expect(log.record(web({ status: 404, timestamp: new Date('2026-10-04T10:00:01Z') }))).not.toBeNull(); + expect(log.record(web({ status: 500, timestamp: new Date('2026-10-04T10:00:02Z') }))).not.toBeNull(); + expect(log.record({ timestamp: new Date(), module: 'x', category: 'system', severity: 'info', message: 'hi' })).toBeNull(); + log.flush(); + expect(log.stats).toEqual({ logged: 3, answered: 1, errored: 1 }); + expect(readAttacks(dir)).toHaveLength(3); + }); + + it('does not log the same event twice, even across a reopen (reconnect backfill)', () => { + const first = new WatchdogLog(dir); + first.record(web()); + expect(first.record(web())).toBeNull(); + first.flush(); + const second = new WatchdogLog(dir); + expect(second.record(web())).toBeNull(); + second.flush(); + expect(readAttacks(dir)).toHaveLength(1); + }); + + it('writes the log owner-only', () => { + const log = new WatchdogLog(dir); + log.record(web()); + log.flush(); + expect(statSync(join(dir, 'attacks.jsonl')).mode & 0o777).toBe(0o600); + }); + + it('groups into findings, answered first, and records verdicts', () => { + const log = new WatchdogLog(dir); + for (let i = 0; i < 5; i++) { + log.record(web({ url: '/wp-login.php', attack: 'scanner', status: 404, timestamp: new Date(Date.UTC(2026, 9, 4, 10, 0, i)) })); + } + log.record(web({ url: '/api/items?id=1 UNION SELECT', status: 200, timestamp: new Date('2026-10-04T11:00:00Z') })); + log.flush(); + + const findings = groupFindings(readAttacks(dir), readTriage(dir)); + expect(findings).toHaveLength(2); + expect(findings[0].path).toBe('/api/items'); + expect(findings[0].outcome).toBe('answered'); + expect(findings[1].count).toBe(5); + expect(findings[1].outcome).toBe('refused'); + + const id = findings[0].id; + expect(findFinding(findings, id.slice(0, 4))?.id).toBe(id); + setTriage(dir, id, { status: 'fixed', pr: 'https://github.com/x/y/pull/1' }); + const after = groupFindings(readAttacks(dir), readTriage(dir)); + // Triaged findings sink below untriaged ones. + expect(after[1].id).toBe(id); + expect(after[1].triage).toMatchObject({ status: 'fixed', pr: 'https://github.com/x/y/pull/1' }); + expect(JSON.parse(readFileSync(join(dir, 'triage.json'), 'utf8'))[id].status).toBe('fixed'); + }); + + it('marks a finding answered when any one repeat was answered', () => { + const records = [ + toRecord(web({ status: 404 })), + toRecord(web({ status: 200, timestamp: new Date('2026-10-04T10:05:00Z') })), + ]; + const [f] = groupFindings(records); + expect(f.outcome).toBe('answered'); + expect(f.statuses).toEqual({ 404: 1, 200: 1 }); + expect(f.samples[0].status).toBe(200); + }); +}); + +describe('brief + repro', () => { + it('produces a shell-safe curl against the vhost that was hit', () => { + const cmd = reproCommand(toRecord(web())); + expect(cmd).toContain("'https://example.com/search?q=1'\\'' OR '\\''1'\\''='\\''1'"); + }); + + it('includes outcome, repro, and the mark commands', () => { + const [f] = groupFindings([toRecord(web())]); + const md = findingBrief(f); + expect(md).toMatch(/^# Watchdog finding [0-9a-f]{10}: sqli on example\.com GET \/search/); + expect(md).toContain('**Outcome:** answered'); + expect(md).toContain('curl -sk'); + expect(md).toContain(`threatcrush watchdog mark ${f.id} fixed --pr `); + }); +}); diff --git a/apps/cli/src/core/watchdog.ts b/apps/cli/src/core/watchdog.ts new file mode 100644 index 00000000..190f232e --- /dev/null +++ b/apps/cli/src/core/watchdog.ts @@ -0,0 +1,487 @@ +import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, fstatSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import type { EventSeverity, ThreatEvent } from '../types/events.js'; + +/** + * Watchdog mode: keep every attack the box saw, so each one can be checked + * afterwards — "was that a real hole?" — and, when it was, handed to whoever + * (or whatever agent) writes the fix PR. + * + * The live feed cannot do this. It is a 500-line ring buffer, it hides routine + * lines, and a ban takes the attacker off the screen — none of which says + * whether the request that got them banned was *answered*. That is the one + * question that matters for triage: a SQLi probe nginx 404'd is noise; the same + * probe answered 200 is a finding. + * + * Two files under `/watchdog/`: + * attacks.jsonl append-only, one line per attack, rotated to attacks.1.jsonl + * triage.json verdict per finding (a finding = one signature, see below) + */ + +export const WATCHDOG_DIRNAME = 'watchdog'; +const LOG_FILE = 'attacks.jsonl'; +const ROTATED_FILE = 'attacks.1.jsonl'; +const TRIAGE_FILE = 'triage.json'; +/** Rotate past this, keeping one previous file. A scanner flood is ~200 B/line. */ +export const ROTATE_BYTES = 50 * 1024 * 1024; +/** How many recent keys to remember so a reconnect's backfill is not logged twice. */ +const SEEN_LIMIT = 5000; + +/** + * How the target answered. This is what decides the triage order: an attack + * that was `answered` or made the app `errored` needs a human; `refused` is the + * expected outcome and is mostly there for the record. + */ +export type Outcome = 'answered' | 'errored' | 'redirected' | 'refused' | 'unknown'; + +export const OUTCOME_ORDER: Outcome[] = ['answered', 'errored', 'unknown', 'redirected', 'refused']; + +export type TriageStatus = 'new' | 'hole' | 'fp' | 'fixed'; +export const TRIAGE_STATUSES: TriageStatus[] = ['new', 'hole', 'fp', 'fixed']; + +export interface AttackRecord { + v: 1; + /** Finding id: a short hash of the signature, shared by every repeat. */ + id: string; + at: string; + module: string; + category: string; + severity: EventSeverity; + message: string; + ip?: string; + host?: string; + method?: string; + /** Path without querystring — the part that identifies the endpoint. */ + path?: string; + /** Full request target as logged, querystring included: the payload. */ + url?: string; + status?: number; + outcome: Outcome; + attack_type?: string; + rule_id?: string; + crs_score?: number; + ua?: string; +} + +export interface TriageEntry { + status: TriageStatus; + note?: string; + pr?: string; + updated_at: string; +} + +export type TriageMap = Record; + +export interface Finding { + id: string; + /** Worst outcome seen across the repeats: one 200 among a thousand 404s still matters. */ + outcome: Outcome; + severity: EventSeverity; + count: number; + first_seen: string; + last_seen: string; + host?: string; + method?: string; + path?: string; + attack_type?: string; + rule_id?: string; + module: string; + title: string; + /** Distinct source IPs, capped. */ + sources: string[]; + source_count: number; + /** Status codes seen, with counts. */ + statuses: Record; + /** A few concrete requests, answered ones first, for reproduction. */ + samples: AttackRecord[]; + triage: TriageEntry; +} + +const SEVERITY_RANK: Record = { info: 0, low: 1, medium: 2, high: 3, critical: 4 }; + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v !== '' && v !== '-' ? v : undefined; +} + +/** + * Is this event an attack worth keeping? Medium and above always (rule + * detections, port scans, brute force), plus any web request the CRS engine + * matched — even below its ban threshold, because a sub-threshold injection + * that came back 200 is exactly the kind of hole nothing else would flag. + * + * Not attacks: the firewall's own ban/unban notices, monitor heartbeats, and + * plain web 4xx/5xx with no attack signature (those are traffic, not probes). + */ +export function isAttack(event: ThreatEvent): boolean { + if (event.module === 'firewall-rules') return false; + const d = event.details ?? {}; + if (d.heartbeat) return false; + const crs = typeof d.crs_score === 'number' ? d.crs_score : 0; + if (crs > 0 || str(d.attack_type)) return true; + // A web line with a status but no signature is a 4xx/5xx, not a probe. + if (event.category === 'web' && typeof d.status === 'number' && !str(d.rule_id)) return false; + return SEVERITY_RANK[event.severity] >= SEVERITY_RANK.medium; +} + +export function outcomeFor(status: number | undefined): Outcome { + if (typeof status !== 'number' || !Number.isFinite(status)) return 'unknown'; + if (status >= 500) return 'errored'; + if (status >= 400) return 'refused'; + if (status >= 300) return 'redirected'; + if (status >= 100) return 'answered'; + return 'unknown'; +} + +/** `GET /a?b=1` out of `Attack [SQLI]: GET /a?b=1`, for events that predate `details.url`. */ +function requestFromMessage(message: string): { method?: string; url?: string } { + const m = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT|TRACE|PROPFIND)\s+(\S+)/.exec(message); + return m ? { method: m[1], url: m[2] } : {}; +} + +/** + * The identity of a finding. Web: the endpoint and attack class, so ten + * thousand scanner hits on /wp-login.php are one row, not ten thousand. Other + * modules: the rule, or the message with addresses and numbers blanked. + */ +export function signatureOf(r: Pick): string { + if (r.path) { + return ['web', r.host ?? '', r.method ?? '', r.path, r.attack_type ?? r.rule_id ?? ''].join('|'); + } + if (r.rule_id) return ['rule', r.module, r.rule_id].join('|'); + const shape = r.message + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?:\/\d+)?\b/g, '') + .replace(/\b[0-9a-f]*:[0-9a-f:]+\b/gi, '') + .replace(/\d+/g, '#'); + return ['msg', r.module, shape].join('|'); +} + +export function findingId(signature: string): string { + return createHash('sha1').update(signature).digest('hex').slice(0, 10); +} + +export function toRecord(event: ThreatEvent): AttackRecord { + const d = event.details ?? {}; + const fromMsg = requestFromMessage(event.message); + const url = str(d.url) ?? fromMsg.url; + const path = str(d.path) ?? (url ? url.split('?')[0] : undefined); + const status = typeof d.status === 'number' ? d.status : undefined; + const base = { + module: event.module, + host: str(d.host), + method: str(d.method) ?? fromMsg.method, + path, + attack_type: str(d.attack_type), + rule_id: str(d.rule_id), + message: event.message, + }; + const timestamp = event.timestamp instanceof Date ? event.timestamp : new Date(event.timestamp); + const record: AttackRecord = { + v: 1, + id: findingId(signatureOf(base)), + at: (Number.isNaN(timestamp.getTime()) ? new Date() : timestamp).toISOString(), + category: event.category, + severity: event.severity, + ...base, + ip: event.source_ip, + url, + status, + outcome: outcomeFor(status), + crs_score: typeof d.crs_score === 'number' ? d.crs_score : undefined, + ua: str(d.ua), + }; + // Drop undefined keys so the log stays greppable and small. + for (const k of Object.keys(record) as (keyof AttackRecord)[]) { + if (record[k] === undefined) delete record[k]; + } + return record; +} + +function dedupeKey(r: AttackRecord): string { + return `${r.at}|${r.ip ?? ''}|${r.message}`; +} + +export interface WatchdogStats { + logged: number; + answered: number; + errored: number; +} + +/** + * Appends attacks to the log. Buffered — a scanner can send hundreds a second + * and a synchronous write per event would stall the render loop — and flushed + * on `flush()`, which the dashboard calls once a second and on exit. + */ +export class WatchdogLog { + readonly dir: string; + readonly file: string; + private buffer: string[] = []; + private seen = new Set(); + readonly stats: WatchdogStats = { logged: 0, answered: 0, errored: 0 }; + + constructor(dir: string) { + this.dir = dir; + this.file = join(dir, LOG_FILE); + mkdirSync(dir, { recursive: true }); + // Seed from the tail of the existing log, so the 100-event backfill the + // dashboard does on every (re)connect does not log the same attacks again. + for (const r of readTail(this.file, 512 * 1024)) this.remember(dedupeKey(r)); + } + + private remember(key: string): void { + this.seen.add(key); + if (this.seen.size > SEEN_LIMIT) { + const oldest = this.seen.values().next().value; + if (oldest !== undefined) this.seen.delete(oldest); + } + } + + /** Logs the event if it is an attack not already logged. Returns the record when it was. */ + record(event: ThreatEvent): AttackRecord | null { + if (!isAttack(event)) return null; + const rec = toRecord(event); + const key = dedupeKey(rec); + if (this.seen.has(key)) return null; + this.remember(key); + this.buffer.push(JSON.stringify(rec)); + this.stats.logged++; + if (rec.outcome === 'answered') this.stats.answered++; + if (rec.outcome === 'errored') this.stats.errored++; + return rec; + } + + flush(): void { + if (this.buffer.length === 0) return; + const chunk = this.buffer.join('\n') + '\n'; + this.buffer = []; + try { + if (existsSync(this.file) && statSync(this.file).size > ROTATE_BYTES) { + renameSync(this.file, join(this.dir, ROTATED_FILE)); + } + } catch { /* rotation is best-effort; appending still works */ } + appendFileSync(this.file, chunk, { mode: 0o600 }); + } +} + +function parseLines(text: string): AttackRecord[] { + const out: AttackRecord[] = []; + for (const line of text.split('\n')) { + if (!line) continue; + try { + const r = JSON.parse(line) as AttackRecord; + if (r && r.v === 1 && typeof r.id === 'string') out.push(r); + } catch { /* a torn last line from a crash; skip it */ } + } + return out; +} + +function readTail(file: string, bytes: number): AttackRecord[] { + if (!existsSync(file)) return []; + const fd = openSync(file, 'r'); + try { + const size = fstatSync(fd).size; + const start = Math.max(0, size - bytes); + const buf = Buffer.alloc(size - start); + readSync(fd, buf, 0, buf.length, start); + let text = buf.toString('utf8'); + // Starting mid-file lands mid-line; drop the partial first line. + if (start > 0) text = text.slice(text.indexOf('\n') + 1); + return parseLines(text); + } finally { + closeSync(fd); + } +} + +/** Every logged attack, oldest first, rotated file included. */ +export function readAttacks(dir: string): AttackRecord[] { + const out: AttackRecord[] = []; + for (const name of [ROTATED_FILE, LOG_FILE]) { + const file = join(dir, name); + if (existsSync(file)) out.push(...parseLines(readFileSync(file, 'utf8'))); + } + return out; +} + +export function readTriage(dir: string): TriageMap { + const file = join(dir, TRIAGE_FILE); + if (!existsSync(file)) return {}; + try { + return JSON.parse(readFileSync(file, 'utf8')) as TriageMap; + } catch { + return {}; + } +} + +export function writeTriage(dir: string, map: TriageMap): void { + mkdirSync(dir, { recursive: true }); + const file = join(dir, TRIAGE_FILE); + // Write-then-rename so a crash never leaves half a verdict file. + writeFileSync(`${file}.tmp`, JSON.stringify(map, null, 2) + '\n', { mode: 0o600 }); + renameSync(`${file}.tmp`, file); +} + +export function setTriage( + dir: string, + id: string, + update: { status: TriageStatus; note?: string; pr?: string }, + now = new Date(), +): TriageEntry { + const map = readTriage(dir); + const prev = map[id]; + const entry: TriageEntry = { + status: update.status, + note: update.note ?? prev?.note, + pr: update.pr ?? prev?.pr, + updated_at: now.toISOString(), + }; + if (!entry.note) delete entry.note; + if (!entry.pr) delete entry.pr; + map[id] = entry; + writeTriage(dir, map); + return entry; +} + +const SOURCES_CAP = 20; +const SAMPLES_CAP = 5; + +/** + * Fold the raw log into findings, worst first: the outcome order above, then + * severity, then volume. Already-triaged findings sort after untriaged ones. + */ +export function groupFindings(records: AttackRecord[], triage: TriageMap = {}): Finding[] { + const byId = new Map }>(); + for (const r of records) { + let f = byId.get(r.id); + if (!f) { + f = { + id: r.id, + outcome: r.outcome, + severity: r.severity, + count: 0, + first_seen: r.at, + last_seen: r.at, + host: r.host, + method: r.method, + path: r.path, + attack_type: r.attack_type, + rule_id: r.rule_id, + module: r.module, + title: r.message, + sources: [], + source_count: 0, + statuses: {}, + samples: [], + triage: triage[r.id] ?? { status: 'new', updated_at: r.at }, + _ips: new Set(), + }; + byId.set(r.id, f); + } + f.count++; + if (r.at < f.first_seen) f.first_seen = r.at; + if (r.at > f.last_seen) f.last_seen = r.at; + if (OUTCOME_ORDER.indexOf(r.outcome) < OUTCOME_ORDER.indexOf(f.outcome)) f.outcome = r.outcome; + if (SEVERITY_RANK[r.severity] > SEVERITY_RANK[f.severity]) f.severity = r.severity; + if (r.status !== undefined) f.statuses[r.status] = (f.statuses[r.status] ?? 0) + 1; + if (r.ip && !f._ips.has(r.ip)) { + f._ips.add(r.ip); + if (f.sources.length < SOURCES_CAP) f.sources.push(r.ip); + } + f.samples.push(r); + } + + const findings: Finding[] = []; + for (const f of byId.values()) { + const { _ips, ...rest } = f; + rest.source_count = _ips.size; + // Keep the samples that best show the hole: worst outcome first, newest next. + rest.samples = rest.samples + .sort((a, b) => OUTCOME_ORDER.indexOf(a.outcome) - OUTCOME_ORDER.indexOf(b.outcome) || b.at.localeCompare(a.at)) + .slice(0, SAMPLES_CAP); + findings.push(rest); + } + + const triaged = (f: Finding) => (f.triage.status === 'new' ? 0 : 1); + return findings.sort((a, b) => + triaged(a) - triaged(b) + || OUTCOME_ORDER.indexOf(a.outcome) - OUTCOME_ORDER.indexOf(b.outcome) + || SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] + || b.count - a.count + || b.last_seen.localeCompare(a.last_seen)); +} + +/** Resolve a full or prefix id; ambiguous or missing prefixes return null. */ +export function findFinding(findings: Finding[], id: string): Finding | null { + const exact = findings.find((f) => f.id === id); + if (exact) return exact; + const matches = findings.filter((f) => f.id.startsWith(id)); + return matches.length === 1 ? matches[0] : null; +} + +function shellQuote(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +/** A curl that replays the sample against the vhost it hit. Only meaningful with a host. */ +export function reproCommand(r: AttackRecord): string | null { + if (!r.host || !r.url) return null; + const method = r.method && r.method !== 'GET' ? ` -X ${r.method}` : ''; + return `curl -sk -o /dev/null -w '%{http_code}\\n'${method} ${shellQuote(`https://${r.host}${r.url}`)}`; +} + +/** + * The hand-off for verifying and fixing one finding: everything an engineer or + * an agent needs to reproduce it against our own site, decide whether it is a + * hole, and — if it is — land the fix as a PR. Markdown so it pastes into an + * issue, a PR body or an agent prompt unchanged. + */ +export function findingBrief(f: Finding): string { + const lines: string[] = []; + const where = f.host ? `${f.host} ${f.method ?? ''} ${f.path ?? ''}`.trim() : f.title; + lines.push(`# Watchdog finding ${f.id}: ${f.attack_type ?? f.rule_id ?? f.module} on ${where}`); + lines.push(''); + lines.push(`- **Outcome:** ${f.outcome}${outcomeExplainer(f.outcome)}`); + lines.push(`- **Severity:** ${f.severity} · **hits:** ${f.count} from ${f.source_count} source(s)`); + lines.push(`- **Seen:** ${f.first_seen} → ${f.last_seen}`); + const statuses = Object.entries(f.statuses).map(([s, n]) => `${s}×${n}`).join(', '); + if (statuses) lines.push(`- **Status codes:** ${statuses}`); + if (f.host) lines.push(`- **Vhost:** ${f.host}`); + if (f.path) lines.push(`- **Endpoint:** ${f.method ?? ''} ${f.path}`.replace(' ', ' ')); + if (f.rule_id) lines.push(`- **Rule:** ${f.rule_id}`); + lines.push(`- **Detected by:** ${f.module}`); + lines.push(`- **Triage:** ${f.triage.status}${f.triage.note ? ` — ${f.triage.note}` : ''}${f.triage.pr ? ` (${f.triage.pr})` : ''}`); + lines.push(''); + lines.push('## Sample requests'); + lines.push(''); + for (const s of f.samples) { + lines.push(`- \`${s.at}\` ${s.ip ?? '?'} → ${s.status ?? '—'} \`${(s.method ? `${s.method} ` : '') + (s.url ?? s.message)}\``); + if (s.ua) lines.push(` - UA: \`${s.ua}\``); + } + const repro = f.samples.map(reproCommand).find(Boolean); + if (repro) { + lines.push(''); + lines.push('## Reproduce (against our own site only)'); + lines.push(''); + lines.push('```sh'); + lines.push(repro); + lines.push('```'); + } + lines.push(''); + lines.push('## Verify, then fix'); + lines.push(''); + lines.push('1. Find the repo that serves this vhost and the handler for the endpoint above.'); + lines.push('2. Decide whether the request did anything it should not: read the handler, replay the sample against a dev instance, check the response body, not just the status code.'); + lines.push(`3. Not a hole: \`threatcrush watchdog mark ${f.id} fp --note ""\`.`); + lines.push('4. A hole: write a failing test that sends the sample payload, fix the handler, open a PR with this brief in the body, merge it once checks are green and ship it.'); + lines.push(`5. Record it: \`threatcrush watchdog mark ${f.id} fixed --pr \`.`); + return lines.join('\n') + '\n'; +} + +function outcomeExplainer(o: Outcome): string { + switch (o) { + case 'answered': return ' — the app served this request (2xx). Verify it did not do what the payload asked.'; + case 'errored': return ' — the app crashed on it (5xx). Input reached code that did not expect it.'; + case 'redirected': return ' — answered with a redirect (3xx); usually auth bouncing it.'; + case 'refused': return ' — refused (4xx). Probably not a hole; kept for the record.'; + default: return ' — not an HTTP request (ssh, network, rule); check the detection itself.'; + } +} diff --git a/apps/cli/src/daemon/watchers/log-watcher.ts b/apps/cli/src/daemon/watchers/log-watcher.ts index dfb52130..34ff6bb9 100644 --- a/apps/cli/src/daemon/watchers/log-watcher.ts +++ b/apps/cli/src/daemon/watchers/log-watcher.ts @@ -124,6 +124,12 @@ export class LogWatcher { const reqPath = (entry.fields.path || '').split('?')[0]; if (reqPath) details.path = reqPath; if (entry.fields.user_agent) details.ua = entry.fields.user_agent; + // How the server answered, and the request as sent. Watchdog mode needs + // both: a probe that came back 200 is a possible hole, the same probe + // 404'd is noise — and the querystring is where the payload lives. + if (Number.isFinite(status)) details.status = status; + if (entry.fields.method) details.method = entry.fields.method; + if (entry.fields.path) details.url = entry.fields.path.slice(0, 2048); if (crs.score > 0) { Object.assign(details, { attack_type: crs.attackType, diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5066e398..b6d29b57 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -8,6 +8,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { monitorCommand } from "./commands/monitor.js"; +import { + watchdogBriefCommand, + watchdogListCommand, + watchdogMarkCommand, + watchdogPathCommand, + watchdogShowCommand, +} from "./commands/watchdog.js"; import { parseFailOn, scanCommand, type ScanFormat } from "./commands/scan.js"; import { initCommand } from "./commands/init.js"; import { statusCommand } from "./commands/status.js"; @@ -311,6 +318,8 @@ program ${chalk.dim("Examples:")} ${chalk.green("$")} threatcrush monitor ${chalk.dim("# Real-time monitoring")} ${chalk.green("$")} threatcrush tui ${chalk.dim("# Interactive dashboard")} + ${chalk.green("$")} threatcrush tui --watchdog ${chalk.dim("# ...logging every attack for triage")} + ${chalk.green("$")} threatcrush watchdog ${chalk.dim("# Triage logged attacks: hole or not?")} ${chalk.green("$")} threatcrush scan ./src ${chalk.dim("# Scan code for vulns")} ${chalk.green("$")} threatcrush pentest URL ${chalk.dim("# Quick web checks on a URL")} ${chalk.green("$")} threatcrush ai-scan URL ${chalk.dim("# Red-team your AI/LLM endpoint (OWASP LLM Top 10)")} @@ -355,8 +364,64 @@ program .description("Interactive security dashboard (htop for security)") .alias("dashboard") .option("--demo", "Run on canned events instead of connecting to the daemon") + .option("--watchdog", "Start in watchdog mode: log every attack for triage (toggle with w)") + .option("--watchdog-dir ", "Where to keep the watchdog log (default: /watchdog)") .action(async (opts) => { - await monitorCommand({ tui: true, demo: opts.demo }); + await monitorCommand({ tui: true, demo: opts.demo, watchdog: opts.watchdog, watchdogDir: opts.watchdogDir }); + }); + +// ─── Watchdog: triage the attacks the dashboard logged ─── + +const watchdogCmd = program + .command("watchdog") + .description("Triage attacks logged by `tui --watchdog`: was it a hole? fix it, PR it, mark it") + .option("--dir ", "Watchdog log directory") + .action((opts) => { + watchdogListCommand(opts); + }); + +watchdogCmd + .command("list") + .alias("ls") + .description("Findings to triage, worst first (answered 2xx, then 5xx, ...)") + .option("--status ", "Only this triage status: new | hole | fp | fixed") + .option("--outcome ", "Only this outcome: answered | errored | unknown | redirected | refused") + .option("-a, --all", "Include refused and already-triaged findings") + .option("-n, --limit ", "Show at most n findings", "50") + .option("--json", "Output findings as JSON") + .action((_opts, cmd) => { + watchdogListCommand(cmd.optsWithGlobals()); + }); + +watchdogCmd + .command("show ") + .description("Every detail of one finding: samples, sources, a repro command") + .option("--json", "Output as JSON") + .action((id: string, _opts, cmd) => { + watchdogShowCommand(id, cmd.optsWithGlobals()); + }); + +watchdogCmd + .command("brief ") + .description("Markdown hand-off to verify and fix one finding (paste into an issue, PR or agent)") + .action((id: string, _opts, cmd) => { + watchdogBriefCommand(id, cmd.optsWithGlobals()); + }); + +watchdogCmd + .command("mark ") + .description("Record a verdict: hole | fp | fixed | new") + .option("--note ", "Why: what you checked, what you found") + .option("--pr ", "The PR that fixes it") + .action((id: string, status: string, _opts, cmd) => { + watchdogMarkCommand(id, status, cmd.optsWithGlobals()); + }); + +watchdogCmd + .command("path") + .description("Print the watchdog log directory") + .action((_opts, cmd) => { + watchdogPathCommand(cmd.optsWithGlobals()); }); program diff --git a/apps/cli/src/tui/__tests__/view.test.ts b/apps/cli/src/tui/__tests__/view.test.ts index 7fbf12ab..1cea0786 100644 --- a/apps/cli/src/tui/__tests__/view.test.ts +++ b/apps/cli/src/tui/__tests__/view.test.ts @@ -211,3 +211,18 @@ describe('routine 4xx noise', () => { expect(screen(state)).toContain('Nothing but routine 4xx'); }); }); + +describe('watchdog badge', () => { + it('is absent until watchdog mode is on', () => { + expect(screen(live())).not.toContain('WATCHDOG'); + }); + + it('shows logged and answered counts, and survives a reset', () => { + let state = reducer(live(), { type: 'watchdog', watchdog: { on: true, logged: 12, answered: 3, errored: 1 } }); + const text = screen(state); + expect(text).toContain('◉ WATCHDOG 12 · 3 answered · 1 5xx'); + expect(text).toContain('watchdog off'); + state = reducer(state, { type: 'reset' }); + expect(state.watchdog.logged).toBe(12); + }); +}); diff --git a/apps/cli/src/tui/dashboard.ts b/apps/cli/src/tui/dashboard.ts index 375a79f9..c6cae438 100644 --- a/apps/cli/src/tui/dashboard.ts +++ b/apps/cli/src/tui/dashboard.ts @@ -7,10 +7,16 @@ import { formatDuration } from '../daemon/firewall/backoff.js'; import { threatcrushTheme } from './theme.js'; import { renderDashboard } from './view.js'; import { demoEvent } from './demo.js'; +import { WatchdogLog, WATCHDOG_DIRNAME } from '../core/watchdog.js'; +import { join } from 'node:path'; export interface DashboardOptions { /** Replay canned events instead of connecting. Never entered by accident. */ demo?: boolean; + /** Start with watchdog mode on: every attack is logged for later triage. */ + watchdog?: boolean; + /** Where the watchdog log lives. Defaults to `/watchdog`. */ + watchdogDir?: string; } const RECONNECT_MS = 2000; @@ -35,6 +41,74 @@ export async function startDashboard(options: DashboardOptions = {}): Promise { + dispatch({ + type: 'watchdog', + watchdog: { + on: watchdogOn, + logged: watchdog?.stats.logged ?? 0, + answered: watchdog?.stats.answered ?? 0, + errored: watchdog?.stats.errored ?? 0, + }, + }); + }; + + /** Every event the feed sees passes through here; only attacks are kept. */ + const observe = (event: ThreatEvent): void => { + if (!watchdogOn || !watchdog) return; + const rec = watchdog.record(event); + if (rec) publishWatchdog(); + }; + + const flushWatchdog = (): void => { + if (!watchdog) return; + try { + watchdog.flush(); + } catch (err) { + // A full disk or a root-owned directory: say so and stop, rather than + // silently dropping attacks the operator thinks are being kept. + watchdogOn = false; + dispatch({ type: 'notice', text: `watchdog stopped: ${(err as Error).message}`, tone: 'error' }); + publishWatchdog(); + } + }; + + const setWatchdog = (on: boolean): void => { + if (on && options.demo) { + dispatch({ type: 'notice', text: 'demo mode — watchdog does not log canned events', tone: 'error' }); + return; + } + if (on && !watchdog) { + try { + watchdog = new WatchdogLog(watchdogDir); + } catch (err) { + dispatch({ type: 'notice', text: `watchdog: ${(err as Error).message}`, tone: 'error' }); + return; + } + } + watchdogOn = on; + if (on) { + // "Log all attacks" includes the ones already on screen. + for (const event of state.events) watchdog?.record(event); + for (const event of state.parked) watchdog?.record(event); + flushWatchdog(); + dispatch({ type: 'notice', text: `watchdog → ${watchdog?.file}`, tone: 'ok' }); + } else { + flushWatchdog(); + dispatch({ type: 'notice', text: 'watchdog off — `threatcrush watchdog list` to triage', tone: 'ok' }); + } + publishWatchdog(); + }; + // Assigned when a live connection exists; a no-op in demo mode. let refreshBans: () => Promise = async () => {}; @@ -50,6 +124,7 @@ export async function startDashboard(options: DashboardOptions = {}): Promise { - if (!stopped) dispatch({ type: 'event', event }); + if (!stopped) { + dispatch({ type: 'event', event }); + observe(event); + } // A ban the daemon decided on by itself shows up as an event; pull the // list straight away so the row is marked before the next poll. if (!stopped && event.module === 'firewall-rules') void refreshBans(); @@ -131,7 +209,9 @@ export async function startDashboard(options: DashboardOptions = {}): Promise dispatch({ type: 'tick' })); + every(1000, () => { + dispatch({ type: 'tick' }); + flushWatchdog(); + }); + + if (options.watchdog) setWatchdog(true); // --------------------------------------------------------------- input @@ -248,6 +333,9 @@ export async function startDashboard(options: DashboardOptions = {}): Promise 0 ? severityColors.high : severityColors.medium }; +} + function header(ui: Container, state: State, theme: Theme, demo: boolean): void { const badge = connectionBadge(state, demo); const uptime = state.daemon ? formatUptime(state.daemon.uptimeSeconds) : '—'; @@ -73,6 +87,8 @@ function header(ui: Container, state: State, theme: Theme, demo: boolean): void row.text(`${badge.glyph} ${badge.label}`, { fg: badge.color, bold: true, size: 12 }); row.text(state.connectionLabel, { fg: theme.muted, size: 'fill' }); if (state.paused) row.text('⏸ PAUSED', { fg: severityColors.medium, bold: true, size: 10 }); + const wd = watchdogBadge(state); + if (wd) row.text(wd.label, { fg: wd.color, bold: true, size: wd.label.length + 1 }); row.text(`uptime ${uptime}`, { fg: theme.muted, size: 16, align: 'right' }); }); }); @@ -498,6 +514,7 @@ function footer(ui: Container, state: State, theme: Theme): void { { key: 'p', label: state.paused ? 'resume' : 'pause' }, { key: 'r', label: 'reset' }, { key: 'n', label: state.showNoise ? 'hide 4xx' : 'show 4xx' }, + { key: 'w', label: state.watchdog.on ? 'watchdog off' : 'watchdog' }, // Only offer the cron toggle once a cron line has actually shown up, or // while it is muting, so the hint never advertises a key with nothing to act on. ...(state.muteCron || state.events.some(isCronLine) From 0aa5c5536b4cee76940300757f818cc0ea96d6b8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 4 Oct 2026 09:08:15 +0000 Subject: [PATCH 2/3] fix(watchdog): open the log instead of check-then-open (CodeQL js/file-system-race) Co-Authored-By: Claude Opus 5.5 --- apps/cli/src/core/watchdog.ts | 13 ++++++++++--- apps/web/next-env.d.ts | 1 + 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/core/watchdog.ts b/apps/cli/src/core/watchdog.ts index 190f232e..4c611c27 100644 --- a/apps/cli/src/core/watchdog.ts +++ b/apps/cli/src/core/watchdog.ts @@ -255,7 +255,8 @@ export class WatchdogLog { const chunk = this.buffer.join('\n') + '\n'; this.buffer = []; try { - if (existsSync(this.file) && statSync(this.file).size > ROTATE_BYTES) { + // statSync throws on a missing file, which the catch treats as "nothing to rotate". + if (statSync(this.file).size > ROTATE_BYTES) { renameSync(this.file, join(this.dir, ROTATED_FILE)); } } catch { /* rotation is best-effort; appending still works */ } @@ -276,8 +277,14 @@ function parseLines(text: string): AttackRecord[] { } function readTail(file: string, bytes: number): AttackRecord[] { - if (!existsSync(file)) return []; - const fd = openSync(file, 'r'); + // Open, don't check-then-open: the dashboard may rotate the file in between. + let fd: number; + try { + fd = openSync(file, 'r'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw err; + } try { const size = fstatSync(fd).size; const start = Math.max(0, size - bytes); diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7..ce4e94a6 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,7 @@ /// /// import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 25c9ccb24129f2968c38b6f202cd55452441c09a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 4 Oct 2026 09:23:14 +0000 Subject: [PATCH 3/3] feat(watchdog): move the attack logger into the daemon The TUI only kept attacks while someone had a dashboard open with `w` on. The daemon now logs every attack from its event bus, always: - daemon/watchdog-service.ts subscribes to the bus, flushes each second and on shutdown, and reports {enabled, dir, logged, answered, errored, error?} in the IPC `status` reply. `[watchdog] enabled = false` turns it off; `[watchdog] dir` moves it. An unwritable dir is reported, not fatal. - A root daemon shares /var/lib/threatcrush/watchdog with `adm` (the group its socket already trusts): setgid 2770, log and triage.json 660, so log readers can `threatcrush watchdog list` and `mark` without sudo. A user-mode daemon keeps it 700/600. - The TUI no longer writes anything: the header badge shows the daemon's counts, and the `w` key and `tui --watchdog` flags are gone (unreleased). - `threatcrush watchdog` reads the system daemon's directory first, like the client picks the system socket. Co-Authored-By: Claude Opus 5.5 --- README.md | 18 +++- apps/cli/src/commands/monitor.ts | 4 +- apps/cli/src/commands/watchdog.ts | 17 ++-- apps/cli/src/core/config.ts | 1 + apps/cli/src/core/watchdog.ts | 58 ++++++++++-- .../daemon/__tests__/watchdog-service.test.ts | 88 +++++++++++++++++ apps/cli/src/daemon/index.ts | 7 +- apps/cli/src/daemon/ipc-protocol.ts | 15 +++ apps/cli/src/daemon/ipc-server.ts | 3 + apps/cli/src/daemon/watchdog-service.ts | 89 ++++++++++++++++++ apps/cli/src/index.ts | 7 +- apps/cli/src/tui/__tests__/view.test.ts | 23 ++++- apps/cli/src/tui/dashboard.ts | 94 +------------------ apps/cli/src/tui/state.ts | 25 +++-- apps/cli/src/tui/view.ts | 1 - apps/cli/src/types/config.ts | 11 +++ 16 files changed, 323 insertions(+), 138 deletions(-) create mode 100644 apps/cli/src/daemon/__tests__/watchdog-service.test.ts create mode 100644 apps/cli/src/daemon/watchdog-service.ts diff --git a/README.md b/README.md index b7ff9f06..95efb2b3 100644 --- a/README.md +++ b/README.md @@ -115,12 +115,12 @@ Full command reference: [threatcrush.com/docs](https://threatcrush.com/docs). ### Watchdog mode: was that attack a real hole? -A ban answers "who attacked", not "did it work". Watchdog mode keeps every -attack the dashboard sees, so each one can be verified afterwards and, when it -turns out to be a hole, fixed in a PR. +A ban answers "who attacked", not "did it work". The daemon keeps every +attack it sees, so each one can be verified afterwards and, when it turns out +to be a hole, fixed in a PR. It is on by default; the dashboard header shows +`◉ WATCHDOG 42 · 3 answered` while it runs. ```bash -threatcrush tui --watchdog # or press w in the dashboard threatcrush watchdog # what to triage, worst first threatcrush watchdog show # samples, sources, status codes, a repro curl threatcrush watchdog brief # Markdown hand-off for an issue, PR or agent @@ -136,6 +136,16 @@ target answered: **answered** (2xx: the app served the payload) first, then **refused** (4xx) last. Refused and already-triaged findings are hidden unless you pass `--all`. Verdicts live in `triage.json` next to the log. +A root daemon writes to `/var/lib/threatcrush/watchdog`, shared with the `adm` +group (the same group its socket trusts), so log readers can triage without +sudo. To turn it off or move it: + +```toml +[watchdog] +enabled = false # default true +dir = "/srv/threatcrush/watchdog" # then pass --dir to `threatcrush watchdog` +``` + ## Features | Feature | Description | diff --git a/apps/cli/src/commands/monitor.ts b/apps/cli/src/commands/monitor.ts index 85e0ad21..1a352b5c 100644 --- a/apps/cli/src/commands/monitor.ts +++ b/apps/cli/src/commands/monitor.ts @@ -12,8 +12,6 @@ interface MonitorOptions { module?: string; tui?: boolean; demo?: boolean; - watchdog?: boolean; - watchdogDir?: string; } interface LogWatcher { @@ -33,7 +31,7 @@ export async function monitorCommand(options: MonitorOptions): Promise { if (options.tui || options.demo) { // Loaded on demand so the plain monitor never pays for the dashboard. const { startDashboard } = await import('../tui/dashboard.js'); - await startDashboard({ demo: options.demo, watchdog: options.watchdog, watchdogDir: options.watchdogDir }); + await startDashboard({ demo: options.demo }); return; } diff --git a/apps/cli/src/commands/watchdog.ts b/apps/cli/src/commands/watchdog.ts index 5fc7ba4b..993220f2 100644 --- a/apps/cli/src/commands/watchdog.ts +++ b/apps/cli/src/commands/watchdog.ts @@ -22,18 +22,19 @@ export interface WatchdogCommonOptions { } /** - * The log a root dashboard wrote lives under /var/lib, a user one under - * ~/.threatcrush. Without `--dir`, use this process's own and fall back to the - * system one when only that exists, so `sudo threatcrush tui --watchdog` and a - * later unprivileged `threatcrush watchdog list` meet in the same place. + * The daemon writes the log: under /var/lib when it runs as root, under + * ~/.threatcrush when it does not. Prefer the system daemon's, for the same + * reason the client prefers its socket — it is the one watching the real + * traffic — and a root daemon shares the directory with `adm`, so an + * unprivileged `threatcrush watchdog` can read it and record verdicts. + * A custom `[watchdog] dir` needs `--dir` or THREATCRUSH_WATCHDOG_DIR. */ export function resolveWatchdogDir(dir?: string): string { if (dir) return dir; if (process.env.THREATCRUSH_WATCHDOG_DIR) return process.env.THREATCRUSH_WATCHDOG_DIR; - const own = join(PATHS.stateDir, WATCHDOG_DIRNAME); const system = join('/var/lib/threatcrush', WATCHDOG_DIRNAME); - if (!existsSync(own) && existsSync(system)) return system; - return own; + if (existsSync(system)) return system; + return join(PATHS.stateDir, WATCHDOG_DIRNAME); } function load(dir: string): Finding[] { @@ -89,7 +90,7 @@ export function watchdogListCommand(opts: WatchdogListOptions): void { if (findings.length === 0) { const empty = existsSync(join(dir, 'attacks.jsonl')) ? 'Nothing to triage.' + (opts.all ? '' : ' (refused and triaged findings hidden; --all shows them)') - : `No watchdog log at ${dir}. Start one with: threatcrush tui --watchdog (or press w in the dashboard).`; + : `No watchdog log at ${dir}. The daemon writes it: threatcrush start (or upgrade + restart an older daemon).`; console.log(chalk.dim(empty)); return; } diff --git a/apps/cli/src/core/config.ts b/apps/cli/src/core/config.ts index 9c93091b..96d31377 100644 --- a/apps/cli/src/core/config.ts +++ b/apps/cli/src/core/config.ts @@ -44,6 +44,7 @@ export function loadConfig(configPath?: string): ThreatCrushConfig { remediation: parsed.remediation, detection: parsed.detection, cloud: parsed.cloud, + watchdog: parsed.watchdog, license: parsed.license, }; } catch { diff --git a/apps/cli/src/core/watchdog.ts b/apps/cli/src/core/watchdog.ts index 4c611c27..31cc78b9 100644 --- a/apps/cli/src/core/watchdog.ts +++ b/apps/cli/src/core/watchdog.ts @@ -1,4 +1,4 @@ -import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, fstatSync } from 'node:fs'; +import { appendFileSync, chmodSync, chownSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, fstatSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { join } from 'node:path'; import type { EventSeverity, ThreatEvent } from '../types/events.js'; @@ -207,24 +207,51 @@ export interface WatchdogStats { errored: number; } +export interface WatchdogLogOptions { + /** + * Share the directory with this group: setgid 2770, files 660. The root + * daemon passes the `adm` gid — the group its socket already trusts — so a + * log-reading user can run `threatcrush watchdog list` and `mark` (which + * writes triage.json) without sudo. Unset: owner-only, 700/600. + */ + shareGid?: number; +} + +/** True when `dir` was set up group-shared (setgid), so new files should be 660. */ +function isShared(dir: string): boolean { + try { + return (statSync(dir).mode & 0o2000) !== 0; + } catch { + return false; + } +} + /** - * Appends attacks to the log. Buffered — a scanner can send hundreds a second - * and a synchronous write per event would stall the render loop — and flushed - * on `flush()`, which the dashboard calls once a second and on exit. + * Appends attacks to the log. The daemon owns one, fed from its event bus. + * Buffered — a scanner can send hundreds a second and a synchronous write per + * event would stall the bus — and flushed on `flush()`, which the daemon calls + * once a second and on shutdown. */ export class WatchdogLog { readonly dir: string; readonly file: string; private buffer: string[] = []; private seen = new Set(); + private readonly shareGid?: number; readonly stats: WatchdogStats = { logged: 0, answered: 0, errored: 0 }; - constructor(dir: string) { + constructor(dir: string, options: WatchdogLogOptions = {}) { this.dir = dir; this.file = join(dir, LOG_FILE); - mkdirSync(dir, { recursive: true }); - // Seed from the tail of the existing log, so the 100-event backfill the - // dashboard does on every (re)connect does not log the same attacks again. + this.shareGid = options.shareGid; + mkdirSync(dir, { recursive: true, mode: this.shareGid === undefined ? 0o700 : 0o2770 }); + if (this.shareGid !== undefined) { + chownSync(dir, process.getuid?.() ?? 0, this.shareGid); + // mkdir's mode is masked by the umask and ignored for an existing dir. + chmodSync(dir, 0o2770); + } + // Seed from the tail of the existing log, so an event seen twice (a module + // replaying on restart) is not logged twice. for (const r of readTail(this.file, 512 * 1024)) this.remember(dedupeKey(r)); } @@ -260,7 +287,14 @@ export class WatchdogLog { renameSync(this.file, join(this.dir, ROTATED_FILE)); } } catch { /* rotation is best-effort; appending still works */ } - appendFileSync(this.file, chunk, { mode: 0o600 }); + appendFileSync(this.file, chunk, { mode: this.shareGid === undefined ? 0o600 : 0o660 }); + if (this.shareGid !== undefined) { + // A fresh file after rotation is root:root 0600 under the umask; regroup it. + try { + chownSync(this.file, process.getuid?.() ?? 0, this.shareGid); + chmodSync(this.file, 0o660); + } catch { /* the log is still written; only group reads suffer */ } + } } } @@ -323,7 +357,11 @@ export function writeTriage(dir: string, map: TriageMap): void { mkdirSync(dir, { recursive: true }); const file = join(dir, TRIAGE_FILE); // Write-then-rename so a crash never leaves half a verdict file. - writeFileSync(`${file}.tmp`, JSON.stringify(map, null, 2) + '\n', { mode: 0o600 }); + // In the daemon's group-shared dir the verdicts must stay group-writable, or + // the next teammate's `mark` fails. The setgid bit already gives the group. + const mode = isShared(dir) ? 0o660 : 0o600; + writeFileSync(`${file}.tmp`, JSON.stringify(map, null, 2) + '\n', { mode }); + try { chmodSync(`${file}.tmp`, mode); } catch { /* umask fallback is still private */ } renameSync(`${file}.tmp`, file); } diff --git a/apps/cli/src/daemon/__tests__/watchdog-service.test.ts b/apps/cli/src/daemon/__tests__/watchdog-service.test.ts new file mode 100644 index 00000000..68f280cd --- /dev/null +++ b/apps/cli/src/daemon/__tests__/watchdog-service.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { ThreatEvent } from '../../types/events.js'; +import { startWatchdog } from '../watchdog-service.js'; +import { readAttacks, setTriage } from '../../core/watchdog.js'; + +const attack = (status: number, second: number): ThreatEvent => ({ + timestamp: new Date(Date.UTC(2026, 9, 4, 12, 0, second)), + module: 'log-watcher', + category: 'web', + severity: 'high', + message: `Attack [SQLI]: GET /q?id=1'--${second}`, + source_ip: '203.0.113.9', + details: { host: 'example.com', path: '/q', method: 'GET', status, attack_type: 'sqli', crs_score: 9 }, +}); + +describe('daemon watchdog service', () => { + let stateDir: string; + const lines: string[] = []; + const log = (l: string) => { lines.push(l); }; + + beforeEach(() => { + stateDir = mkdtempSync(join(tmpdir(), 'tc-wd-svc-')); + lines.length = 0; + }); + afterEach(() => { rmSync(stateDir, { recursive: true, force: true }); }); + + it('logs attacks from the bus, reports counts, and flushes on stop', () => { + const bus = new EventEmitter(); + const svc = startWatchdog({ bus, stateDir, log, flushMs: 60_000 }); + bus.emit('event', attack(200, 1)); + bus.emit('event', attack(500, 2)); + bus.emit('event', { timestamp: new Date(), module: 'x', category: 'system', severity: 'info', message: 'noise' }); + expect(svc.status()).toMatchObject({ enabled: true, logged: 2, answered: 1, errored: 1, dir: join(stateDir, 'watchdog') }); + + svc.stop(); + expect(readAttacks(join(stateDir, 'watchdog'))).toHaveLength(2); + // Stopped: the listener is gone. + bus.emit('event', attack(200, 3)); + expect(svc.status().logged).toBe(2); + }); + + it('does nothing when [watchdog] enabled = false', () => { + const bus = new EventEmitter(); + const svc = startWatchdog({ bus, stateDir, log, config: { enabled: false } }); + bus.emit('event', attack(200, 1)); + svc.stop(); + expect(svc.status().enabled).toBe(false); + expect(existsSync(join(stateDir, 'watchdog'))).toBe(false); + }); + + it('honours [watchdog] dir', () => { + const bus = new EventEmitter(); + const dir = join(stateDir, 'elsewhere'); + const svc = startWatchdog({ bus, stateDir, log, config: { dir } }); + bus.emit('event', attack(200, 1)); + svc.stop(); + expect(readAttacks(dir)).toHaveLength(1); + }); + + it('reports, rather than throws, when the directory cannot be created', () => { + const bus = new EventEmitter(); + // A path under a regular file fails fast with ENOTDIR. (Not /proc: Node's + // recursive mkdir spins forever there.) + const file = join(stateDir, 'not-a-dir'); + writeFileSync(file, 'x'); + const svc = startWatchdog({ bus, stateDir, log, config: { dir: join(file, 'watchdog') } }); + expect(svc.status().enabled).toBe(false); + expect(svc.status().error).toBeTruthy(); + expect(lines.some((l) => l.startsWith('[watchdog] cannot open'))).toBe(true); + }); + + it('shares the log with a group: setgid dir, group-writable log and verdicts', () => { + const bus = new EventEmitter(); + const gid = process.getgid!(); + const svc = startWatchdog({ bus, stateDir, log, shareGid: gid }); + bus.emit('event', attack(200, 1)); + svc.stop(); + const dir = join(stateDir, 'watchdog'); + expect(statSync(dir).mode & 0o7777).toBe(0o2770); + expect(statSync(join(dir, 'attacks.jsonl')).mode & 0o777).toBe(0o660); + setTriage(dir, 'abc', { status: 'fp' }); + expect(statSync(join(dir, 'triage.json')).mode & 0o777).toBe(0o660); + }); +}); diff --git a/apps/cli/src/daemon/index.ts b/apps/cli/src/daemon/index.ts index 1d7a9f2f..1b601b73 100644 --- a/apps/cli/src/daemon/index.ts +++ b/apps/cli/src/daemon/index.ts @@ -19,6 +19,7 @@ import { RemediationManager } from './firewall/remediation.js'; import { remediationSettings } from './firewall/settings.js'; import { formatDuration } from './firewall/backoff.js'; import { bus } from './event-bus.js'; +import { startWatchdog } from './watchdog-service.js'; import { initStateDB, closeDB } from '../core/state.js'; import { loadConfig } from '../core/config.js'; import { configureAttackDetection } from '../core/log-parser.js'; @@ -204,7 +205,10 @@ export async function runDaemon(): Promise { : '[cloud] not linked; run `threatcrush login` and `threatcrush servers link` to report to the dashboard'); } - const ipc = new IpcServer(version, moduleHost, remediation); + // Every attack to disk for triage (`threatcrush watchdog`), dashboard or not. + const watchdog = startWatchdog({ bus, config: config.watchdog, stateDir: PATHS.stateDir, log: logLine }); + + const ipc = new IpcServer(version, moduleHost, remediation, () => watchdog.status()); await ipc.start(); logLine(`[daemon] ipc listening on ${PATHS.socket}`); @@ -216,6 +220,7 @@ export async function runDaemon(): Promise { try { allowlistSync?.stop(); } catch {} try { await cloudSync?.stop(); } catch {} try { await moduleHost.stop(); } catch {} + try { watchdog.stop(); } catch {} try { await ipc.stop(); } catch {} try { closeDB(); } catch {} try { await flushTelemetry(); } catch {} diff --git a/apps/cli/src/daemon/ipc-protocol.ts b/apps/cli/src/daemon/ipc-protocol.ts index 3f33c7ff..f9fa6296 100644 --- a/apps/cli/src/daemon/ipc-protocol.ts +++ b/apps/cli/src/daemon/ipc-protocol.ts @@ -75,6 +75,21 @@ export interface DaemonStatusReply { * still read a status reply from an older daemon. */ stateDb?: boolean; + /** + * The daemon's attack log for triage (`threatcrush watchdog`). Counts are + * since this daemon started. Absent from a daemon that predates it. + */ + watchdog?: WatchdogStatus; +} + +export interface WatchdogStatus { + enabled: boolean; + dir: string; + logged: number; + answered: number; + errored: number; + /** Set when the log could not be opened or written, e.g. a read-only state dir. */ + error?: string; } export interface EventPush { diff --git a/apps/cli/src/daemon/ipc-server.ts b/apps/cli/src/daemon/ipc-server.ts index 0a1b6027..be0d2281 100644 --- a/apps/cli/src/daemon/ipc-server.ts +++ b/apps/cli/src/daemon/ipc-server.ts @@ -11,6 +11,7 @@ import type { IpcPush, DaemonStatusReply, BlocklistReply, + WatchdogStatus, } from './ipc-protocol.js'; import type { ModuleHost } from './module-host.js'; import type { RemediationManager } from './firewall/remediation.js'; @@ -35,6 +36,7 @@ export class IpcServer { private version: string, private moduleHost: ModuleHost, private remediation?: RemediationManager, + private watchdog?: () => WatchdogStatus, ) { bus.on('event', (event: ThreatEvent) => { this.counters.events++; @@ -176,6 +178,7 @@ export class IpcServer { modules: this.moduleHost.summary(), counters: { ...this.counters }, stateDb: isStateDbAvailable(), + ...(this.watchdog ? { watchdog: this.watchdog() } : {}), }; return this.send(client, { id: req.id, ok: true, result: status }); } diff --git a/apps/cli/src/daemon/watchdog-service.ts b/apps/cli/src/daemon/watchdog-service.ts new file mode 100644 index 00000000..bb36b61c --- /dev/null +++ b/apps/cli/src/daemon/watchdog-service.ts @@ -0,0 +1,89 @@ +import { statSync } from 'node:fs'; +import { join } from 'node:path'; +import type { EventEmitter } from 'node:events'; +import type { ThreatEvent } from '../types/events.js'; +import type { WatchdogSection } from '../types/config.js'; +import { WATCHDOG_DIRNAME, WatchdogLog } from '../core/watchdog.js'; +import type { WatchdogStatus } from './ipc-protocol.js'; + +export interface WatchdogServiceOptions { + bus: Pick; + config?: WatchdogSection; + stateDir: string; + log: (line: string) => void; + /** Group to share the log with. Default: the gid of /var/log/auth.log when root. */ + shareGid?: number; + flushMs?: number; +} + +export interface WatchdogService { + status(): WatchdogStatus; + stop(): void; +} + +/** + * The group allowed to read the attack log: `adm`, found the same way the IPC + * socket finds it (the group owning /var/log/auth.log). Only when root — a + * user-mode daemon keeps its log private under ~/.threatcrush. + */ +export function defaultShareGid(): number | undefined { + const isRoot = process.platform === 'linux' && typeof process.getuid === 'function' && process.getuid() === 0; + if (!isRoot) return undefined; + try { + return statSync('/var/log/auth.log').gid; + } catch { + return undefined; + } +} + +/** + * Watchdog mode, in the daemon: every attack on the bus goes to the triage + * log, whether or not anyone has a dashboard open. It used to live in the TUI, + * which meant attacks were only kept while someone was watching. + */ +export function startWatchdog(opts: WatchdogServiceOptions): WatchdogService { + const dir = opts.config?.dir ?? join(opts.stateDir, WATCHDOG_DIRNAME); + const base = { dir, logged: 0, answered: 0, errored: 0 }; + + if (opts.config?.enabled === false) { + opts.log('[watchdog] off ([watchdog] enabled = false)'); + return { status: () => ({ enabled: false, ...base }), stop: () => {} }; + } + + let log: WatchdogLog; + try { + log = new WatchdogLog(dir, { shareGid: opts.shareGid ?? defaultShareGid() }); + } catch (err) { + const error = (err as Error).message; + opts.log(`[watchdog] cannot open ${dir}: ${error}`); + return { status: () => ({ enabled: false, ...base, error }), stop: () => {} }; + } + + let error: string | undefined; + const onEvent = (event: ThreatEvent): void => { log.record(event); }; + const flush = (): void => { + try { + log.flush(); + error = undefined; + } catch (err) { + // Keep recording: a full disk can clear. Say so once per distinct error. + const message = (err as Error).message; + if (message !== error) opts.log(`[watchdog] write failed: ${message}`); + error = message; + } + }; + + opts.bus.on('event', onEvent); + const timer = setInterval(flush, opts.flushMs ?? 1000); + timer.unref?.(); + opts.log(`[watchdog] logging attacks to ${log.file}`); + + return { + status: () => ({ enabled: true, dir, ...log.stats, ...(error ? { error } : {}) }), + stop: () => { + clearInterval(timer); + opts.bus.off('event', onEvent); + flush(); + }, + }; +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index b6d29b57..13eeee5f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -318,7 +318,6 @@ program ${chalk.dim("Examples:")} ${chalk.green("$")} threatcrush monitor ${chalk.dim("# Real-time monitoring")} ${chalk.green("$")} threatcrush tui ${chalk.dim("# Interactive dashboard")} - ${chalk.green("$")} threatcrush tui --watchdog ${chalk.dim("# ...logging every attack for triage")} ${chalk.green("$")} threatcrush watchdog ${chalk.dim("# Triage logged attacks: hole or not?")} ${chalk.green("$")} threatcrush scan ./src ${chalk.dim("# Scan code for vulns")} ${chalk.green("$")} threatcrush pentest URL ${chalk.dim("# Quick web checks on a URL")} @@ -364,17 +363,15 @@ program .description("Interactive security dashboard (htop for security)") .alias("dashboard") .option("--demo", "Run on canned events instead of connecting to the daemon") - .option("--watchdog", "Start in watchdog mode: log every attack for triage (toggle with w)") - .option("--watchdog-dir ", "Where to keep the watchdog log (default: /watchdog)") .action(async (opts) => { - await monitorCommand({ tui: true, demo: opts.demo, watchdog: opts.watchdog, watchdogDir: opts.watchdogDir }); + await monitorCommand({ tui: true, demo: opts.demo }); }); // ─── Watchdog: triage the attacks the dashboard logged ─── const watchdogCmd = program .command("watchdog") - .description("Triage attacks logged by `tui --watchdog`: was it a hole? fix it, PR it, mark it") + .description("Triage the attacks the daemon logged: was it a hole? fix it, PR it, mark it") .option("--dir ", "Watchdog log directory") .action((opts) => { watchdogListCommand(opts); diff --git a/apps/cli/src/tui/__tests__/view.test.ts b/apps/cli/src/tui/__tests__/view.test.ts index 1cea0786..9184c09c 100644 --- a/apps/cli/src/tui/__tests__/view.test.ts +++ b/apps/cli/src/tui/__tests__/view.test.ts @@ -217,12 +217,25 @@ describe('watchdog badge', () => { expect(screen(live())).not.toContain('WATCHDOG'); }); - it('shows logged and answered counts, and survives a reset', () => { - let state = reducer(live(), { type: 'watchdog', watchdog: { on: true, logged: 12, answered: 3, errored: 1 } }); - const text = screen(state); - expect(text).toContain('◉ WATCHDOG 12 · 3 answered · 1 5xx'); - expect(text).toContain('watchdog off'); + it('shows the daemon-reported counts, survives a reset, and clears when the daemon goes', () => { + let state = reducer(live(), { + type: 'connected', + label: 'daemon pid 4242', + status: { ...status, watchdog: { enabled: true, dir: '/w', logged: 12, answered: 3, errored: 1 } }, + }); + expect(screen(state)).toContain('◉ WATCHDOG 12 · 3 answered · 1 5xx'); state = reducer(state, { type: 'reset' }); expect(state.watchdog.logged).toBe(12); + state = reducer(state, { type: 'connection_lost', label: 'gone' }); + expect(screen(state)).not.toContain('WATCHDOG'); + }); + + it('stays hidden for a daemon with watchdog disabled', () => { + const state = reducer(live(), { + type: 'connected', + label: 'daemon pid 4242', + status: { ...status, watchdog: { enabled: false, dir: '/w', logged: 0, answered: 0, errored: 0 } }, + }); + expect(screen(state)).not.toContain('WATCHDOG'); }); }); diff --git a/apps/cli/src/tui/dashboard.ts b/apps/cli/src/tui/dashboard.ts index c6cae438..375a79f9 100644 --- a/apps/cli/src/tui/dashboard.ts +++ b/apps/cli/src/tui/dashboard.ts @@ -7,16 +7,10 @@ import { formatDuration } from '../daemon/firewall/backoff.js'; import { threatcrushTheme } from './theme.js'; import { renderDashboard } from './view.js'; import { demoEvent } from './demo.js'; -import { WatchdogLog, WATCHDOG_DIRNAME } from '../core/watchdog.js'; -import { join } from 'node:path'; export interface DashboardOptions { /** Replay canned events instead of connecting. Never entered by accident. */ demo?: boolean; - /** Start with watchdog mode on: every attack is logged for later triage. */ - watchdog?: boolean; - /** Where the watchdog log lives. Defaults to `/watchdog`. */ - watchdogDir?: string; } const RECONNECT_MS = 2000; @@ -41,74 +35,6 @@ export async function startDashboard(options: DashboardOptions = {}): Promise { - dispatch({ - type: 'watchdog', - watchdog: { - on: watchdogOn, - logged: watchdog?.stats.logged ?? 0, - answered: watchdog?.stats.answered ?? 0, - errored: watchdog?.stats.errored ?? 0, - }, - }); - }; - - /** Every event the feed sees passes through here; only attacks are kept. */ - const observe = (event: ThreatEvent): void => { - if (!watchdogOn || !watchdog) return; - const rec = watchdog.record(event); - if (rec) publishWatchdog(); - }; - - const flushWatchdog = (): void => { - if (!watchdog) return; - try { - watchdog.flush(); - } catch (err) { - // A full disk or a root-owned directory: say so and stop, rather than - // silently dropping attacks the operator thinks are being kept. - watchdogOn = false; - dispatch({ type: 'notice', text: `watchdog stopped: ${(err as Error).message}`, tone: 'error' }); - publishWatchdog(); - } - }; - - const setWatchdog = (on: boolean): void => { - if (on && options.demo) { - dispatch({ type: 'notice', text: 'demo mode — watchdog does not log canned events', tone: 'error' }); - return; - } - if (on && !watchdog) { - try { - watchdog = new WatchdogLog(watchdogDir); - } catch (err) { - dispatch({ type: 'notice', text: `watchdog: ${(err as Error).message}`, tone: 'error' }); - return; - } - } - watchdogOn = on; - if (on) { - // "Log all attacks" includes the ones already on screen. - for (const event of state.events) watchdog?.record(event); - for (const event of state.parked) watchdog?.record(event); - flushWatchdog(); - dispatch({ type: 'notice', text: `watchdog → ${watchdog?.file}`, tone: 'ok' }); - } else { - flushWatchdog(); - dispatch({ type: 'notice', text: 'watchdog off — `threatcrush watchdog list` to triage', tone: 'ok' }); - } - publishWatchdog(); - }; - // Assigned when a live connection exists; a no-op in demo mode. let refreshBans: () => Promise = async () => {}; @@ -124,7 +50,6 @@ export async function startDashboard(options: DashboardOptions = {}): Promise { - if (!stopped) { - dispatch({ type: 'event', event }); - observe(event); - } + if (!stopped) dispatch({ type: 'event', event }); // A ban the daemon decided on by itself shows up as an event; pull the // list straight away so the row is marked before the next poll. if (!stopped && event.module === 'firewall-rules') void refreshBans(); @@ -209,9 +131,7 @@ export async function startDashboard(options: DashboardOptions = {}): Promise { - dispatch({ type: 'tick' }); - flushWatchdog(); - }); - - if (options.watchdog) setWatchdog(true); + every(1000, () => dispatch({ type: 'tick' })); // --------------------------------------------------------------- input @@ -333,9 +248,6 @@ export async function startDashboard(options: DashboardOptions = {}): Promise/watchdog` (/var/lib/threatcrush/watchdog as root). */ + dir?: string; +} + export interface ThreatCrushConfig { daemon: DaemonConfig; api: ApiConfig; @@ -91,6 +101,7 @@ export interface ThreatCrushConfig { remediation?: RemediationSection; detection?: DetectionSection; cloud?: CloudSection; + watchdog?: WatchdogSection; license?: { key_file?: string; key?: string;