diff --git a/integrations/discord/.env.example b/integrations/discord/.env.example new file mode 100644 index 0000000..9498aa2 --- /dev/null +++ b/integrations/discord/.env.example @@ -0,0 +1,6 @@ +DISCORD_TOKEN= +DISCORD_CLIENT_ID= +DISCORD_GUILD_ID= +SANCTIFIER_API_URL=http://localhost:3000 +SANCTIFIER_LATEST_URL= +SANCTIFIER_STATUS_URL= diff --git a/integrations/discord/README.md b/integrations/discord/README.md new file mode 100644 index 0000000..f420ab0 --- /dev/null +++ b/integrations/discord/README.md @@ -0,0 +1,69 @@ +# Sanctifier Discord Bot + +Minimal Discord integration for querying Sanctifier findings from slash commands. + +## Commands + +| Command | Purpose | +| --- | --- | +| `/sanctifier explain code:S001` | Explains a canonical Sanctifier finding code. | +| `/sanctifier latest limit:5` | Shows recent findings from the configured latest-report endpoint. | +| `/sanctifier status` | Checks whether the configured Sanctifier endpoint is reachable. | + +## Setup + +1. Create a Discord application and bot at . +2. Copy `.env.example` to `.env` or set the same variables in your deployment environment. +3. Install dependencies: + + ```bash + cd integrations/discord + npm install + ``` + +4. Register slash commands: + + ```bash + npm run register + ``` + + Set `DISCORD_GUILD_ID` while testing so commands appear immediately in one server. Omit it for global registration when deploying. + +5. Start the bot: + + ```bash + npm start + ``` + +## Environment + +| Variable | Required | Description | +| --- | --- | --- | +| `DISCORD_TOKEN` | Yes | Bot token from the Discord developer portal. | +| `DISCORD_CLIENT_ID` | Yes | Application client ID used when registering slash commands. | +| `DISCORD_GUILD_ID` | No | Guild/server ID for fast test registration. | +| `SANCTIFIER_API_URL` | No | Base URL for a hosted Sanctifier dashboard/API. | +| `SANCTIFIER_LATEST_URL` | No | Full URL returning latest report JSON. Overrides `SANCTIFIER_API_URL` for `/latest`. | +| `SANCTIFIER_STATUS_URL` | No | Full URL used by `/status`. Defaults to `SANCTIFIER_API_URL`. | + +The `/latest` command accepts JSON in any of these shapes: + +```json +{ "findings": [] } +``` + +```json +{ "latest": { "findings": [] } } +``` + +```json +{ "report": { "findings": [] } } +``` + +## Deployment Notes + +- Use Node 20 or newer. +- Run `npm run register` during release or whenever command definitions change. +- Run `npm start` as a long-lived process on Fly.io, Render, a VM, or any worker platform that supports outbound Discord gateway connections. +- Keep Discord secrets in the platform secret store, not in the repository. +- For staging, register to a single guild with `DISCORD_GUILD_ID`; for production, use global registration. diff --git a/integrations/discord/package.json b/integrations/discord/package.json new file mode 100644 index 0000000..be9aa0f --- /dev/null +++ b/integrations/discord/package.json @@ -0,0 +1,18 @@ +{ + "name": "@sanctifier/discord-bot", + "version": "0.1.0", + "private": true, + "description": "Discord slash command integration for Sanctifier findings.", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "register": "node src/register-commands.js", + "start": "node src/bot.js", + "test": "node --test" + }, + "dependencies": { + "discord.js": "^14.16.3" + } +} diff --git a/integrations/discord/src/bot.js b/integrations/discord/src/bot.js new file mode 100644 index 0000000..3f8f535 --- /dev/null +++ b/integrations/discord/src/bot.js @@ -0,0 +1,36 @@ +import { Client, Events, GatewayIntentBits } from 'discord.js'; +import { handleInteraction } from './commands.js'; + +const token = process.env.DISCORD_TOKEN; + +if (!token) { + throw new Error('DISCORD_TOKEN is required to start the Sanctifier Discord bot.'); +} + +const client = new Client({ + intents: [GatewayIntentBits.Guilds], +}); + +client.once(Events.ClientReady, (readyClient) => { + console.log(`Sanctifier Discord bot logged in as ${readyClient.user.tag}`); +}); + +client.on(Events.InteractionCreate, async (interaction) => { + try { + await handleInteraction(interaction); + } catch (error) { + console.error('Failed to handle Discord interaction:', error); + + const message = 'Sanctifier could not complete that command. Please try again later.'; + + if (interaction.isRepliable()) { + if (interaction.deferred || interaction.replied) { + await interaction.editReply(message); + } else { + await interaction.reply({ content: message, ephemeral: true }); + } + } + } +}); + +await client.login(token); diff --git a/integrations/discord/src/commands.js b/integrations/discord/src/commands.js new file mode 100644 index 0000000..2dbfd85 --- /dev/null +++ b/integrations/discord/src/commands.js @@ -0,0 +1,68 @@ +import { SlashCommandBuilder } from 'discord.js'; +import { formatFindingExplanation } from './findings.js'; +import { fetchLatestFindings, formatLatestFindings } from './latest.js'; +import { fetchStatus, formatStatus } from './status.js'; + +export const commandData = [ + new SlashCommandBuilder() + .setName('sanctifier') + .setDescription('Query Sanctifier findings and service status.') + .addSubcommand((subcommand) => + subcommand + .setName('explain') + .setDescription('Explain a Sanctifier finding code.') + .addStringOption((option) => + option + .setName('code') + .setDescription('Finding code, such as S001.') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('latest') + .setDescription('Show the latest Sanctifier findings from the configured API.') + .addIntegerOption((option) => + option + .setName('limit') + .setDescription('Maximum findings to show.') + .setMinValue(1) + .setMaxValue(10), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('status') + .setDescription('Check whether the configured Sanctifier endpoint is reachable.'), + ), +].map((command) => command.toJSON()); + +export async function handleInteraction(interaction) { + if (!interaction.isChatInputCommand()) return; + if (interaction.commandName !== 'sanctifier') return; + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'explain') { + const code = interaction.options.getString('code', true); + await interaction.reply({ + content: formatFindingExplanation(code), + ephemeral: true, + }); + return; + } + + if (subcommand === 'latest') { + await interaction.deferReply({ ephemeral: true }); + const limit = interaction.options.getInteger('limit') || 5; + const latest = await fetchLatestFindings(); + await interaction.editReply(formatLatestFindings(latest, limit)); + return; + } + + if (subcommand === 'status') { + await interaction.deferReply({ ephemeral: true }); + const status = await fetchStatus(); + await interaction.editReply(formatStatus(status)); + } +} diff --git a/integrations/discord/src/findings.js b/integrations/discord/src/findings.js new file mode 100644 index 0000000..5425b81 --- /dev/null +++ b/integrations/discord/src/findings.js @@ -0,0 +1,117 @@ +export const FINDINGS = { + S001: { + title: 'Missing require_auth', + severity: 'critical', + summary: 'A state-changing path can run without a Soroban authorization guard.', + remediation: 'Require the caller, owner, admin, or affected address to call require_auth before mutating state.', + }, + S002: { + title: 'Panic, unwrap, or expect in contract path', + severity: 'high', + summary: 'A runtime panic can lock execution or hide recoverable error handling.', + remediation: 'Return explicit errors and handle fallible calls without panicking.', + }, + S003: { + title: 'Unchecked arithmetic', + severity: 'high', + summary: 'Overflow, underflow, or truncation can silently distort balances or limits.', + remediation: 'Use checked arithmetic and return a typed error when an operation cannot be represented safely.', + }, + S004: { + title: 'Ledger entry size pressure', + severity: 'medium', + summary: 'A write can exceed or approach Soroban ledger entry limits.', + remediation: 'Split large state, cap collection sizes, or store only stable references on chain.', + }, + S005: { + title: 'Storage key collision', + severity: 'high', + summary: 'Multiple data paths can write through the same storage key.', + remediation: 'Namespace keys per feature and include the minimum required discriminators.', + }, + S006: { + title: 'Unsafe pattern', + severity: 'medium', + summary: 'The contract uses a pattern that is risky in deterministic smart contract execution.', + remediation: 'Replace timestamp-derived randomness and other unsafe shortcuts with deterministic, auditable flows.', + }, + S007: { + title: 'Custom rule match', + severity: 'medium', + summary: 'A project-specific rule matched code that the team chose to block or review.', + remediation: 'Check the custom rule text and either fix the code or document a deliberate suppression.', + }, + S008: { + title: 'Event emission issue', + severity: 'low', + summary: 'Important state changes may not emit consistent events for wallets and indexers.', + remediation: 'Emit stable, documented events for externally visible state transitions.', + }, + S009: { + title: 'Unhandled Result', + severity: 'high', + summary: 'A fallible call returns a Result that is not checked.', + remediation: 'Match on the Result, propagate failures, and avoid treating failed operations as successful.', + }, + S010: { + title: 'Upgrade or governance risk', + severity: 'high', + summary: 'Admin, upgrade, or governance paths can create takeover or recovery risk.', + remediation: 'Use multisig, timelocks, clear authorization checks, and documented emergency paths.', + }, + S011: { + title: 'Invariant violation', + severity: 'critical', + summary: 'A formal property could not be proven or was disproved by the solver.', + remediation: 'Review the counterexample, tighten preconditions, or fix the state transition that breaks the invariant.', + }, + S012: { + title: 'SEP-41 interface deviation', + severity: 'medium', + summary: 'A token contract does not match expected SEP-41 behavior.', + remediation: 'Align exported functions, auth checks, and error behavior with the SEP-41 interface.', + }, +}; + +export function normalizeFindingCode(value) { + const normalized = String(value || '').trim().toUpperCase(); + const match = normalized.match(/S?(\d{1,3})/); + + if (!match) return ''; + + return `S${match[1].padStart(3, '0')}`; +} + +export function explainFinding(value) { + const code = normalizeFindingCode(value); + const finding = FINDINGS[code]; + + if (!finding) { + return { + found: false, + code: code || String(value || '').trim(), + message: 'Unknown finding code. Try one of S001 through S012.', + }; + } + + return { + found: true, + code, + ...finding, + }; +} + +export function formatFindingExplanation(value) { + const explanation = explainFinding(value); + + if (!explanation.found) { + return explanation.message; + } + + return [ + `**${explanation.code}: ${explanation.title}**`, + `Severity: ${explanation.severity}`, + explanation.summary, + `Fix: ${explanation.remediation}`, + ].join('\n'); +} diff --git a/integrations/discord/src/latest.js b/integrations/discord/src/latest.js new file mode 100644 index 0000000..0ad6adf --- /dev/null +++ b/integrations/discord/src/latest.js @@ -0,0 +1,78 @@ +function getLatestUrl() { + if (process.env.SANCTIFIER_LATEST_URL) { + return process.env.SANCTIFIER_LATEST_URL; + } + + if (process.env.SANCTIFIER_API_URL) { + return new URL('/api/reports/latest', process.env.SANCTIFIER_API_URL).toString(); + } + + return ''; +} + +function asFindingList(payload) { + if (Array.isArray(payload)) return payload; + if (Array.isArray(payload?.findings)) return payload.findings; + if (Array.isArray(payload?.latest?.findings)) return payload.latest.findings; + if (Array.isArray(payload?.report?.findings)) return payload.report.findings; + + return []; +} + +export async function fetchLatestFindings({ fetchImpl = fetch } = {}) { + const latestUrl = getLatestUrl(); + + if (!latestUrl) { + return { + state: 'not_configured', + findings: [], + source: '', + }; + } + + const response = await fetchImpl(latestUrl, { + headers: { accept: 'application/json' }, + }); + + if (!response.ok) { + return { + state: 'unavailable', + findings: [], + source: latestUrl, + status: response.status, + }; + } + + const payload = await response.json(); + + return { + state: 'ok', + findings: asFindingList(payload), + source: latestUrl, + }; +} + +export function formatLatestFindings(result, limit = 5) { + if (result.state === 'not_configured') { + return 'Latest findings are not configured yet. Set SANCTIFIER_LATEST_URL or SANCTIFIER_API_URL for this bot.'; + } + + if (result.state === 'unavailable') { + return `Could not load latest findings from ${result.source} (HTTP ${result.status}).`; + } + + if (!result.findings.length) { + return 'No findings returned by the latest report endpoint.'; + } + + const lines = result.findings.slice(0, limit).map((finding, index) => { + const code = finding.code || `#${index + 1}`; + const severity = finding.severity ? ` (${finding.severity})` : ''; + const title = finding.title || finding.message || finding.category || 'Untitled finding'; + const location = finding.location ? ` - ${finding.location}` : ''; + + return `${index + 1}. ${code}${severity}: ${title}${location}`; + }); + + return [`Latest Sanctifier findings from ${result.source}:`, ...lines].join('\n'); +} diff --git a/integrations/discord/src/register-commands.js b/integrations/discord/src/register-commands.js new file mode 100644 index 0000000..4a2204a --- /dev/null +++ b/integrations/discord/src/register-commands.js @@ -0,0 +1,26 @@ +import { REST, Routes } from 'discord.js'; +import { commandData } from './commands.js'; + +const token = process.env.DISCORD_TOKEN; +const clientId = process.env.DISCORD_CLIENT_ID; +const guildId = process.env.DISCORD_GUILD_ID; + +if (!token) { + throw new Error('DISCORD_TOKEN is required to register slash commands.'); +} + +if (!clientId) { + throw new Error('DISCORD_CLIENT_ID is required to register slash commands.'); +} + +const rest = new REST({ version: '10' }).setToken(token); +const route = guildId + ? Routes.applicationGuildCommands(clientId, guildId) + : Routes.applicationCommands(clientId); + +await rest.put(route, { body: commandData }); + +console.log( + `Registered ${commandData.length} Sanctifier Discord command(s)` + + (guildId ? ` for guild ${guildId}.` : ' globally.'), +); diff --git a/integrations/discord/src/status.js b/integrations/discord/src/status.js new file mode 100644 index 0000000..e1c4e3f --- /dev/null +++ b/integrations/discord/src/status.js @@ -0,0 +1,57 @@ +function getStatusUrl() { + if (process.env.SANCTIFIER_STATUS_URL) { + return process.env.SANCTIFIER_STATUS_URL; + } + + if (process.env.SANCTIFIER_API_URL) { + return process.env.SANCTIFIER_API_URL; + } + + return ''; +} + +export async function fetchStatus({ fetchImpl = fetch } = {}) { + const statusUrl = getStatusUrl(); + + if (!statusUrl) { + return { + state: 'not_configured', + source: '', + }; + } + + try { + const response = await fetchImpl(statusUrl, { + method: 'HEAD', + cache: 'no-store', + }); + + return { + state: response.ok ? 'online' : 'degraded', + source: statusUrl, + status: response.status, + }; + } catch (error) { + return { + state: 'offline', + source: statusUrl, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function formatStatus(result) { + if (result.state === 'not_configured') { + return 'Sanctifier status is not configured yet. Set SANCTIFIER_API_URL or SANCTIFIER_STATUS_URL.'; + } + + if (result.state === 'online') { + return `Sanctifier is reachable at ${result.source}.`; + } + + if (result.state === 'degraded') { + return `Sanctifier responded from ${result.source}, but with HTTP ${result.status}.`; + } + + return `Sanctifier is not reachable at ${result.source}: ${result.error}`; +} diff --git a/integrations/discord/test/findings.test.js b/integrations/discord/test/findings.test.js new file mode 100644 index 0000000..8c4adfd --- /dev/null +++ b/integrations/discord/test/findings.test.js @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + explainFinding, + formatFindingExplanation, + normalizeFindingCode, +} from '../src/findings.js'; +import { formatLatestFindings } from '../src/latest.js'; +import { formatStatus } from '../src/status.js'; + +test('normalizes finding codes', () => { + assert.equal(normalizeFindingCode('s1'), 'S001'); + assert.equal(normalizeFindingCode(' S012 '), 'S012'); +}); + +test('explains known finding codes', () => { + const explanation = explainFinding('S001'); + + assert.equal(explanation.found, true); + assert.equal(explanation.code, 'S001'); + assert.match(explanation.summary, /authorization/i); +}); + +test('formats unknown finding codes with a useful hint', () => { + assert.match(formatFindingExplanation('S999'), /Unknown finding code/); +}); + +test('formats latest findings payloads', () => { + const output = formatLatestFindings({ + state: 'ok', + source: 'https://sanctifier.example/reports/latest', + findings: [ + { + code: 'S003', + severity: 'high', + title: 'Unchecked subtraction', + location: 'src/lib.rs:42', + }, + ], + }); + + assert.match(output, /S003/); + assert.match(output, /src\/lib\.rs:42/); +}); + +test('formats status states', () => { + assert.match(formatStatus({ state: 'online', source: 'https://sanctifier.example' }), /reachable/); + assert.match(formatStatus({ state: 'not_configured', source: '' }), /SANCTIFIER_API_URL/); +});