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
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Sentinel watches what happens on a Discord server — bans, kicks, role and perm

## Features

**72 event types**, each with its own logger:
**70 event types**, each with its own logger:

| Area | Events |
|---|---|
Expand All @@ -41,7 +41,7 @@ Sentinel watches what happens on a Discord server — bans, kicks, role and perm
| Voice & activity | join, leave, move, mute/deaf/stream/video, Stage, scheduled events, soundboard, voice effects |
| Content | emoji, stickers |
| Automation & apps | AutoMod rules and actions, slash commands, application command permissions, Discord Audit Log |
| System & advanced | server availability, optional typing and raw Gateway capture |
| System | server availability and Gateway diagnostics |

**Per-logger control**

Expand Down Expand Up @@ -86,6 +86,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.
- **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.
- **Web hardening.** HttpOnly/Secure session cookies (HMAC-hashed in the database, rotated on login), OAuth `state`, Origin-based CSRF checks, strict CSP, per-session rate limiting, input validation, guild ownership checks on every channel/role ID.
Expand Down Expand Up @@ -181,20 +182,22 @@ Anyone can add the hosted bot: **Public Bot** must be enabled in the Developer P
| Price | — | €1.99/month · €19.99/year | €4.99/month · €49.99/year | €8.99/month · €89.99/year |
| Standard loggers | ✓ | ✓ | ✓ | ✓ |
| High-volume loggers (messages, reactions, polls, interactions, Audit Log) | | ✓ | ✓ | ✓ |
| Advanced loggers (typing, raw Gateway, diagnostics) | | | ✓ | ✓ |
| Gateway diagnostics and server availability loggers | | | ✓ | ✓ |
| Retention | 15 days | 30 days | 60 days | 90 days |
| Events stored per day | 2,000 | 5,000 | 15,000 | 25,000 |
| Log channels | 3 | 8 | 20 | 20 |
| Priority support | | ✓ | ✓ | ✓ |
| Custom bot name and banner in the server | | | | ✓ |

Customers paying for several servers get 15% off with 2 subscriptions, 20% with 3 and 25% with 5 or more. The full list of limits lives in [`packages/shared/src/plans.ts`](packages/shared/src/plans.ts). When a plan is lowered or expires, loggers outside it are switched off and settings above its limits are clamped; nothing is deleted. Online payments are not enabled yet: the pricing page asks people to get in touch, and plans, expiry dates and coupons are managed by hand.
The full list of limits lives in [`packages/shared/src/plans.ts`](packages/shared/src/plans.ts). When a plan is lowered or expires, loggers outside it are switched off and settings above its limits are clamped; nothing is deleted.

