Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 45 additions & 4 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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 = <T extends { summary: string; details: unknown }>(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) => {
Expand Down Expand Up @@ -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) })
)));
});

Expand Down
7 changes: 4 additions & 3 deletions apps/bot/src/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions apps/bot/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []
}
Expand Down
3 changes: 2 additions & 1 deletion apps/bot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down
5 changes: 3 additions & 2 deletions apps/bot/src/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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'
}
Expand Down
28 changes: 27 additions & 1 deletion apps/bot/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { value: GuildSettings; expires: number }>();

Expand Down Expand Up @@ -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),
Expand All @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/_components/GuildDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ export default function GuildDashboard({ guildId, locale }: { guildId: string; l
</>}

{tab === 'history' && <>
<div className="toolbar"><input placeholder={L('Cerca nel riepilogo…', 'Search summaries…')} value={historyText} onChange={(e) => setHistoryText(e.target.value)} /><select value={historyEvent} onChange={(e) => setHistoryEvent(e.target.value)}><option value="">{L('Tutti gli eventi', 'All events')}</option>{routes.map(({ event }) => <option key={event.key} value={event.key}>{localizeEvent(event, locale).label}</option>)}</select>{canModerate && <a className="button-link" href={`/backend/api/guilds/${guildId}/export${historyEvent ? `?eventKey=${encodeURIComponent(historyEvent)}` : ''}`}>{L('Esporta JSON', 'Export JSON')}</a>}</div>
<div className="toolbar"><input placeholder={L('Cerca nel riepilogo o per ID di utente o canale…', 'Search summaries, or a user or channel ID…')} value={historyText} onChange={(e) => setHistoryText(e.target.value)} /><select value={historyEvent} onChange={(e) => setHistoryEvent(e.target.value)}><option value="">{L('Tutti gli eventi', 'All events')}</option>{routes.map(({ event }) => <option key={event.key} value={event.key}>{localizeEvent(event, locale).label}</option>)}</select>{canModerate && <a className="button-link" href={`/backend/api/guilds/${guildId}/export${historyEvent ? `?eventKey=${encodeURIComponent(historyEvent)}` : ''}`}>{L('Esporta JSON', 'Export JSON')}</a>}</div>
<div className="history-list">{history.map((item) => <article key={item.id} className="history-item"><div><span className="history-meta"><span className="event-key">{item.eventKey}</span><span className={`state state-${item.dispatchState.toLowerCase()}`}>{item.dispatchState.replace(/_/g, ' ').toLowerCase()}</span></span><time className="mono">{formatDate(item.createdAt)}</time></div><strong>{item.summary}</strong><p>{item.actorId ? `${L('Autore', 'Actor')}: ${item.actorId}` : ''}{item.channelId ? ` · ${L('Canale', 'Channel')}: ${item.channelId}` : ''}</p><details><summary>{L('Dettagli', 'Details')}</summary>{item.dispatchError && <p>{L('Errore invio', 'Delivery error')}: {item.dispatchError}</p>}<pre>{JSON.stringify(item.details, null, 2)}</pre></details></article>)}</div>
{!historyLoading && !history.length && <div className="notice">{L('Nessun evento trovato.', 'No events found.')}</div>}
{historyPage < historyPages && <div className="toolbar"><button disabled={historyLoading} onClick={() => setHistoryPage((page) => page + 1)}>{historyLoading ? L('Caricamento…', 'Loading…') : L('Carica altri', 'Load more')}</button></div>}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/_components/LegalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const copy: Record<Locale, { kicker: string; title: string; intro: string; updat
]
] },
{ title: 'How we protect it', body: [
'Message content, attachments, embeds and event details are encrypted at rest with AES-256-GCM. Session tokens are stored only as hashes. The bot cannot perform moderation actions, and each component has its own database permissions. The code is open source, so these measures can be verified on GitHub.'
'Message content, attachments, embeds, event details and summaries, and the names of message authors are encrypted at rest with AES-256-GCM; database backups are encrypted with a key kept off the server. Session tokens are stored only as hashes. The bot cannot perform moderation actions, and each component has its own database permissions. The code is open source, so these measures can be verified on GitHub.'
] },
{ title: 'Sharing', body: [
'We do not sell data and we do not use it for advertising or profiling, or to train artificial intelligence or machine-learning models. Data is shared only with the providers needed to run the service: Discord (the platform the bot works on), Cloudflare (network delivery and protection) and our hosting provider. Log messages are posted only in the channels configured by each server’s administrators.'
Expand Down Expand Up @@ -168,7 +168,7 @@ const copy: Record<Locale, { kicker: string; title: string; intro: string; updat
]
] },
{ title: 'Come li proteggiamo', body: [
'Contenuto dei messaggi, allegati, embed e dettagli degli eventi sono cifrati con AES-256-GCM. I token di sessione sono salvati solo come hash. Il bot non può eseguire azioni di moderazione e ogni componente ha permessi separati sul database. Il codice è open source, quindi queste misure sono verificabili su GitHub.'
'Contenuto dei messaggi, allegati, embed, dettagli e riepiloghi degli eventi e i nomi degli autori dei messaggi sono cifrati con AES-256-GCM; i backup del database sono cifrati con una chiave conservata fuori dal server. I token di sessione sono salvati solo come hash. Il bot non può eseguire azioni di moderazione e ogni componente ha permessi separati sul database. Il codice è open source, quindi queste misure sono verificabili su GitHub.'
] },
{ title: 'Condivisione', body: [
'Non vendiamo i dati e non li usiamo per pubblicità o profilazione, né per addestrare modelli di intelligenza artificiale o di apprendimento automatico. Li condividiamo solo con i fornitori necessari al servizio: Discord (la piattaforma su cui opera il bot), Cloudflare (distribuzione e protezione di rete) e il nostro fornitore di hosting. I messaggi di log vengono pubblicati solo nei canali configurati dagli amministratori di ciascun server.'
Expand Down
Loading
Loading