From 668b06ab86c3e8bdb53eca03d36a8ae2ab7565bc Mon Sep 17 00:00:00 2001 From: Haxurus <72351758+haxurus@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:32:47 +0200 Subject: [PATCH] feat(security): encrypt summaries, author names and backups at rest - Event summaries and message snapshot author tags are encrypted with the existing AES-256-GCM key; legacy rows are encrypted in batches at startup. - History search: IDs match actor/target/channel; free text is matched after decryption within the latest 5,000 events of the filter. - Pre-deploy database backups are streamed into age, encrypted to a public key; the private key stays off the VPS. Unencrypted dumps are removed. - DEPLOYMENT.md: backup key setup and restore; shutdown procedure. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- apps/api/src/index.ts | 49 +++++++++++++++++++-- apps/bot/src/dispatcher.ts | 7 +-- apps/bot/src/handlers.ts | 5 ++- apps/bot/src/index.ts | 3 +- apps/bot/src/recorder.ts | 5 ++- apps/bot/src/store.ts | 28 +++++++++++- apps/web/app/_components/GuildDashboard.tsx | 2 +- apps/web/app/_components/LegalPage.tsx | 4 +- docs/DEPLOYMENT.md | 43 +++++++++++++++--- ops/sentinel-deploy | 31 ++++++++++--- 11 files changed, 151 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 37b0115..7727b29 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ A logging bot sees a lot, so Sentinel is designed to limit what a compromised co - **Read-only Discord capability policy.** The bot process allows only REST reads, posting log messages, leaving a server and changing its own nickname and banner in a server (Brand plan; any other member field is rejected). Bans, kicks, timeouts, role/channel/message changes, webhooks and AutoMod changes are blocked in-process, regardless of the permissions given to the bot role. A build-time check (`policy:check`) rejects code that tries to bypass it. - **Separated secrets.** The Discord token exists only in the bot container; the web container has no application secrets; secrets live in root-only files on the host. -- **Encryption at rest.** Message content, attachments, embeds and event details are encrypted with AES-256-GCM. +- **Encryption at rest.** Message content, attachments, embeds, event details and summaries, and message authors are encrypted with AES-256-GCM; pre-deploy database backups are encrypted with age to a key kept off the server. - **Data minimisation.** No online status, activities or typing are recorded. History expires with the plan's retention, and when the bot is removed from a server all of that server's data and settings are deleted 30 days later unless it is added back. - **Least-privilege database.** Distinct PostgreSQL roles for API and bot; the bot cannot read sessions, panel audit or super-admin tables. CI verifies these grants on every change. - **Isolation.** Segmented Docker networks, private authenticated Redis, read-only non-root containers with `cap_drop: ALL` and resource limits, no Docker socket, egress blocked towards host/LAN. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4300afa..0206587 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,7 +11,7 @@ import { createSession, destroySession, isSuperAdminUserId, randomToken, require import { getBotGuilds, getGuildResources, leaveBotGuild } from './discord.js'; import { panelAudit } from './audit.js'; import { superAdminAudit } from './super-audit.js'; -import { unprotectJson } from './security.js'; +import { decryptText, unprotectJson } from './security.js'; import { exportJsonChunks, findForeignReference, isValidTimeZone, rateLimitKey } from './helpers.js'; import { planOverview, registerPlanRoutes } from './plan-routes.js'; import { guildTier, sweepExpiredPlans } from './plan-service.js'; @@ -574,6 +574,17 @@ app.get('/api/guilds/:guildId/resources', async (request, reply) => { } }); +/** Most recent events scanned by a free-text history search. */ +const TEXT_SEARCH_WINDOW = 5_000; + +const safeDecrypt = (value: string) => { + try { + return decryptText(value) ?? ''; + } catch { + return ''; + } +}; + app.get('/api/guilds/:guildId/events', async (request, reply) => { const { guildId } = request.params as { guildId: string }; const session = await requireGuild(request, reply, guildId); @@ -597,14 +608,44 @@ app.get('/api/guilds/:guildId/events', async (request, reply) => { ...(filters.actorId ? { actorId: filters.actorId } : {}), ...(filters.targetId ? { targetId: filters.targetId } : {}), ...(filters.channelId ? { channelId: filters.channelId } : {}), - ...(filters.q ? { summary: { contains: filters.q, mode: 'insensitive' as const } } : {}), ...((filters.from || filters.to) ? { createdAt: { ...(filters.from ? { gte: filters.from } : {}), ...(filters.to ? { lte: filters.to } : {}) } } : {}) }; + const readable = (item: T) => ({ ...item, summary: safeDecrypt(item.summary), details: unprotectJson(item.details) }); + const q = filters.q?.trim(); + + // An ID matches the actor, the target or the channel of an event. + if (q && /^\d{17,20}$/.test(q)) { + const idWhere = { ...where, OR: [{ actorId: q }, { targetId: q }, { channelId: q }] }; + const [items, total] = await Promise.all([ + prisma.logEvent.findMany({ where: idWhere, orderBy: { createdAt: 'desc' }, skip: (page - 1) * take, take }), + prisma.logEvent.count({ where: idWhere }) + ]); + return { items: items.map(readable), total, page, take, pages: Math.max(1, Math.ceil(total / take)) }; + } + + // Summaries are encrypted, so the database cannot search them: free text is + // matched after decryption, within the most recent events of the filter. + if (q) { + const needle = q.toLocaleLowerCase(); + const candidates = await prisma.logEvent.findMany({ where, orderBy: { createdAt: 'desc' }, take: TEXT_SEARCH_WINDOW, select: { id: true, summary: true } }); + const matches = candidates.filter((row) => safeDecrypt(row.summary).toLocaleLowerCase().includes(needle)).map((row) => row.id); + const pageIds = matches.slice((page - 1) * take, page * take); + const rows = pageIds.length ? await prisma.logEvent.findMany({ where: { id: { in: pageIds } }, orderBy: { createdAt: 'desc' } }) : []; + return { + items: rows.map(readable), + total: matches.length, + page, + take, + pages: Math.max(1, Math.ceil(matches.length / take)), + searchWindow: candidates.length === TEXT_SEARCH_WINDOW ? TEXT_SEARCH_WINDOW : null + }; + } + const [items, total] = await Promise.all([ prisma.logEvent.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * take, take }), prisma.logEvent.count({ where }) ]); - return { items: items.map((item) => ({ ...item, details: unprotectJson(item.details) })), total, page, take, pages: Math.max(1, Math.ceil(total / take)) }; + return { items: items.map(readable), total, page, take, pages: Math.max(1, Math.ceil(total / take)) }; }); app.get('/api/guilds/:guildId/stats', async (request, reply) => { @@ -734,7 +775,7 @@ app.get('/api/guilds/:guildId/export', async (request, reply) => { return reply.send(Readable.from(exportJsonChunks( { exportedAt: new Date().toISOString(), guildId }, pages(), - (event) => ({ ...event, details: decrypt(event) }) + (event) => ({ ...event, summary: safeDecrypt(event.summary), details: decrypt(event) }) ))); }); diff --git a/apps/bot/src/dispatcher.ts b/apps/bot/src/dispatcher.ts index fc536da..9026fb7 100644 --- a/apps/bot/src/dispatcher.ts +++ b/apps/bot/src/dispatcher.ts @@ -5,7 +5,7 @@ import { effectiveTier, eventDefinition, requiredTierForEvent, tierAllows } from import { DiscordAPIError, EmbedBuilder, type Client } from 'discord.js'; import { config } from './config.js'; import { logger } from './logger.js'; -import { unprotectJson, redactText } from './security.js'; +import { decryptText, unprotectJson, redactText } from './security.js'; import { eventEmbedCopy, fieldLabel, normalizeEmbedLocale } from './embed-i18n.js'; import { asObject, @@ -77,8 +77,9 @@ export function startDispatcher(client: Client) { if (!channel || channel.guildId !== guild.id || !channel.isSendable()) { await mark('CHANNEL_UNAVAILABLE'); return; } const embedLocale = normalizeEmbedLocale(event.guild.locale); - const copy = eventEmbedCopy(event.eventKey, embedLocale, def?.label ?? event.eventKey, def?.description ?? event.summary); - const description = embedLocale === 'it' ? event.summary : copy.description; + const summary = decryptText(event.summary) ?? ''; + const copy = eventEmbedCopy(event.eventKey, embedLocale, def?.label ?? event.eventKey, def?.description ?? summary); + const description = embedLocale === 'it' ? summary : copy.description; const fields: EmbedField[] = []; if (event.actorId && route?.showActor !== false) { diff --git a/apps/bot/src/handlers.ts b/apps/bot/src/handlers.ts index d20762f..6ead80c 100644 --- a/apps/bot/src/handlers.ts +++ b/apps/bot/src/handlers.ts @@ -334,18 +334,19 @@ export function registerHandlers(client: Client, status: StatusNotifier) { if (snapshot?.authorId === client.user?.id || message.author?.id === client.user?.id) return; const settings = await getGuildSettings(message.guildId); const authorId = snapshot?.authorId ?? message.author?.id ?? null; + const snapshotTag = decryptText(snapshot?.authorTag); await recordEvent({ guildId: message.guildId, eventKey: 'message.delete', targetId: authorId, channelId: message.channelId, - summary: `È stato eliminato un messaggio${snapshot?.authorTag ? ` di ${snapshot.authorTag}` : ''}.`, + summary: `È stato eliminato un messaggio${snapshotTag ? ` di ${snapshotTag}` : ''}.`, details: { messageId: message.id, content: settings?.storeMessageContent ? (decryptText(snapshot?.content) ?? message.content ?? null) : null, attachments: snapshot ? unprotectJson(snapshot.attachments) : attachmentData(message), authorId, - authorTag: snapshot?.authorTag ?? message.author?.tag ?? null, + authorTag: snapshotTag ?? message.author?.tag ?? null, actorBot: snapshot?.authorBot ?? message.author?.bot ?? false, actorRoleIds: snapshot?.roleIds ?? [] } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 62e0b8a..0e5a81a 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -7,7 +7,7 @@ import { import { prisma } from '@sentinel/db'; import { config } from './config.js'; import { logger } from './logger.js'; -import { REMOVED_GUILD_GRACE_DAYS, ensureGuild, isGuildInstallBlocked, markMissingGuildsRemoved, purgeRemovedGuilds, runRetentionCleanup } from './store.js'; +import { REMOVED_GUILD_GRACE_DAYS, encryptLegacyRows, ensureGuild, isGuildInstallBlocked, markMissingGuildsRemoved, purgeRemovedGuilds, runRetentionCleanup } from './store.js'; import { registerHandlers } from './handlers.js'; import { startDispatcher } from './dispatcher.js'; import { onQuotaExceeded } from './recorder.js'; @@ -93,6 +93,7 @@ client.once(Events.ClientReady, async (ready) => { .then((count) => { if (count) logger.info({ count }, 'Servers removed while offline'); }) .catch((error) => logger.error({ error: errorText(error) }, 'Unable to reconcile removed servers')); await runMaintenance(); + void encryptLegacyRows().catch((error) => logger.error({ error: errorText(error) }, 'Unable to encrypt legacy rows')); }); /** Retention cleanup, then deletion of servers removed more than 30 days ago. */ diff --git a/apps/bot/src/recorder.ts b/apps/bot/src/recorder.ts index 13e3844..826a95f 100644 --- a/apps/bot/src/recorder.ts +++ b/apps/bot/src/recorder.ts @@ -2,7 +2,7 @@ import { prisma } from '@sentinel/db'; import { effectiveTier, eventDefinition, planLimits, requiredTierForEvent, tierAllows, type PlanTier } from '@sentinel/shared'; import { logQueue, queueConnection } from './queue.js'; import { jsonSafe, redactSecrets } from './utils.js'; -import { protectJson, redactText } from './security.js'; +import { encryptText, protectJson, redactText } from './security.js'; import { DISPATCH_ATTEMPTS } from './dispatcher.js'; import { logger } from './logger.js'; @@ -144,7 +144,8 @@ export async function recordEvent(input: RecordInput) { actorId: input.actorId ?? null, targetId: input.targetId ?? null, channelId: input.channelId ?? null, - summary: input.summary.slice(0, 2000), + // The summary names the people involved, so it is encrypted like the details. + summary: encryptText(input.summary.slice(0, 2000))!, details: protectJson(boundDetails(input.details)), dispatchState: route?.enabled === false ? 'DISABLED' : 'PENDING' } diff --git a/apps/bot/src/store.ts b/apps/bot/src/store.ts index 93a6de7..76b0d71 100644 --- a/apps/bot/src/store.ts +++ b/apps/bot/src/store.ts @@ -2,6 +2,7 @@ import { prisma, type GuildSettings } from '@sentinel/db'; import { EVENT_CATALOG, effectiveTier, planLimits } from '@sentinel/shared'; import type { Guild, Message } from 'discord.js'; import { encryptText, protectJson } from './security.js'; +import { logger } from './logger.js'; const cache = new Map(); @@ -59,7 +60,7 @@ export async function snapshotMessage(message: Message) { guildId: message.guildId, channelId: message.channelId, authorId: message.author.id, - authorTag: message.author.tag, + authorTag: encryptText(message.author.tag), authorBot: message.author.bot, content, attachments: protectJson(attachments), @@ -70,6 +71,31 @@ export async function snapshotMessage(message: Message) { }); } +/** + * Rows written before summaries and author names were encrypted. They expire + * with the retention anyway; this encrypts them in small batches so that no + * name stays in clear in the meantime. + */ +export async function encryptLegacyRows() { + let total = 0; + for (;;) { + const events = await prisma.logEvent.findMany({ where: { NOT: { summary: { startsWith: 'enc:v1:' } } }, select: { id: true, summary: true }, take: 500 }); + const snapshots = await prisma.messageSnapshot.findMany({ + where: { authorTag: { not: null }, NOT: { authorTag: { startsWith: 'enc:v1:' } } }, + select: { messageId: true, authorTag: true }, + take: 500 + }); + if (!events.length && !snapshots.length) break; + await prisma.$transaction([ + ...events.map((row) => prisma.logEvent.update({ where: { id: row.id }, data: { summary: encryptText(row.summary)! } })), + ...snapshots.map((row) => prisma.messageSnapshot.update({ where: { messageId: row.messageId }, data: { authorTag: encryptText(row.authorTag) } })) + ]); + total += events.length + snapshots.length; + } + if (total) logger.info({ rows: total }, 'Encrypted legacy summaries and author names'); + return total; +} + /** Days a server's data is kept after the bot is removed from it. */ export const REMOVED_GUILD_GRACE_DAYS = 30; diff --git a/apps/web/app/_components/GuildDashboard.tsx b/apps/web/app/_components/GuildDashboard.tsx index feef5ad..12e754f 100644 --- a/apps/web/app/_components/GuildDashboard.tsx +++ b/apps/web/app/_components/GuildDashboard.tsx @@ -520,7 +520,7 @@ export default function GuildDashboard({ guildId, locale }: { guildId: string; l } {tab === 'history' && <> -
setHistoryText(e.target.value)} />{canModerate && {L('Esporta JSON', 'Export JSON')}}
+
setHistoryText(e.target.value)} />{canModerate && {L('Esporta JSON', 'Export JSON')}}
{history.map((item) =>
{item.eventKey}{item.dispatchState.replace(/_/g, ' ').toLowerCase()}
{item.summary}

{item.actorId ? `${L('Autore', 'Actor')}: ${item.actorId}` : ''}{item.channelId ? ` · ${L('Canale', 'Channel')}: ${item.channelId}` : ''}

{L('Dettagli', 'Details')}{item.dispatchError &&

{L('Errore invio', 'Delivery error')}: {item.dispatchError}

}
{JSON.stringify(item.details, null, 2)}
)}
{!historyLoading && !history.length &&
{L('Nessun evento trovato.', 'No events found.')}
} {historyPage < historyPages &&
} diff --git a/apps/web/app/_components/LegalPage.tsx b/apps/web/app/_components/LegalPage.tsx index c1a22db..348e8be 100644 --- a/apps/web/app/_components/LegalPage.tsx +++ b/apps/web/app/_components/LegalPage.tsx @@ -60,7 +60,7 @@ const copy: Record restore.dump +pg_restore --clean --if-exists -d "$DATABASE_URL" restore.dump +shred -u restore.dump +``` ## 10. Repository protection @@ -266,3 +288,14 @@ Configured on GitHub for `haxurus/Sentinel`: - **Actions:** `GITHUB_TOKEN` read-only by default and unable to approve pull requests, every action must be pinned to a full commit SHA, workflows from outside contributors need approval. Changes to `deploy/`, `ops/`, `security/`, `.github/workflows/` and migrations should always be reviewed carefully: they reach production or the VPS. + +## 11. Shutting down the hosted instance + +The Discord Developer Terms require deleting all collected data when an application stops operating. To shut the hosted instance down for good: + +1. Unpublish the Discord subscription SKUs and tell subscribers in advance (see the terms of service). +2. Announce the shutdown on the support server and the website, giving server administrators time to export their history. +3. Stop the stack: `docker compose --project-name sentinel -f /srv/docker/sentinel/docker-compose.yml down -v` (the `-v` deletes the database and Redis volumes). +4. Delete `/srv/docker/sentinel/backups/`, the secrets in `/srv/docker/sentinel/secrets/` and the container images. +5. Reset the bot token and the OAuth client secret in the Developer Portal, then delete the application or remove the bot from every server. +6. Revoke the deploy key on the VPS and the GitHub `production` secrets. diff --git a/ops/sentinel-deploy b/ops/sentinel-deploy index 13e1b4d..8ecf9c2 100755 --- a/ops/sentinel-deploy +++ b/ops/sentinel-deploy @@ -8,6 +8,9 @@ ENV_FILE="$STATE_DIR/.env" CURRENT_FILE="$STATE_DIR/.release" PREVIOUS_FILE="$STATE_DIR/.previous-release" BACKUP_DIR="$STATE_DIR/backups" +# age public key(s) the pre-deploy backups are encrypted to. The matching +# private key is kept off the VPS, so a copy of the disk cannot be read. +BACKUP_RECIPIENTS="$STATE_DIR/backup-recipients.txt" PROJECT=sentinel log() { printf '[sentinel-deploy] %s\n' "$*"; } @@ -68,15 +71,31 @@ backup_database() { fi cid="$(compose "$app" "$migrate" ps -q postgres 2>/dev/null || true)" [ -n "$cid" ] || return 0 + command -v age >/dev/null 2>&1 || die 'age is not installed (apt install age); deployment aborted' + grep -Eq '^age1[0-9a-z]{58}$' "$BACKUP_RECIPIENTS" 2>/dev/null || + die "no age public key in $BACKUP_RECIPIENTS; deployment aborted (see DEPLOYMENT.md, Backups)" stamp="$(date -u +%Y%m%dT%H%M%SZ)" - out="$BACKUP_DIR/predeploy-$stamp.dump" - log "creating pre-deploy database backup" - if compose "$app" "$migrate" exec -T postgres sh -c \ - 'export PGPASSWORD="$(cat /run/secrets/postgres_admin_password)"; exec pg_dump -Fc -U "$POSTGRES_USER" "$POSTGRES_DB"' > "$out"; then + out="$BACKUP_DIR/predeploy-$stamp.dump.age" + status_file="$(mktemp)" + log "creating encrypted pre-deploy database backup" + # The dump is streamed straight into age: it never touches the disk in + # clear. A POSIX pipeline only reports age's exit code, so pg_dump's is + # written to a file. + old_umask="$(umask)" + umask 077 + if { + compose "$app" "$migrate" exec -T postgres sh -c \ + 'export PGPASSWORD="$(cat /run/secrets/postgres_admin_password)"; exec pg_dump -Fc -U "$POSTGRES_USER" "$POSTGRES_DB"' + echo "$?" > "$status_file" + } | age -R "$BACKUP_RECIPIENTS" -o "$out" && [ "$(cat "$status_file")" = 0 ] && [ -s "$out" ]; then + umask "$old_umask" + rm -f "$status_file" chmod 600 "$out" - find "$BACKUP_DIR" -type f -name 'predeploy-*.dump' -mtime +14 -delete 2>/dev/null || true + find "$BACKUP_DIR" -type f -name 'predeploy-*.dump.age' -mtime +14 -delete 2>/dev/null || true + # Backups taken before encryption was introduced. + find "$BACKUP_DIR" -type f -name 'predeploy-*.dump' -delete 2>/dev/null || true else - rm -f "$out" + rm -f "$out" "$status_file" die 'database backup failed; deployment aborted' fi }