**Payments.** Paid plans are sold only as **Discord Premium Apps guild subscriptions**, at the same prices as the pricing page, as the [Discord Developer Policy](https://support-dev.discord.com/hc/articles/8563934450327-Discord-Developer-Policy) requires. There are no coupons or multi-server discounts, because Discord could not offer the same price. The SKU IDs are set in the super console; every two minutes the API reads the application's active entitlements through the bot (a read-only REST call) and updates each server's plan: a purchase switches the plan on, a renewal extends it and an ended subscription brings the server back to Free. If the entitlements cannot be read, nothing changes. Until SKUs are configured the pricing page shows the plans as "coming soon on Discord".

**Super console** (`SUPER_ADMIN_USER_ID` only, enforced by the API):

- connected servers with their plan (level, billing period, expiry, coupon, internal note), leave or block a server;
- connected servers with their plan (level, source — Discord subscription or manual —, expiry, internal note), leave or block a server; manual plans are free trials, partner or complimentary plans;
- public contact details shown on the pricing page;
- coupons and discounts (percentage or fixed amount, plans, billing period, validity, redemptions, optional promotion on the pricing page);
- **Discord subscriptions**: the SKU ID of each plan and billing period, the last sync and a manual "sync now";
- the **status channel**: one server and channel, chosen among those the bot can see, that receives bot startup, errors, warnings, reached quotas and servers added, removed or rejected — these notices are no longer posted in individual servers;
- installation blacklist and an audit log of every action, including automatic plan expiries.

Expand Down
23 changes: 16 additions & 7 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ Do not publish tokens, credentials, database dumps, sensitive logs, or working e

Report vulnerabilities privately through GitHub: **[Report a vulnerability](https://github.com/haxurus/Sentinel/security/advisories/new)** (Security → Advisories). If you cannot use GitHub, write to haxurus@icloud.com. Please allow time for a fix before any public disclosure.

In case of a suspected compromise:

1. temporarily disable automatic deployment by setting `ENABLE_VPS_DEPLOY=false`;
2. rotate affected Discord tokens, OAuth secrets, and deployment keys;
3. revoke compromised sessions/persisted credentials;
4. inspect host/container logs;
5. restore from a known-good release and backup.
## Incident response

What the operator of the hosted instance does when data may have been accessed without authorisation, or a system that processes Discord data may be compromised. The [Discord Developer Terms of Service](https://support-dev.discord.com/hc/articles/8562894815383-Discord-Developer-Terms-of-Service) (section 5c) require Discord and affected users to be notified promptly; the GDPR adds the deadlines below.

1. **Contain — immediately.**
- Disable automatic deployment (`ENABLE_VPS_DEPLOY=false`) and, if needed, stop the affected containers or roll back to a known-good release.
- Rotate whatever may be exposed: the Discord bot token and OAuth client secret (Developer Portal), the internal API key, database and Redis passwords, deployment SSH keys. Rotating `log_data_encryption_key` requires re-encrypting stored data: do it only if the key itself may have leaked.
- Revoke dashboard sessions (delete the `PanelSession` rows) so everyone signs in again.
2. **Assess — within 24 hours.** Use host, container, Nginx and database logs to establish what was accessed, which servers and users are affected, since when, and whether the encrypted fields or the key were exposed. Keep a written timeline and the evidence.
3. **Notify Discord — promptly, as soon as the incident is confirmed.** Through the [Developer Support form](https://support-dev.discord.com/hc/requests/new), with what happened, the affected data and servers, the actions taken and the remediation in progress. Send updates until it is closed.
4. **Notify users — without undue delay.**
- Administrators of the affected servers: an announcement on the [support server](https://discord.gg/DDBGWP5fMX) and on the website and, where possible, a message in each affected server's default log channel.
- If the breach is likely to result in a high risk for the people involved, inform them directly as well (GDPR art. 34).
- Personal data breach: notify the Garante per la protezione dei dati personali **within 72 hours** of becoming aware of it, unless it is unlikely to result in a risk (GDPR art. 33). Record every breach internally, even when it is not notified.
5. **Recover.** Restore from a known-good release and backup, re-enable deployment and watch the logs closely for the following days.
6. **Learn.** Write a short post-mortem (cause, impact, fixes) and track the fixes as issues; publish a GitHub security advisory when the cause is a vulnerability in Sentinel itself, so self-hosted instances can update.

## Secrets

Expand Down
88 changes: 88 additions & 0 deletions apps/api/src/discord-billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { prisma } from '@sentinel/db';
import { getEntitlements, type Entitlement } from './discord.js';
import { decidePlan, parseSkus, plansFromEntitlements, skuIndex } from './discord-plans.js';
import { applyPlanLimits, systemAudit } from './plan-service.js';

export { PAID_TIERS, SKU_PERIODS, parseSkus, storeLinks } from './discord-plans.js';

type Logger = { warn: (obj: object, msg: string) => void; error: (obj: object, msg: string) => void };

export type SyncResult = { at: string; ok: boolean; entitlements: number; changed: number; error?: string };
let lastSync: SyncResult | null = null;
export const lastDiscordSync = () => lastSync;

export async function configuredSkus() {
const config = await prisma.instanceConfig.findUnique({ where: { id: 1 }, select: { discordSkus: true } });
return parseSkus(config?.discordSkus);
}

/**
* Brings every server's plan in line with the Discord subscriptions. If the
* entitlements cannot be read nothing changes: an outage never downgrades.
*/
export async function syncDiscordPlans(log: Logger): Promise<SyncResult> {
const skus = await configuredSkus();
if (!skuIndex(skus).size) {
lastSync = { at: new Date().toISOString(), ok: true, entitlements: 0, changed: 0 };
return lastSync;
}

let entitlements: Entitlement[];
try {
entitlements = await getEntitlements();
} catch (error) {
log.warn({ err: error }, 'Unable to read Discord entitlements');
lastSync = { at: new Date().toISOString(), ok: false, entitlements: 0, changed: 0, error: 'ENTITLEMENTS_UNAVAILABLE' };
return lastSync;
}

const now = new Date();
const discordPlans = plansFromEntitlements(entitlements, skus, now);
const rows = await prisma.guildSettings.findMany({
where: { OR: [{ planSource: 'DISCORD' }, { guildId: { in: [...discordPlans.keys()] } }] },
select: { guildId: true, planTier: true, planExpiresAt: true, planSource: true, planEntitlementId: true, planBilling: true }
});

let changed = 0;
for (const row of rows) {
const decision = decidePlan(row, discordPlans.get(row.guildId), now);
if (decision.kind === 'keep') continue;
try {
if (decision.kind === 'discord') {
const { plan } = decision;
await prisma.guildSettings.update({
where: { guildId: row.guildId },
data: {
planTier: plan.tier,
planBilling: plan.billing,
planExpiresAt: plan.expiresAt,
planSource: 'DISCORD',
planEntitlementId: plan.entitlementId,
planCouponCode: null,
planUpdatedAt: now,
premiumEnabled: true
}
});
const applied = decision.downgrade ? await applyPlanLimits(row.guildId, plan.tier, log) : null;
if (row.planSource !== 'DISCORD' || row.planTier !== plan.tier || row.planEntitlementId !== plan.entitlementId) {
await systemAudit('plan.discord_subscription', 'GUILD', row.guildId, {
previousTier: row.planTier, tier: plan.tier, billing: plan.billing, entitlementId: plan.entitlementId, applied
});
}
} else {
await prisma.guildSettings.update({
where: { guildId: row.guildId },
data: { planTier: 'FREE', planBilling: null, planExpiresAt: null, planSource: 'MANUAL', planEntitlementId: null, planUpdatedAt: now, premiumEnabled: false }
});
const applied = await applyPlanLimits(row.guildId, 'FREE', log);
await systemAudit('plan.discord_ended', 'GUILD', row.guildId, { previousTier: row.planTier, entitlementId: row.planEntitlementId, applied });
}
changed += 1;
} catch (error) {
log.error({ err: error, guildId: row.guildId }, 'Unable to sync Discord plan');
}
}

lastSync = { at: now.toISOString(), ok: true, entitlements: entitlements.length, changed };
return lastSync;
}
123 changes: 123 additions & 0 deletions apps/api/src/discord-plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { effectiveTier, tierLevel } from '@sentinel/shared';
import type { Entitlement } from './discord.js';

/**
* Paid plans are sold only as Discord Premium Apps guild subscriptions. Each
* plan and billing period maps to a SKU configured in the super console; the
* API reads the application's entitlements through the bot and keeps every
* server's plan in line with them.
*/

export const PAID_TIERS = ['TIER1', 'TIER2', 'TIER3'] as const;
export const SKU_PERIODS = ['MONTHLY', 'YEARLY'] as const;
export type PaidTier = (typeof PAID_TIERS)[number];
export type SkuPeriod = (typeof SKU_PERIODS)[number];
export type DiscordSkus = Partial<Record<PaidTier, Partial<Record<SkuPeriod, string>>>>;

/**
* A subscription keeps the plan for a few days after the end of the paid
* period, so a late renewal or a failed sync never switches loggers off. When
* the entitlement disappears, sync downgrades the server straight away.
*/
export const RENEWAL_GRACE_MS = 3 * 86_400_000;

const SNOWFLAKE = /^\d{17,20}$/;

/** Reads the stored JSON defensively: unknown keys and bad IDs are ignored. */
export function parseSkus(value: unknown): DiscordSkus {
const result: DiscordSkus = {};
if (!value || typeof value !== 'object' || Array.isArray(value)) return result;
for (const tier of PAID_TIERS) {
const periods = (value as Record<string, unknown>)[tier];
if (!periods || typeof periods !== 'object') continue;
for (const period of SKU_PERIODS) {
const id = (periods as Record<string, unknown>)[period];
if (typeof id === 'string' && SNOWFLAKE.test(id)) result[tier] = { ...result[tier], [period]: id };
}
}
return result;
}

export const skuIndex = (skus: DiscordSkus) => {
const index = new Map<string, { tier: PaidTier; period: SkuPeriod }>();
for (const tier of PAID_TIERS) {
for (const period of SKU_PERIODS) {
const id = skus[tier]?.[period];
if (id) index.set(id, { tier, period });
}
}
return index;
};

/** Store pages of the configured SKUs, where people subscribe in Discord. */
export function storeLinks(skus: DiscordSkus, applicationId: string) {
const links = {} as Record<PaidTier, Record<SkuPeriod, string | null>>;
for (const tier of PAID_TIERS) {
links[tier] = { MONTHLY: null, YEARLY: null };
for (const period of SKU_PERIODS) {
const id = skus[tier]?.[period];
if (id) links[tier][period] = `https://discord.com/application-directory/${applicationId}/store/${id}`;
}
}
const enabled = PAID_TIERS.some((tier) => SKU_PERIODS.some((period) => links[tier][period]));
return { enabled, store: `https://discord.com/application-directory/${applicationId}/store`, links };
}

export type DiscordPlan = { tier: PaidTier; billing: SkuPeriod; entitlementId: string; expiresAt: Date | null };

/**
* The best active subscription of each server: the highest plan, and for the
* same plan the one that lasts longer. Entitlements of unknown SKUs, user
* subscriptions and ended ones are ignored.
*/
export function plansFromEntitlements(entitlements: Entitlement[], skus: DiscordSkus, now = new Date()) {
const index = skuIndex(skus);
const best = new Map<string, DiscordPlan>();
for (const entitlement of entitlements) {
const sku = index.get(entitlement.skuId);
if (!sku || !entitlement.guildId) continue;
const endsAt = entitlement.endsAt ? new Date(entitlement.endsAt) : null;
if (endsAt && (Number.isNaN(endsAt.getTime()) || endsAt.getTime() <= now.getTime())) continue;
const candidate: DiscordPlan = {
tier: sku.tier,
billing: sku.period,
entitlementId: entitlement.id,
expiresAt: endsAt ? new Date(endsAt.getTime() + RENEWAL_GRACE_MS) : null
};
const current = best.get(entitlement.guildId);
const longer = (a: Date | null, b: Date | null) => a === null ? b !== null : b !== null && a.getTime() > b.getTime();
if (!current || tierLevel(candidate.tier) > tierLevel(current.tier) ||
(candidate.tier === current.tier && longer(candidate.expiresAt, current.expiresAt))) {
best.set(entitlement.guildId, candidate);
}
}
return best;
}

type StoredPlan = { planTier: string; planExpiresAt: Date | null; planSource: string; planEntitlementId: string | null; planBilling: string | null };

export type PlanDecision =
| { kind: 'keep' }
| { kind: 'discord'; plan: DiscordPlan; downgrade: boolean }
| { kind: 'ended' };

/**
* What sync does with one server. A Discord subscription replaces a manual
* plan unless the manual one is higher and still running (a gift or trial
* above what was bought); a Discord plan without an active subscription ends.
*/
export function decidePlan(stored: StoredPlan, discord: DiscordPlan | undefined, now = new Date()): PlanDecision {
const current = effectiveTier(stored, now);
if (discord) {
if (stored.planSource !== 'DISCORD' && tierLevel(current) > tierLevel(discord.tier)) return { kind: 'keep' };
const same = stored.planSource === 'DISCORD' &&
stored.planTier === discord.tier &&
stored.planBilling === discord.billing &&
stored.planEntitlementId === discord.entitlementId &&
(stored.planExpiresAt?.getTime() ?? null) === (discord.expiresAt?.getTime() ?? null);
if (same) return { kind: 'keep' };
return { kind: 'discord', plan: discord, downgrade: tierLevel(discord.tier) < tierLevel(current) };
}
if (stored.planSource === 'DISCORD') return { kind: 'ended' };
return { kind: 'keep' };
}
7 changes: 7 additions & 0 deletions apps/api/src/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ export async function setBotBranding(guildId: string, profile: { nick?: string |
return api<{ ok: true; guildId: string }>(`/guilds/${id(guildId)}/branding`, 'POST', profile, 15_000);
}

export type Entitlement = { id: string; skuId: string; guildId: string | null; type: number; startsAt: string | null; endsAt: string | null };

/** Active Discord Premium Apps entitlements of the application. */
export async function getEntitlements() {
return api<Entitlement[]>('/entitlements', 'GET', undefined, 15_000);
}

export async function sendStatusTest() {
return api<{ ok: true } | { ok: false; error: string }>('/status/test', 'POST', {}, 10_000);
}
Expand Down
Loading
Loading