From 2dddae8f9b11495115e045c8452e0c1a11a1fd7f Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 11 Feb 2026 16:04:33 -0800 Subject: [PATCH] feat(audit): Add automation component logging Add audit logging to: - WebhookHandler: connect/disconnect, receive, not_found, denied, HMAC verified - HeartbeatMonitor: start/stop, cycle, check, fail, recover - GmailWatcher: connect/disconnect lifecycle events All automation events now captured in audit log with proper context --- src/automation/gmail.ts | 5 ++++- src/automation/heartbeat.ts | 26 ++++++++++++++++++++++++++ src/automation/webhooks.ts | 18 +++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/automation/gmail.ts b/src/automation/gmail.ts index 3a81946..96d8028 100644 --- a/src/automation/gmail.ts +++ b/src/automation/gmail.ts @@ -6,6 +6,7 @@ import type { GmailConfig } from '../config/schema.js'; import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js'; import { parseInterval } from './heartbeat.js'; import { sanitizeHtml } from '../utils/html.js'; +import { auditLogger } from '../audit/index.js'; /** Minimal interface for the parts of ChannelRegistry we need. */ interface ChannelLookup { @@ -99,6 +100,7 @@ export class GmailWatcher implements ChannelAdapter { this._status = 'connected'; console.log(`GmailWatcher: Connected (poll_interval=${this.config.poll_interval ?? '300s'})`); + auditLogger?.systemStart('GmailWatcher', { poll_interval: this.config.poll_interval }); } async disconnect(): Promise { @@ -107,11 +109,12 @@ export class GmailWatcher implements ChannelAdapter { this.pollTimer = undefined; } if (this.watchTimer) { - clearInterval(this.watchTimer); + clearTimeout(this.watchTimer); this.watchTimer = undefined; } this.oauth2Client = undefined; this._status = 'disconnected'; + auditLogger?.systemStop('GmailWatcher'); } async send(peerId: string, message: OutboundMessage): Promise { diff --git a/src/automation/heartbeat.ts b/src/automation/heartbeat.ts index a470d9a..9084273 100644 --- a/src/automation/heartbeat.ts +++ b/src/automation/heartbeat.ts @@ -2,6 +2,7 @@ import { statfsSync, accessSync, constants as fsConstants } from 'fs'; import { request } from 'http'; import type { HeartbeatConfig, HeartbeatCheck } from '../config/schema.js'; import type { ChannelAdapter, ChannelStatus, OutboundMessage } from '../channels/types.js'; +import { auditLogger } from '../audit/index.js'; /** Result of a single health check. */ export interface CheckResult { @@ -77,6 +78,7 @@ export class HeartbeatMonitor { const intervalMs = parseInterval(this.deps.config.interval); console.log(`HeartbeatMonitor: starting (interval=${this.deps.config.interval}, checks=[${this.deps.config.checks.join(', ')}])`); + auditLogger?.systemStart('HeartbeatMonitor', { interval: this.deps.config.interval, checks: this.deps.config.checks }); this.timer = setInterval(() => { this.runChecks().catch((err) => { @@ -96,6 +98,7 @@ export class HeartbeatMonitor { clearInterval(this.timer); this.timer = undefined; } + auditLogger?.systemStop('HeartbeatMonitor'); } /** Run all configured checks and return the result. */ @@ -136,6 +139,12 @@ export class HeartbeatMonitor { } checks.push(result); + auditLogger?.heartbeatCheck({ + check_name: result.name, + healthy: result.healthy, + message: result.message, + duration_ms: result.durationMs, + }); } const healthy = checks.every((c) => c.healthy); @@ -154,16 +163,33 @@ export class HeartbeatMonitor { this.notifiedFailure = true; const failedChecks = checks.filter((c) => !c.healthy).map((c) => `${c.name}: ${c.message}`); await this.notify(`Heartbeat FAILING (${this.consecutiveFailures} consecutive failures):\n${failedChecks.join('\n')}`); + + auditLogger?.heartbeatFail({ + checks_failed: failedChecks, + consecutive_failures: this.consecutiveFailures, + threshold: this.deps.config.failure_threshold, + }); } } else { if (this.notifiedFailure) { // Recovery notification await this.notify(`Heartbeat RECOVERED after ${this.consecutiveFailures} consecutive failure(s). All checks passing.`); + + auditLogger?.heartbeatRecover({ + consecutive_failures_before: this.consecutiveFailures, + }); } this.consecutiveFailures = 0; this.notifiedFailure = false; } + auditLogger?.heartbeatCycle({ + interval_ms: parseInterval(this.deps.config.interval), + checks: this.deps.config.checks, + healthy, + consecutive_failures: this.consecutiveFailures, + }); + return heartbeatResult; } diff --git a/src/automation/webhooks.ts b/src/automation/webhooks.ts index 60254cf..52549c7 100644 --- a/src/automation/webhooks.ts +++ b/src/automation/webhooks.ts @@ -2,6 +2,7 @@ import { createHmac, timingSafeEqual } from 'crypto'; import type { IncomingMessage, ServerResponse } from 'http'; import type { WebhookConfig } from '../config/schema.js'; import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js'; +import { auditLogger } from '../audit/index.js'; /** Minimal interface for the parts of ChannelRegistry we need. */ interface ChannelLookup { @@ -83,11 +84,13 @@ export class WebhookHandler implements ChannelAdapter { const enabledCount = this.webhookConfigs.filter(w => w.enabled).length; if (enabledCount > 0) { console.log(`WebhookHandler: ${enabledCount} webhook(s) registered`); + auditLogger?.systemStart('WebhookHandler', { webhooks_enabled: enabledCount }); } } async disconnect(): Promise { this._status = 'disconnected'; + auditLogger?.systemStop('WebhookHandler'); } async send(peerId: string, message: OutboundMessage): Promise { @@ -118,12 +121,14 @@ export class WebhookHandler implements ChannelAdapter { async handleRequest(webhookName: string, req: IncomingMessage, res: ServerResponse): Promise { const webhook = this.webhooks.get(webhookName); if (!webhook) { + auditLogger?.webhookNotFound(webhookName); res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unknown webhook' })); return false; } if (!webhook.enabled) { + auditLogger?.webhookDenied(webhookName, 'Webhook disabled'); res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Webhook disabled' })); return false; @@ -132,9 +137,12 @@ export class WebhookHandler implements ChannelAdapter { const body = await readBody(req); // Verify HMAC if secret is configured + const signatureVerified = !webhook.secret; if (webhook.secret) { const signature = req.headers['x-webhook-signature'] as string | undefined; - if (!signature || !verifyHmac(body, webhook.secret, signature)) { + const verified = signature ? verifyHmac(body, webhook.secret, signature) : false; + if (!verified) { + auditLogger?.webhookDenied(webhookName, 'Invalid HMAC signature'); res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid signature' })); return false; @@ -154,6 +162,14 @@ export class WebhookHandler implements ChannelAdapter { metadata: { webhookName, body }, }; + auditLogger?.webhookReceive({ + webhook_name: webhookName, + body, + signature_verified: signatureVerified, + output_channel: webhook.output.channel, + output_peer: webhook.output.peer, + }); + this.messageHandler?.(msg); res.writeHead(202, { 'Content-Type': 'application/json' });