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
This commit is contained in:
@@ -6,6 +6,7 @@ import type { GmailConfig } from '../config/schema.js';
|
|||||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
||||||
import { parseInterval } from './heartbeat.js';
|
import { parseInterval } from './heartbeat.js';
|
||||||
import { sanitizeHtml } from '../utils/html.js';
|
import { sanitizeHtml } from '../utils/html.js';
|
||||||
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
/** Minimal interface for the parts of ChannelRegistry we need. */
|
/** Minimal interface for the parts of ChannelRegistry we need. */
|
||||||
interface ChannelLookup {
|
interface ChannelLookup {
|
||||||
@@ -99,6 +100,7 @@ export class GmailWatcher implements ChannelAdapter {
|
|||||||
|
|
||||||
this._status = 'connected';
|
this._status = 'connected';
|
||||||
console.log(`GmailWatcher: Connected (poll_interval=${this.config.poll_interval ?? '300s'})`);
|
console.log(`GmailWatcher: Connected (poll_interval=${this.config.poll_interval ?? '300s'})`);
|
||||||
|
auditLogger?.systemStart('GmailWatcher', { poll_interval: this.config.poll_interval });
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
@@ -107,11 +109,12 @@ export class GmailWatcher implements ChannelAdapter {
|
|||||||
this.pollTimer = undefined;
|
this.pollTimer = undefined;
|
||||||
}
|
}
|
||||||
if (this.watchTimer) {
|
if (this.watchTimer) {
|
||||||
clearInterval(this.watchTimer);
|
clearTimeout(this.watchTimer);
|
||||||
this.watchTimer = undefined;
|
this.watchTimer = undefined;
|
||||||
}
|
}
|
||||||
this.oauth2Client = undefined;
|
this.oauth2Client = undefined;
|
||||||
this._status = 'disconnected';
|
this._status = 'disconnected';
|
||||||
|
auditLogger?.systemStop('GmailWatcher');
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { statfsSync, accessSync, constants as fsConstants } from 'fs';
|
|||||||
import { request } from 'http';
|
import { request } from 'http';
|
||||||
import type { HeartbeatConfig, HeartbeatCheck } from '../config/schema.js';
|
import type { HeartbeatConfig, HeartbeatCheck } from '../config/schema.js';
|
||||||
import type { ChannelAdapter, ChannelStatus, OutboundMessage } from '../channels/types.js';
|
import type { ChannelAdapter, ChannelStatus, OutboundMessage } from '../channels/types.js';
|
||||||
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
/** Result of a single health check. */
|
/** Result of a single health check. */
|
||||||
export interface CheckResult {
|
export interface CheckResult {
|
||||||
@@ -77,6 +78,7 @@ export class HeartbeatMonitor {
|
|||||||
|
|
||||||
const intervalMs = parseInterval(this.deps.config.interval);
|
const intervalMs = parseInterval(this.deps.config.interval);
|
||||||
console.log(`HeartbeatMonitor: starting (interval=${this.deps.config.interval}, checks=[${this.deps.config.checks.join(', ')}])`);
|
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.timer = setInterval(() => {
|
||||||
this.runChecks().catch((err) => {
|
this.runChecks().catch((err) => {
|
||||||
@@ -96,6 +98,7 @@ export class HeartbeatMonitor {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = undefined;
|
this.timer = undefined;
|
||||||
}
|
}
|
||||||
|
auditLogger?.systemStop('HeartbeatMonitor');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run all configured checks and return the result. */
|
/** Run all configured checks and return the result. */
|
||||||
@@ -136,6 +139,12 @@ export class HeartbeatMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checks.push(result);
|
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);
|
const healthy = checks.every((c) => c.healthy);
|
||||||
@@ -154,16 +163,33 @@ export class HeartbeatMonitor {
|
|||||||
this.notifiedFailure = true;
|
this.notifiedFailure = true;
|
||||||
const failedChecks = checks.filter((c) => !c.healthy).map((c) => `${c.name}: ${c.message}`);
|
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')}`);
|
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 {
|
} else {
|
||||||
if (this.notifiedFailure) {
|
if (this.notifiedFailure) {
|
||||||
// Recovery notification
|
// Recovery notification
|
||||||
await this.notify(`Heartbeat RECOVERED after ${this.consecutiveFailures} consecutive failure(s). All checks passing.`);
|
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.consecutiveFailures = 0;
|
||||||
this.notifiedFailure = false;
|
this.notifiedFailure = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogger?.heartbeatCycle({
|
||||||
|
interval_ms: parseInterval(this.deps.config.interval),
|
||||||
|
checks: this.deps.config.checks,
|
||||||
|
healthy,
|
||||||
|
consecutive_failures: this.consecutiveFailures,
|
||||||
|
});
|
||||||
|
|
||||||
return heartbeatResult;
|
return heartbeatResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHmac, timingSafeEqual } from 'crypto';
|
|||||||
import type { IncomingMessage, ServerResponse } from 'http';
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
import type { WebhookConfig } from '../config/schema.js';
|
import type { WebhookConfig } from '../config/schema.js';
|
||||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.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. */
|
/** Minimal interface for the parts of ChannelRegistry we need. */
|
||||||
interface ChannelLookup {
|
interface ChannelLookup {
|
||||||
@@ -83,11 +84,13 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
const enabledCount = this.webhookConfigs.filter(w => w.enabled).length;
|
const enabledCount = this.webhookConfigs.filter(w => w.enabled).length;
|
||||||
if (enabledCount > 0) {
|
if (enabledCount > 0) {
|
||||||
console.log(`WebhookHandler: ${enabledCount} webhook(s) registered`);
|
console.log(`WebhookHandler: ${enabledCount} webhook(s) registered`);
|
||||||
|
auditLogger?.systemStart('WebhookHandler', { webhooks_enabled: enabledCount });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
this._status = 'disconnected';
|
this._status = 'disconnected';
|
||||||
|
auditLogger?.systemStop('WebhookHandler');
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||||
@@ -118,12 +121,14 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
async handleRequest(webhookName: string, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
async handleRequest(webhookName: string, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||||
const webhook = this.webhooks.get(webhookName);
|
const webhook = this.webhooks.get(webhookName);
|
||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
|
auditLogger?.webhookNotFound(webhookName);
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Unknown webhook' }));
|
res.end(JSON.stringify({ error: 'Unknown webhook' }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webhook.enabled) {
|
if (!webhook.enabled) {
|
||||||
|
auditLogger?.webhookDenied(webhookName, 'Webhook disabled');
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Webhook disabled' }));
|
res.end(JSON.stringify({ error: 'Webhook disabled' }));
|
||||||
return false;
|
return false;
|
||||||
@@ -132,9 +137,12 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
|
|
||||||
// Verify HMAC if secret is configured
|
// Verify HMAC if secret is configured
|
||||||
|
const signatureVerified = !webhook.secret;
|
||||||
if (webhook.secret) {
|
if (webhook.secret) {
|
||||||
const signature = req.headers['x-webhook-signature'] as string | undefined;
|
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.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
||||||
return false;
|
return false;
|
||||||
@@ -154,6 +162,14 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
metadata: { webhookName, body },
|
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);
|
this.messageHandler?.(msg);
|
||||||
|
|
||||||
res.writeHead(202, { 'Content-Type': 'application/json' });
|
res.writeHead(202, { 'Content-Type': 'application/json' });
|
||||||
|
|||||||
Reference in New Issue
Block a user