Skip to content
Open
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
6 changes: 6 additions & 0 deletions integrations/discord/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
DISCORD_GUILD_ID=
SANCTIFIER_API_URL=http://localhost:3000
SANCTIFIER_LATEST_URL=
SANCTIFIER_STATUS_URL=
69 changes: 69 additions & 0 deletions integrations/discord/README.md
Original file line number Diff line number Diff line change
@@ -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 <https://discord.com/developers/applications>.
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.
18 changes: 18 additions & 0 deletions integrations/discord/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
36 changes: 36 additions & 0 deletions integrations/discord/src/bot.js
Original file line number Diff line number Diff line change
@@ -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);
68 changes: 68 additions & 0 deletions integrations/discord/src/commands.js
Original file line number Diff line number Diff line change
@@ -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));
}
}
117 changes: 117 additions & 0 deletions integrations/discord/src/findings.js
Original file line number Diff line number Diff line change
@@ -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');
}
78 changes: 78 additions & 0 deletions integrations/discord/src/latest.js
Original file line number Diff line number Diff line change
@@ -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');
}
Loading