Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 61 additions & 1 deletion src/lib/server/audit.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
19 changes: 18 additions & 1 deletion src/lib/server/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AuditEvent, 'action' | 'message' | 'detail'>): 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<void> {
// Session-create hooks only know the user id; fill the name from the user table.
let actorName = ev.actor?.username ?? ev.actorName ?? '';
Expand Down Expand Up @@ -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
})
Expand Down
26 changes: 26 additions & 0 deletions src/lib/server/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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', {
Expand Down