diff --git a/README.md b/README.md index f7140624..95efb2b3 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,39 @@ 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". 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 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. + +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/watchdog.ts b/apps/cli/src/commands/watchdog.ts new file mode 100644 index 00000000..993220f2 --- /dev/null +++ b/apps/cli/src/commands/watchdog.ts @@ -0,0 +1,166 @@ +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 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 system = join('/var/lib/threatcrush', WATCHDOG_DIRNAME); + if (existsSync(system)) return system; + return join(PATHS.stateDir, WATCHDOG_DIRNAME); +} + +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}. The daemon writes it: threatcrush start (or upgrade + restart an older daemon).`; + 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/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 new file mode 100644 index 00000000..31cc78b9 --- /dev/null +++ b/apps/cli/src/core/watchdog.ts @@ -0,0 +1,532 @@ +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'; + +/** + * 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; +} + +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. 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, options: WatchdogLogOptions = {}) { + this.dir = dir; + this.file = join(dir, LOG_FILE); + 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)); + } + + 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 { + // 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 */ } + 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 */ } + } + } +} + +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[] { + // 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); + 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. + // 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); +} + +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/__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/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..13eeee5f 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,7 @@ program ${chalk.dim("Examples:")} ${chalk.green("$")} threatcrush monitor ${chalk.dim("# Real-time monitoring")} ${chalk.green("$")} threatcrush tui ${chalk.dim("# Interactive dashboard")} + ${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)")} @@ -359,6 +367,60 @@ program await monitorCommand({ tui: true, demo: opts.demo }); }); +// ─── Watchdog: triage the attacks the dashboard logged ─── + +const watchdogCmd = program + .command("watchdog") + .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); + }); + +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 .command("init") .description("Sign in, auto-detect services, and configure ThreatCrush") diff --git a/apps/cli/src/tui/__tests__/view.test.ts b/apps/cli/src/tui/__tests__/view.test.ts index 7fbf12ab..9184c09c 100644 --- a/apps/cli/src/tui/__tests__/view.test.ts +++ b/apps/cli/src/tui/__tests__/view.test.ts @@ -211,3 +211,31 @@ 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 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/state.ts b/apps/cli/src/tui/state.ts index 5ed177b9..9bf8c252 100644 --- a/apps/cli/src/tui/state.ts +++ b/apps/cli/src/tui/state.ts @@ -107,6 +107,28 @@ export interface State { * low/info here, so muting the keepalive never hides a real finding. */ muteCron: boolean; + /** + * The daemon's watchdog log: every attack kept on disk for triage + * (`threatcrush watchdog`). Read from `status`; the daemon owns the log. + */ + watchdog: WatchdogView; +} + +export interface WatchdogView { + on: boolean; + /** Attacks the daemon logged since it started, and how many were answered (2xx) or crashed (5xx). */ + logged: number; + answered: number; + errored: number; +} + +const OFF_WATCHDOG: WatchdogView = { on: false, logged: 0, answered: 0, errored: 0 }; + +/** An older daemon has no `watchdog` in its status; that reads as off. */ +function watchdogView(status: DaemonStatusReply): WatchdogView { + const wd = status.watchdog; + if (!wd) return OFF_WATCHDOG; + return { on: wd.enabled, logged: wd.logged, answered: wd.answered, errored: wd.errored }; } export type Action = @@ -174,6 +196,7 @@ export function initialState(now: number = Date.now()): State { sourceFilter: null, showNoise: false, muteCron: false, + watchdog: OFF_WATCHDOG, }; } @@ -267,6 +290,7 @@ export function reducer(state: State, action: Action): State { connectionLabel: action.label, daemon: action.status, modules: action.status.modules, + watchdog: watchdogView(action.status), counters: { events: action.status.counters.events, threats: action.status.counters.threats, @@ -275,7 +299,7 @@ export function reducer(state: State, action: Action): State { }; case 'connection_lost': - return { ...state, connection: 'lost', connectionLabel: action.label, daemon: null }; + return { ...state, connection: 'lost', connectionLabel: action.label, daemon: null, watchdog: OFF_WATCHDOG }; case 'event': { // Pausing parks events instead of dropping them. The old build discarded @@ -463,6 +487,8 @@ export function reducer(state: State, action: Action): State { moduleIndex: state.moduleIndex, showNoise: state.showNoise, muteCron: state.muteCron, + // Reset clears the view; the watchdog log on disk is not the view. + watchdog: state.watchdog, }; } } diff --git a/apps/cli/src/tui/view.ts b/apps/cli/src/tui/view.ts index 7bc09299..f5b58f77 100644 --- a/apps/cli/src/tui/view.ts +++ b/apps/cli/src/tui/view.ts @@ -63,6 +63,20 @@ function connectionBadge(state: State, demo: boolean) { return { glyph: '○', label: 'OFFLINE', color: severityColors.high }; } +/** + * `◉ WATCHDOG 42 · 3 answered`. Answered (2xx) and errored (5xx) attacks are + * the ones to triage, so they turn the badge red; refused-only stays amber. + */ +export function watchdogBadge(state: State): { label: string; color: number } | null { + const wd = state.watchdog; + if (!wd.on) return null; + const hot = wd.answered + wd.errored; + const parts = [`◉ WATCHDOG ${formatCount(wd.logged)}`]; + if (wd.answered) parts.push(`${formatCount(wd.answered)} answered`); + if (wd.errored) parts.push(`${formatCount(wd.errored)} 5xx`); + return { label: parts.join(' · '), color: hot > 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' }); }); }); diff --git a/apps/cli/src/types/config.ts b/apps/cli/src/types/config.ts index d68df1db..c82d0172 100644 --- a/apps/cli/src/types/config.ts +++ b/apps/cli/src/types/config.ts @@ -83,6 +83,16 @@ export interface CloudSection { min_severity?: 'info' | 'low' | 'medium' | 'high' | 'critical'; } +/** + * `[watchdog]` — keep every attack on disk for triage (`threatcrush watchdog`). + */ +export interface WatchdogSection { + /** Default true. `false` stops the daemon writing the attack log. */ + enabled?: boolean; + /** Default `/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; 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.