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 @@ More: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).

A logging bot sees a lot, so Sentinel is designed to limit what a compromised component could do.

- **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.
- **Read-only Discord capability policy.** The bot process allows only REST reads, posting log messages, leaving a server, changing its own nickname and banner in a server (Brand plan; any other member field is rejected) and opening a direct message with a single user, used once for the welcome to a server's owner (group DMs are 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, 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.
Expand Down
12 changes: 12 additions & 0 deletions apps/bot/src/capability-policy-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ for (const [method, route] of allowed) {
evaluateDiscordCapability(method, route);
}
evaluateDiscordCapability('PATCH', '/guilds/123456789012345678/members/@me', { nick: 'Sentinel', banner: null });
evaluateDiscordCapability('POST', '/users/@me/channels', { recipient_id: '234567890123456789' });

// Only a one-to-one DM may be opened: no group DMs, no extra fields.
for (const body of [undefined, {}, { recipient_id: 'x' }, { recipient_id: '234567890123456789', nicks: {} }, { access_tokens: [], nicks: {} }, [{ recipient_id: '234567890123456789' }]]) {
let rejected = false;
try {
evaluateDiscordCapability('POST', '/users/@me/channels', body);
} catch {
rejected = true;
}
if (!rejected) throw new Error(`Capability policy allowed a DM channel body: ${JSON.stringify(body)}`);
}

// The own-profile route must never carry moderation fields.
for (const body of [{ roles: [] }, { mute: true }, { deaf: true }, { channel_id: null }, { communication_disabled_until: '2030-01-01' }, { nick: 'x', roles: [] }, 'nick=x', null]) {
Expand Down
17 changes: 16 additions & 1 deletion apps/bot/src/capability-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Client } from 'discord.js';
export type DiscordCapabilityDecision = {
method: string;
route: string;
capability: 'read' | 'send-log-message' | 'leave-guild' | 'edit-own-profile';
capability: 'read' | 'send-log-message' | 'leave-guild' | 'edit-own-profile' | 'open-dm';
};

export class DiscordCapabilityViolation extends Error {
Expand All @@ -27,6 +27,10 @@ const LEAVE_GUILD_ROUTE = /^\/users\/@me\/guilds\/\d{17,20}$/;
// channel moves and timeouts live on /members/:userId and stay blocked.
const OWN_MEMBER_ROUTE = /^\/guilds\/\d{17,20}\/members\/@me$/;
const OWN_PROFILE_FIELDS = new Set(['nick', 'banner']);
// Opening a direct-message channel with one user, used only for the one-time
// welcome to a server owner. Group DMs (access_tokens, nicks) stay blocked.
const OPEN_DM_ROUTE = /^\/users\/@me\/channels$/;
const SNOWFLAKE = /^\d{17,20}$/;

function normalizeMethod(value: unknown) {
return String(value ?? '').trim().toUpperCase();
Expand Down Expand Up @@ -55,6 +59,13 @@ function ownProfileBodyAllowed(body: unknown) {
return Object.keys(body).every((key) => OWN_PROFILE_FIELDS.has(key));
}

function openDmBodyAllowed(body: unknown) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
const keys = Object.keys(body);
const recipient = (body as Record<string, unknown>).recipient_id;
return keys.length === 1 && keys[0] === 'recipient_id' && typeof recipient === 'string' && SNOWFLAKE.test(recipient);
}

export function evaluateDiscordCapability(methodValue: unknown, routeValue: unknown, body?: unknown): DiscordCapabilityDecision {
const method = normalizeMethod(methodValue);
const route = normalizeDiscordRoute(routeValue);
Expand All @@ -73,6 +84,10 @@ export function evaluateDiscordCapability(methodValue: unknown, routeValue: unkn
return { method, route, capability: 'edit-own-profile' };
}

if (method === 'POST' && OPEN_DM_ROUTE.test(route) && openDmBodyAllowed(body)) {
return { method, route, capability: 'open-dm' };
}

throw new DiscordCapabilityViolation(method || '<unknown>', route);
}

Expand Down
5 changes: 4 additions & 1 deletion apps/bot/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,8 @@ export const config = {
internalApiKey,
internalApiPort: Number(process.env.BOT_INTERNAL_PORT ?? 3002),
redisUrl,
logLevel: process.env.LOG_LEVEL ?? 'info'
logLevel: process.env.LOG_LEVEL ?? 'info',
// Public links in the welcome message. Self-hosted instances set their own.
webUrl: (process.env.WEB_URL?.trim() || 'https://sentinel.haxurus.com').replace(/\/$/, ''),
supportUrl: process.env.SUPPORT_URL?.trim() || 'https://discord.gg/DDBGWP5fMX'
};
21 changes: 16 additions & 5 deletions apps/bot/src/discord-actions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Routes, type Client, type Guild } from 'discord.js';
import { Routes, type Client, type EmbedBuilder, type Guild, type GuildMember, type TextChannel } from 'discord.js';

export type GuildLeaveReason = 'super-console' | 'blocked-guild' | 'blocked-installer';

