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
5 changes: 3 additions & 2 deletions src/api/routes/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ router.get('/', async (req, res) => {
const pool = getRestartPool();
if (pool) {
const rows = await getRestarts(pool, 20);
body.restarts = rows.map(r => ({
timestamp: r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp),
body.restarts = rows.map((r) => ({
timestamp:
r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp),
reason: r.reason || 'unknown',
version: r.version ?? null,
uptimeBefore: r.uptime_seconds ?? null,
Expand Down
4 changes: 1 addition & 3 deletions src/api/ws/logStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,7 @@ function validateTicket(ticket, secret) {
if (!Number.isFinite(expiryNum) || expiryNum <= Date.now()) return false;

// Re-derive HMAC and compare with timing-safe equality
const expected = createHmac('sha256', secret)
.update(`${nonce}.${expiry}`)
.digest('hex');
const expected = createHmac('sha256', secret).update(`${nonce}.${expiry}`).digest('hex');

try {
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hmac, 'hex'));
Expand Down
4 changes: 2 additions & 2 deletions src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
* PostgreSQL connection pool and migration runner
*/

import { fileURLToPath } from 'node:url';
import path from 'node:path';
import pg from 'pg';
import { fileURLToPath } from 'node:url';
import { runner } from 'node-pg-migrate';
import pg from 'pg';
import { info, error as logError } from './logger.js';

const { Pool } = pg;
Expand Down
35 changes: 26 additions & 9 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ import {
setInitialTransport,
} from './config-listeners.js';
import { closeDb, getPool, initDb } from './db.js';
import { addPostgresTransport, addWebSocketTransport, removeWebSocketTransport, debug, error, info, warn } from './logger.js';
import {
addPostgresTransport,
addWebSocketTransport,
debug,
error,
info,
removeWebSocketTransport,
warn,
} from './logger.js';
import {
getConversationHistory,
initConversationHistory,
Expand Down Expand Up @@ -221,7 +229,12 @@ client.on('interactionCreate', async (interaction) => {
await command.execute(interaction);
info('Command executed', { command: commandName, user: interaction.user.tag });
} catch (err) {
error('Command error', { command: commandName, error: err.message, stack: err.stack, source: 'slash_command' });
error('Command error', {
command: commandName,
error: err.message,
stack: err.stack,
source: 'slash_command',
});

const errorMessage = {
content: '❌ An error occurred while executing this command.',
Expand Down Expand Up @@ -435,13 +448,17 @@ async function startup() {
await client.login(token);

// Set Sentry context now that we know the bot identity (no-op if disabled)
import('./sentry.js').then(({ Sentry, sentryEnabled }) => {
if (sentryEnabled) {
Sentry.setTag('bot.username', client.user?.tag || 'unknown');
Sentry.setTag('bot.version', BOT_VERSION);
info('Sentry error monitoring enabled', { environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production' });
}
}).catch(() => {});
import('./sentry.js')
.then(({ Sentry, sentryEnabled }) => {
if (sentryEnabled) {
Sentry.setTag('bot.username', client.user?.tag || 'unknown');
Sentry.setTag('bot.version', BOT_VERSION);
info('Sentry error monitoring enabled', {
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV || 'production',
});
}
})
.catch(() => {});

// Start REST API server with WebSocket log streaming (non-fatal — bot continues without it)
{
Expand Down
2 changes: 1 addition & 1 deletion src/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import { PostgresTransport } from './transports/postgres.js';
import { sentryEnabled } from './sentry.js';
import { PostgresTransport } from './transports/postgres.js';
import { SentryTransport } from './transports/sentry.js';
import { WebSocketTransport } from './transports/websocket.js';

Expand Down
28 changes: 28 additions & 0 deletions src/modules/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { getUserFriendlyMessage } from '../utils/errors.js';
// safe wrapper applies identically to either target type.
import { safeReply } from '../utils/safeSend.js';
import { getConfig } from './config.js';
import { checkLinks } from './linkFilter.js';
import { checkRateLimit } from './rateLimit.js';
import { isSpam, sendSpamAlert } from './spam.js';
import { accumulateMessage, evaluateNow } from './triage.js';
import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js';
Expand Down Expand Up @@ -93,6 +95,32 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) {
// Resolve per-guild config so feature gates respect guild overrides
const guildConfig = getConfig(message.guild.id);

// Rate limit + link filter — both gated on moderation.enabled.
// Each check is isolated so a failure in one doesn't prevent the other from running.
if (guildConfig.moderation?.enabled) {
try {
const { limited } = await checkRateLimit(message, guildConfig);
if (limited) return;
} catch (rlErr) {
logError('Rate limit check failed', {
channelId: message.channel.id,
userId: message.author.id,
error: rlErr?.message,
});
}

try {
const { blocked } = await checkLinks(message, guildConfig);
if (blocked) return;
} catch (lfErr) {
logError('Link filter check failed', {
channelId: message.channel.id,
userId: message.author.id,
error: lfErr?.message,
});
}
}

// Spam detection
if (guildConfig.moderation?.enabled && isSpam(message.content)) {
warn('Spam detected', { userId: message.author.id, contentPreview: '[redacted]' });
Expand Down
176 changes: 176 additions & 0 deletions src/modules/linkFilter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Link Filter Module
* Extracts URLs from messages and checks against a configurable domain blocklist.
* Also detects phishing TLD patterns (.xyz with suspicious keywords).
*/

import { EmbedBuilder } from 'discord.js';
import { warn } from '../logger.js';
import { isExempt } from '../utils/modExempt.js';
import { safeSend } from '../utils/safeSend.js';
import { sanitizeMentions } from '../utils/sanitizeMentions.js';

/**
* Regex to extract URLs from message content.
* Matches http/https URLs and bare domain.tld patterns.
*/
const URL_REGEX =
/https?:\/\/(?:www\.)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z]{2,})+)(\/[^\s]*)?|(?:^|\s)(?:www\.)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z]{2,})+)(\/[^\s]*)?/gi;

/**
* Phishing TLD patterns: .xyz links whose path/subdomain contains scam keywords.
* Catches "discord-nitro-free.xyz", "free-nitro.xyz/claim", etc.
*/
const PHISHING_PATTERNS = [
// .xyz domains with suspicious keywords anywhere in the URL
/(?:discord|nitro|free|gift|giveaway|steam|crypto|nft|airdrop)[a-z0-9\-_.]*\.xyz(?:\/[^\s]*)?/i,
// Any .xyz URL that contains those keywords in the path
/[a-z0-9\-_.]+\.xyz\/[^\s]*(?:discord|nitro|free|gift|steam|crypto)[^\s]*/i,
// Common phishing subdomains regardless of TLD
/(?:discord-nitro|discordnitro|free-nitro|steamgift)\.[a-z]{2,}(?:\/[^\s]*)?/i,
];

/**
* Normalize a domain entry from the blocklist.
* Lowercases the value and strips a leading "www." so that blocklist entries
* are comparable to the already-normalized hostnames extracted by extractUrls().
*
* @param {string} domain
* @returns {string}
*/
function normalizeBlockedDomain(domain) {
return domain.toLowerCase().replace(/^www\./, '');
}

/**
* Extract all hostnames/domains from a message string.
* @param {string} content
* @returns {{ hostname: string, fullUrl: string }[]}
*/
export function extractUrls(content) {
const results = [];
const seen = new Set();
let match;
const regex = new RegExp(URL_REGEX.source, URL_REGEX.flags);

for (match = regex.exec(content); match; match = regex.exec(content)) {
// Group 1: hostname from http(s):// URL, Group 3: bare domain
const hostname = (match[1] || match[3] || '').toLowerCase().replace(/^www\./, '');
const fullUrl = match[0].trim();

if (hostname && !seen.has(hostname)) {
seen.add(hostname);
results.push({ hostname, fullUrl });
}
}

return results;
}

/**
* Check whether the content contains any phishing TLD patterns.
* @param {string} content
* @returns {string|null} matched pattern string or null
*/
export function matchPhishingPattern(content) {
for (const pattern of PHISHING_PATTERNS) {
const m = content.match(pattern);
if (m) return m[0];
}
return null;
}

/**
* Alert the mod channel about a blocked link.
* @param {import('discord.js').Message} message
* @param {Object} config
* @param {string} matchedDomain
* @param {string} reason - 'blocklist' | 'phishing'
*/
async function alertModChannel(message, config, matchedDomain, reason) {
const alertChannelId = config.moderation?.alertChannelId;
if (!alertChannelId) return;

const alertChannel = await message.client.channels.fetch(alertChannelId).catch(() => null);
if (!alertChannel) return;

const embed = new EmbedBuilder()
.setColor(0xed4245)
.setTitle(
`🔗 Suspicious Link ${reason === 'phishing' ? '(Phishing Pattern)' : '(Blocklisted Domain)'} Detected`,
)
.addFields(
{
name: 'User',
value: `<@${message.author.id}> (${sanitizeMentions(message.author.tag)})`,
inline: true,
},
{ name: 'Channel', value: `<#${message.channel.id}>`, inline: true },
{ name: 'Matched', value: `\`${matchedDomain}\``, inline: true },
{ name: 'Content', value: sanitizeMentions(message.content.slice(0, 1000)) || '*empty*' },
)
.setTimestamp();

await safeSend(alertChannel, { embeds: [embed] }).catch(() => {});
}

/**
* Check whether a message contains blocked or suspicious links.
* Deletes the message and alerts the mod channel if a match is found.
*
* @param {import('discord.js').Message} message - Discord message object
* @param {Object} config - Bot config (merged guild config)
* @returns {Promise<{ blocked: boolean, domain?: string }>}
*/
export async function checkLinks(message, config) {
const lfConfig = config.moderation?.linkFilter ?? {};

if (!lfConfig.enabled) return { blocked: false };
if (isExempt(message, config)) return { blocked: false };

const content = message.content;
if (!content) return { blocked: false };

// 1. Check phishing patterns first (fast regex, no list lookup needed)
const phishingMatch = matchPhishingPattern(content);
if (phishingMatch) {
warn('Link filter: phishing pattern detected', {
userId: message.author.id,
channelId: message.channel.id,
match: phishingMatch,
});
await message.delete().catch(() => {});
await alertModChannel(message, config, phishingMatch, 'phishing');
return { blocked: true, domain: phishingMatch };
}

// 2. Check extracted URLs against the configurable domain blocklist.
// Normalize each blocklist entry (lowercase, strip www.) so that
// mixed-case or www-prefixed config entries match correctly.
const rawBlockedDomains = lfConfig.blockedDomains ?? [];
if (rawBlockedDomains.length === 0) return { blocked: false };

const blockedDomains = rawBlockedDomains.map(normalizeBlockedDomain);

const urls = extractUrls(content);
for (const { hostname, fullUrl } of urls) {
// Exact match or subdomain match (e.g. "evil.com" also catches "sub.evil.com")
const matched = blockedDomains.find(
(blocked) => hostname === blocked || hostname.endsWith(`.${blocked}`),
);

if (matched) {
warn('Link filter: blocked domain detected', {
userId: message.author.id,
channelId: message.channel.id,
hostname,
blockedRule: matched,
});
await message.delete().catch(() => {});
await alertModChannel(message, config, hostname || fullUrl, 'blocklist');
return { blocked: true, domain: matched };
}
}

return { blocked: false };
}
Loading
Loading