supamend
Version:
Pluggable DevSecOps Security Scanner with 10+ scanners and multiple reporting channels
264 lines • 11.5 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DiscordReporter = void 0;
const errors_1 = require("../core/errors");
const retry_1 = require("../core/retry");
const axios_1 = __importDefault(require("axios"));
class DiscordReporter {
constructor() {
this.name = 'discord';
this.description = 'Send security scan results to Discord channel via webhook';
this.version = '1.0.0';
}
async init(config) {
const webhookUrl = config?.webhookUrl || process.env.DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
throw new Error('Discord webhook URL is required. Set DISCORD_WEBHOOK_URL environment variable or provide in config.');
}
this.config = {
webhookUrl,
username: config?.username || 'SupaMend Security Scanner',
avatarUrl: config?.avatarUrl || 'https://cdn.discordapp.com/emojis/🛡️.png',
threadId: config?.threadId
};
// Validate webhook URL format
if (!webhookUrl.includes('discord.com/api/webhooks/')) {
throw new Error('Invalid Discord webhook URL format');
}
}
async report(results, options) {
if (!this.config) {
throw new Error('Discord reporter not initialized');
}
try {
const message = this.generateDiscordMessage(results, options);
const result = await (0, retry_1.retryWithCondition)(async () => {
const response = await axios_1.default.post(this.config.webhookUrl, message, {
headers: {
'Content-Type': 'application/json'
},
timeout: 30000 // 30 seconds timeout
});
if (response.status !== 204) {
throw new Error(`Discord API error: ${response.status} ${response.statusText}`);
}
return response.data;
}, (error) => {
const retryableErrors = ['timeout', 'network', 'connection', 'temporary', 'rate limit'];
return retryableErrors.some(err => error.message.toLowerCase().includes(err));
}, {
maxAttempts: 3,
baseDelay: 1000,
maxDelay: 10000
});
if (!result.success) {
throw result.error || new Error('Failed to send Discord message');
}
console.log(`Security scan results sent to Discord channel`);
}
catch (error) {
const reporterError = error instanceof Error ? error : new Error(String(error));
throw new errors_1.ReporterError(`Failed to send Discord report: ${reporterError.message}`, this.name, {
recoverable: true,
retryable: this.isRetryableError(reporterError),
cause: reporterError,
context: { operation: 'report', webhookUrl: this.config?.webhookUrl }
});
}
}
async isAvailable() {
return !!process.env.DISCORD_WEBHOOK_URL;
}
getConfigSchema() {
return {
type: 'object',
properties: {
webhookUrl: {
type: 'string',
description: 'Discord webhook URL'
},
username: {
type: 'string',
description: 'Custom username for the bot'
},
avatarUrl: {
type: 'string',
description: 'Custom avatar URL for the bot'
},
threadId: {
type: 'string',
description: 'Thread ID to send message in thread'
}
},
required: ['webhookUrl']
};
}
generateDiscordMessage(results, options) {
const summary = this.generateSummary(results);
const severityColors = {
critical: 0xdc3545, // Red
high: 0xfd7e14, // Orange
medium: 0xffc107, // Yellow
low: 0x28a745 // Green
};
const embeds = [];
// Main summary embed
const summaryEmbed = {
title: '🔒 Security Scan Results',
description: `Found **${results.length}** security issues in the latest scan.`,
color: results.length > 0 ? severityColors.critical : 0x28a745,
timestamp: new Date().toISOString(),
fields: []
};
// Add severity breakdown
if (Object.keys(summary.severity).length > 0) {
const severityText = Object.entries(summary.severity)
.map(([sev, count]) => `${sev.toUpperCase()}: **${count}**`)
.join(' | ');
summaryEmbed.fields.push({
name: 'Severity Breakdown',
value: severityText,
inline: false
});
}
// Add scanner breakdown
if (Object.keys(summary.scanners).length > 0) {
const scannerText = Object.entries(summary.scanners)
.map(([scanner, count]) => `**${scanner}**: ${count}`)
.join(' • ');
summaryEmbed.fields.push({
name: '🔍 Scanners Used',
value: scannerText,
inline: false
});
}
// Add repository info if available
const repoUrl = options?.repo;
if (repoUrl && typeof repoUrl === 'string') {
let repoName = repoUrl;
if (repoUrl.includes('github.com')) {
const match = repoUrl.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?(?:\/|$)/);
if (match && match[1]) {
repoName = match[1];
}
}
summaryEmbed.fields.push({
name: '📦 Repository',
value: repoUrl.startsWith('http') ? `[${repoName}](${repoUrl})` : repoName,
inline: true
});
}
// Add scan timestamp
summaryEmbed.fields.push({
name: '⏰ Scan Time',
value: new Date().toLocaleString(),
inline: true
});
// Add total files scanned if available
const uniqueFiles = [...new Set(results.map(r => r.file).filter(Boolean))];
if (uniqueFiles.length > 0) {
summaryEmbed.fields.push({
name: '📁 Files Affected',
value: `${uniqueFiles.length} files`,
inline: true
});
}
embeds.push(summaryEmbed);
// Add critical issues embed
const criticalIssues = results.filter(r => r.severity === 'critical');
if (criticalIssues.length > 0) {
const criticalEmbed = {
title: '🚨 Critical Issues',
color: severityColors.critical,
fields: criticalIssues.slice(0, 5).map(issue => ({
name: `${issue.title}`,
value: `**Scanner:** ${issue.scanner}\n**Type:** ${issue.type}\n**Description:** ${issue.description?.substring(0, 100) || 'No description'}${issue.description && issue.description.length > 100 ? '...' : ''}\n**File:** \`${issue.file || 'N/A'}${issue.line ? `:${issue.line}` : ''}\`${issue.rule ? `\n**Rule:** ${issue.rule}` : ''}`,
inline: false
})),
footer: {
text: `Showing ${Math.min(criticalIssues.length, 5)} of ${criticalIssues.length} critical issues`
}
};
embeds.push(criticalEmbed);
}
// Add high priority issues embed
const highIssues = results.filter(r => r.severity === 'high');
if (highIssues.length > 0) {
const highEmbed = {
title: '⚠️ High Priority Issues',
color: severityColors.high,
fields: highIssues.slice(0, 5).map(issue => ({
name: `${issue.title}`,
value: `**Scanner:** ${issue.scanner}\n**Type:** ${issue.type}\n**Description:** ${issue.description?.substring(0, 100) || 'No description'}${issue.description && issue.description.length > 100 ? '...' : ''}\n**File:** \`${issue.file || 'N/A'}${issue.line ? `:${issue.line}` : ''}\`${issue.rule ? `\n**Rule:** ${issue.rule}` : ''}`,
inline: false
})),
footer: {
text: `Showing ${Math.min(highIssues.length, 5)} of ${highIssues.length} high priority issues`
}
};
embeds.push(highEmbed);
}
// Add medium priority issues if there are few
const mediumIssues = results.filter(r => r.severity === 'medium');
if (mediumIssues.length > 0 && mediumIssues.length <= 3) {
const mediumEmbed = {
title: '⚠️ Medium Priority Issues',
color: severityColors.medium,
fields: mediumIssues.map(issue => ({
name: `${issue.title}`,
value: `**Scanner:** ${issue.scanner}\n**Type:** ${issue.type}\n**Description:** ${issue.description?.substring(0, 100) || 'No description'}${issue.description && issue.description.length > 100 ? '...' : ''}\n**File:** \`${issue.file || 'N/A'}${issue.line ? `:${issue.line}` : ''}\`${issue.rule ? `\n**Rule:** ${issue.rule}` : ''}`,
inline: false
}))
};
embeds.push(mediumEmbed);
}
else if (mediumIssues.length > 3) {
summaryEmbed.fields.push({
name: 'Medium Issues',
value: `${mediumIssues.length} medium priority issues found. Check full report for details.`,
inline: false
});
}
return {
...(this.config.username && { username: this.config.username }),
...(this.config.avatarUrl && { avatar_url: this.config.avatarUrl }),
content: results.length > 0 ? 'Security scan completed with issues found!' : 'Security scan completed - no issues found! 🎉',
embeds,
...(this.config.threadId && { thread_id: this.config.threadId })
};
}
generateSummary(results) {
const severityCounts = results.reduce((acc, result) => {
acc[result.severity] = (acc[result.severity] || 0) + 1;
return acc;
}, {});
const scannerCounts = results.reduce((acc, result) => {
acc[result.scanner] = (acc[result.scanner] || 0) + 1;
return acc;
}, {});
return {
severity: severityCounts,
scanners: scannerCounts
};
}
/**
* Check if an error is retryable
*/
isRetryableError(error) {
const retryablePatterns = [
'timeout',
'network',
'connection',
'temporary',
'rate limit',
'resource temporarily unavailable'
];
return retryablePatterns.some(pattern => error.message.toLowerCase().includes(pattern));
}
}
exports.DiscordReporter = DiscordReporter;
exports.default = new DiscordReporter();
//# sourceMappingURL=discord.js.map