/**
* The only high-level Discord mutations Sentinel intentionally exposes.
* The REST capability policy independently restricts the underlying requests
* to DELETE /users/@me/guilds/:guildId and PATCH /guilds/:guildId/members/@me
* (nickname and banner only).
* The only high-level Discord mutations Sentinel intentionally exposes besides
* log delivery (dispatcher.ts). The REST capability policy independently
* restricts the underlying requests to DELETE /users/@me/guilds/:guildId,
* PATCH /guilds/:guildId/members/@me (nickname and banner only),
* POST /users/@me/channels (one-to-one DM only) and POST /channels/:id/messages.
*/
export async function leaveGuild(guild: Guild, _reason: GuildLeaveReason) {
return guild.leave();
Expand All @@ -26,3 +27,13 @@ export async function editOwnGuildProfile(client: Client, guildId: string, profi
if (profile.banner !== undefined) body.banner = profile.banner;
return client.rest.patch(Routes.guildMember(guildId, '@me'), { body });
}

/** One-time welcome when Sentinel joins a server: never mentions anyone. */
export async function postWelcome(channel: TextChannel, embed: EmbedBuilder) {
return channel.send({ embeds: [embed], allowedMentions: { parse: [] } });
}

/** One-time welcome DM to the owner of a server Sentinel just joined. */
export async function sendOwnerWelcome(owner: GuildMember, embed: EmbedBuilder) {
return owner.send({ embeds: [embed], allowedMentions: { parse: [] } });
}
2 changes: 2 additions & 0 deletions apps/bot/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from 'discord.js';
import { prisma } from '@sentinel/db';
import { ensureGuild, getGuildSettings, isGuildInstallBlocked, isUserInstallBlocked, markGuildRemoved, snapshotMessage } from './store.js';
import { sendWelcome } from './welcome.js';
import { recordEvent, shouldCapture } from './recorder.js';
import { findRecentAuditEntry, jsonSafe } from './utils.js';
import { decryptText, redactText, unprotectJson } from './security.js';
Expand Down Expand Up @@ -130,6 +131,7 @@ export function registerHandlers(client: Client, status: StatusNotifier) {
{ name: 'Aggiunto da', value: installerId ? `<@${installerId}>\n\`${installerId}\`` : 'sconosciuto' }
]
});
await sendWelcome(guild);
});

