From c9379358f6cd287157dfeaf1570671ccbcd16cb4 Mon Sep 17 00:00:00 2001 From: Theo Date: Sat, 24 Oct 2026 15:24:46 +0200 Subject: [PATCH] Enhance audit summaries for manual whispers: include message text and redact credentials --- README.md | 4 +++ src/lib/server/audit.test.ts | 62 ++++++++++++++++++++++++++++++++- src/lib/server/audit.ts | 19 +++++++++- src/lib/server/webhooks.test.ts | 26 ++++++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 63c48eb3..1f08b838 100644 --- a/README.md +++ b/README.md @@ -540,6 +540,10 @@ its own short line ("Kicked …: 3 team kills this match"), never the text a rul holds the newest 50 rows you may read in the audit trail; **All in the audit trail** opens them all, with the trail's filters by action and by person. +Manual whispers, including group whispers, also include their message text beside the delivery +result in new audit entries and the Discord command mirror. Credential-looking lines are redacted +as in audit details. Automated whispers still keep only their short result, not their message text. + With a [Seeding reward](#automation-triggers) switched on for the server, people with _Automation_ there see where the player stands with it: the seed time banked on that server over the rule's window against the minutes it asks for and what is left, or the reserved slot they hold, which the diff --git a/src/lib/server/audit.test.ts b/src/lib/server/audit.test.ts index 55937b16..ad7834ce 100644 --- a/src/lib/server/audit.test.ts +++ b/src/lib/server/audit.test.ts @@ -1,5 +1,65 @@ import { describe, expect, test } from 'bun:test'; -import { redact } from './audit'; +import { auditMessage, redact } from './audit'; +import { MAX_CHAT } from '../chat'; + +describe('manual whisper audit summaries', () => { + test('single and group whispers include the text without replacing the delivery result', () => { + for (const action of ['rcon.whisper', 'rcon.whisperMany']) { + for (const message of ['Message sent.', 'Sent to 2 of 3 players.', 'Player not found.']) { + expect(auditMessage({ action, message, detail: { message: 'Please switch teams.' } })).toBe( + `Whisper: Please switch teams.\n${message}` + ); + } + } + }); + + test('only includes the bounded text the action sends, leaving room after a long result', () => { + const result = auditMessage({ + action: 'rcon.whisper', + message: 'x'.repeat(1000), + detail: { message: ` ${'m'.repeat(400)} ` } + }); + expect(result).toStartWith(`Whisper: ${'m'.repeat(MAX_CHAT)}\n`); + expect(result.length).toBeLessThanOrEqual(1000); + }); + + test('redacts credential lines before labelling the whisper', () => { + const result = auditMessage({ + action: 'rcon.whisper', + message: 'Sent.', + detail: { message: 'Password=hunter2\nPlease reconnect.\nToken=secret-value' } + }); + expect(result).toBe('Whisper: Password=[redacted]\nPlease reconnect.\nToken=[redacted]\nSent.'); + }); + + test('does not expose automated text or change other action summaries', () => { + for (const action of [ + 'trigger.welcome', + 'trigger.faction_change', + 'rcon.broadcast', + 'rcon.kick' + ]) { + expect(auditMessage({ action, message: 'Done.', detail: { message: 'private stats' } })).toBe( + 'Done.' + ); + } + }); + + test('missing, empty and malformed text leaves the existing summary alone', () => { + for (const detail of [ + null, + undefined, + {}, + { message: '' }, + { message: ' ' }, + { message: 42 } + ]) { + expect(auditMessage({ action: 'rcon.whisper', message: 'Not sent.', detail })).toBe( + 'Not sent.' + ); + } + }); +}); describe('redact', () => { test('replaces values whose key looks like a credential', () => { diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts index 21eb8654..ebef3131 100644 --- a/src/lib/server/audit.ts +++ b/src/lib/server/audit.ts @@ -19,6 +19,7 @@ import { auditLog, user, type AuditRow } from './db/schema'; import { storableText } from './db/storable'; import { notifyWebhooks } from './webhook-delivery'; import { OWNERS_ROWS } from './audit-rows'; +import { MAX_CHAT } from '../chat'; export type { AuditRow }; export type Outcome = 'ok' | 'error' | 'denied'; @@ -73,6 +74,22 @@ export function redact(value: unknown, depth = 0): unknown { return value; } +/** Manual whispers belong in every audit summary; rule messages remain private. */ +export function auditMessage(ev: Pick): string { + const summary = str(ev.message, 1000); + if (ev.action !== 'rcon.whisper' && ev.action !== 'rcon.whisperMany') return summary; + const detail = ev.detail; + if (!detail || typeof detail !== 'object' || !('message' in detail)) return summary; + if (typeof detail.message !== 'string') return summary; + const text = str(detail.message, MAX_CHAT); + if (!text) return summary; + // Scrub before adding the label so a credential on the first line is still recognised. + const whisper = `Whisper: ${redact(text)}`; + return [whisper, String(redact(summary)).slice(0, 1000 - whisper.length - 1)] + .filter(Boolean) + .join('\n'); +} + export async function writeAudit(env: Env, req: Request | null, ev: AuditEvent): Promise { // Session-create hooks only know the user id; fill the name from the user table. let actorName = ev.actor?.username ?? ev.actorName ?? ''; @@ -101,7 +118,7 @@ export async function writeAudit(env: Env, req: Request | null, ev: AuditEvent): detail: ev.detail === undefined || ev.detail === null ? null : redact(ev.detail), outcome: ev.outcome, status: ev.status ?? null, - message: storableText(str(ev.message, 1000)), + message: storableText(auditMessage(ev)), userAgent: storableText(ev.userAgent ?? (req ? userAgent(req) : '')), durationMs: ev.durationMs ?? null }) diff --git a/src/lib/server/webhooks.test.ts b/src/lib/server/webhooks.test.ts index b5b65253..7fb3239e 100644 --- a/src/lib/server/webhooks.test.ts +++ b/src/lib/server/webhooks.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { validateWebhookUrl } from './webhooks'; import { RULE_KINDS } from '../rule-kinds'; +import { auditMessage } from './audit'; import { buildEmbed, buildTeamKillEmbed, @@ -108,6 +109,31 @@ describe('buildEmbed', () => { expect(e.description).toContain('Outcome: **denied** (403)'); expect(e.color).toBe(0x8a8a90); }); + test('manual whisper summaries reach Discord as escaped text with their delivery result', () => { + for (const action of ['rcon.whisper', 'rcon.whisperMany']) { + const message = auditMessage({ + action, + message: 'Sent to 2 of 3 players.', + detail: { message: 'Please **switch** teams <@&1>\nhttps://example.com' } + }); + const e = buildEmbed('Warcon', { ...row, action, message }); + expect(e.description).toContain('Sent to 2 of 3 players.'); + expect(e.description).toContain('Whisper: Please \\*\\*switch\\*\\* teams \\<@&1\\>'); + expect(e.description).not.toContain('https://example.com'); + expect(e.description).toContain('https:\u200B//example.com'); + } + }); + test('Discord keeps the full whisper even when the game returns a long result', () => { + const text = 'm'.repeat(256); + const message = auditMessage({ + action: 'rcon.whisper', + message: 'x'.repeat(1000), + detail: { message: text } + }); + expect(buildEmbed('Warcon', { ...row, action: 'rcon.whisper', message }).description).toContain( + `Whisper: ${text}` + ); + }); test('a server added, refused or deleted never says where RCON listens', () => { for (const action of ['server.create', 'server.update', 'server.delete']) { const e = buildEmbed('Warcon', {