on(Events.GuildMemberAdd, async (member) => {
Expand Down
100 changes: 100 additions & 0 deletions apps/bot/src/welcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { ChannelType, EmbedBuilder, PermissionFlagsBits, type Guild, type TextChannel } from 'discord.js';
import { config } from './config.js';
import { postWelcome, sendOwnerWelcome } from './discord-actions.js';
import { logger } from './logger.js';

/**
* One-time onboarding when Sentinel is added to a server: a message in the
* server and a direct message to its owner, with the setup steps, the privacy
* policy and the support contact. Nothing else is ever sent by DM (Discord
* Developer Policy: messages must relate to the app's functionality).
*/

const ACCENT = 0xa78bfa;
const NEEDED = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks];

type Locale = 'en' | 'it';

const copy = {
en: {
title: 'Thanks for adding Sentinel',
intro: 'Sentinel records what happens on this server and posts it to the log channels you choose. It is read-only: it never bans, kicks or deletes anything.',
stepsTitle: 'Set it up in a few minutes',
steps: (web: string, guildId: string) => [
`1. Open the [dashboard](${web}/en/dashboard/${guildId}) and sign in with Discord.`,
'2. Choose a **default log channel**: Sentinel needs *View Channel*, *Send Messages* and *Embed Links* there.',
'3. Turn on the **loggers** you need. Everything is configured from the dashboard, there are no commands.'
].join('\n'),
guide: 'Guide',
privacy: 'Privacy and terms',
support: 'Support',
links: (web: string, support: string) => `[Getting started](${web}/en/getting-started) · [Privacy policy and terms](${web}/en/legal) · [Support server](${support})`,
ownerNote: (guild: string) => `You are receiving this once because Sentinel was added to **${guild}**, which you own. Sentinel does not send any other direct messages.`,
footer: 'Sentinel · Discord logging bot'
},
it: {
title: 'Grazie per aver aggiunto Sentinel',
intro: 'Sentinel registra ciò che accade in questo server e lo pubblica nei canali di log che scegli. È in sola lettura: non banna, non espelle e non elimina nulla.',
stepsTitle: 'Configuralo in pochi minuti',
steps: (web: string, guildId: string) => [
`1. Apri la [dashboard](${web}/it/dashboard/${guildId}) e accedi con Discord.`,
'2. Scegli un **canale di log predefinito**: lì Sentinel deve poter *vedere il canale*, *inviare messaggi* e *incorporare link*.',
'3. Attiva i **logger** che ti servono. Si configura tutto dalla dashboard, non ci sono comandi.'
].join('\n'),
guide: 'Guida',
privacy: 'Privacy e termini',
support: 'Supporto',
links: (web: string, support: string) => `[Primi passi](${web}/it/getting-started) · [Privacy e termini](${web}/it/legal) · [Server di supporto](${support})`,
ownerNote: (guild: string) => `Ricevi questo messaggio una sola volta perché Sentinel è stato aggiunto a **${guild}**, di cui sei proprietario. Sentinel non invia altri messaggi privati.`,
footer: 'Sentinel · bot di log per Discord'
}
} as const;

export const welcomeLocale = (preferredLocale: string | null | undefined): Locale =>
preferredLocale?.toLowerCase().startsWith('it') ? 'it' : 'en';

/** Escapes a server name so it cannot inject markdown or mentions. */
const plain = (value: string) => value.replace(/[\\*_~`|>[\]()@#<]/g, (char) => `\\${char}`).slice(0, 100);

export function welcomeEmbed(locale: Locale, guildId: string, guildName: string | null, forOwner: boolean) {
const c = copy[locale];
const embed = new EmbedBuilder()
.setColor(ACCENT)
.setTitle(c.title)
.setDescription(c.intro)
.addFields(
{ name: c.stepsTitle, value: c.steps(config.webUrl, guildId) },
{ name: `${c.guide} · ${c.privacy} · ${c.support}`, value: c.links(config.webUrl, config.supportUrl) }
)
.setFooter({ text: c.footer });
if (forOwner && guildName) embed.addFields({ name: '​', value: c.ownerNote(plain(guildName)) });
return embed;
}

/** The system channel if Sentinel can post there, otherwise the first text channel it can. */
function welcomeChannel(guild: Guild): TextChannel | null {
const me = guild.members.me;
if (!me) return null;
const canPost = (channel: TextChannel) => channel.permissionsFor(me)?.has(NEEDED) ?? false;
if (guild.systemChannel && canPost(guild.systemChannel)) return guild.systemChannel;
const candidates = [...guild.channels.cache.values()]
.filter((channel): channel is TextChannel => channel.type === ChannelType.GuildText)
.sort((a, b) => a.position - b.position);
return candidates.find(canPost) ?? null;
}

export async function sendWelcome(guild: Guild) {
const locale = welcomeLocale(guild.preferredLocale);
const channel = welcomeChannel(guild);
if (channel) {
await postWelcome(channel, welcomeEmbed(locale, guild.id, guild.name, false))
.catch((error: unknown) => logger.warn({ guildId: guild.id, error: String(error) }, 'Welcome message not delivered'));
}
// Owners may have direct messages from server members turned off: that is
// their choice and the message is simply skipped.
const owner = await guild.fetchOwner().catch(() => null);
if (owner && !owner.user.bot) {
await sendOwnerWelcome(owner, welcomeEmbed(locale, guild.id, guild.name, true))
.catch((error: unknown) => logger.info({ guildId: guild.id, error: String(error) }, 'Welcome DM to the owner not delivered'));
}
}
4 changes: 4 additions & 0 deletions apps/web/app/(en)/en/[...missing]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { notFound } from 'next/navigation';

/** Any path without a page: hands over to the localized not-found page. */
export default function Page() { notFound(); }
12 changes: 12 additions & 0 deletions apps/web/app/(en)/en/getting-started/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import GettingStartedPage from '../../../_components/GettingStartedPage';
import { pageMetadata } from '../../../_components/seo';

export const metadata: Metadata = pageMetadata({
locale: 'en',
path: '/getting-started',
title: 'Getting started with Sentinel',
description: 'Add Sentinel to your Discord server and set up audit, moderation and message logs from the dashboard in five minutes: log channel, loggers, history and team access.'
});

export default function Page() { return <GettingStartedPage locale="en" />; }
3 changes: 3 additions & 0 deletions apps/web/app/(en)/en/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import NotFoundPage from '../../_components/NotFoundPage';

export default function NotFound() { return <NotFoundPage locale="en" />; }
4 changes: 4 additions & 0 deletions apps/web/app/(it)/it/[...missing]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { notFound } from 'next/navigation';

/** Any path without a page: hands over to the localized not-found page. */
export default function Page() { notFound(); }
12 changes: 12 additions & 0 deletions apps/web/app/(it)/it/getting-started/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Metadata } from 'next';
import GettingStartedPage from '../../../_components/GettingStartedPage';
import { pageMetadata } from '../../../_components/seo';

export const metadata: Metadata = pageMetadata({
locale: 'it',
path: '/getting-started',
title: 'Primi passi con Sentinel',
description: 'Aggiungi Sentinel al tuo server Discord e configura in cinque minuti dalla dashboard i log di audit, moderazione e messaggi: canale di log, logger, storico e accessi.'
});

export default function Page() { return <GettingStartedPage locale="it" />; }
3 changes: 3 additions & 0 deletions apps/web/app/(it)/it/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import NotFoundPage from '../../_components/NotFoundPage';

export default function NotFound() { return <NotFoundPage locale="it" />; }
4 changes: 4 additions & 0 deletions apps/web/app/(root)/[...missing]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { notFound } from 'next/navigation';

/** Any path outside /en and /it without a page: English not-found page. */
export default function Page() { notFound(); }
3 changes: 3 additions & 0 deletions apps/web/app/(root)/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import NotFoundPage from '../_components/NotFoundPage';

export default function NotFound() { return <NotFoundPage locale="en" />; }
Loading
Loading