UNPKG

supamend

Version:

Pluggable DevSecOps Security Scanner with 10+ scanners and multiple reporting channels

335 lines • 14.3 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SlackReporter = void 0; const errors_1 = require("../core/errors"); const retry_1 = require("../core/retry"); const axios_1 = __importDefault(require("axios")); class SlackReporter { constructor() { this.name = 'slack'; this.description = 'Send security scan results to Slack channel'; this.version = '1.0.0'; this.slackApiUrl = 'https://slack.com/api/chat.postMessage'; } async init(config) { const webhookUrl = config?.webhookUrl || process.env.SLACK_WEBHOOK_URL; const token = config?.token || process.env.SLACK_TOKEN; const channel = config?.channel || process.env.SLACK_CHANNEL; // Support both webhook and token methods if (webhookUrl) { this.config = { webhookUrl, username: config?.username || 'SupaMend Security Scanner', icon_emoji: config?.icon_emoji || ':shield:' }; } else if (token && channel) { this.config = { token, channel, username: config?.username || 'SupaMend Security Scanner', icon_emoji: config?.icon_emoji || ':shield:', thread_ts: config?.thread_ts }; // Validate token format if (!token.startsWith('xoxb-') && !token.startsWith('xoxp-')) { throw new Error('Invalid Slack token format. Must start with xoxb- or xoxp-'); } } else { throw new Error('Slack webhook URL or (token + channel) is required. Set SLACK_WEBHOOK_URL or (SLACK_TOKEN + SLACK_CHANNEL) environment variables.'); } } async report(results, options) { if (!this.config) { throw new Error('Slack reporter not initialized'); } try { const message = this.generateSlackMessage(results, options); const result = await (0, retry_1.retryWithCondition)(async () => { let response; if (this.config.webhookUrl) { // Use webhook URL (simpler method) response = await axios_1.default.post(this.config.webhookUrl, message, { headers: { 'Content-Type': 'application/json' }, timeout: 30000, httpsAgent: new (require('https').Agent)({ rejectUnauthorized: false }) }); if (response.status !== 200) { throw new Error(`Slack webhook error: ${response.status} ${response.statusText}`); } } else { // Use API with token response = await axios_1.default.post(this.slackApiUrl, message, { headers: { 'Authorization': `Bearer ${this.config.token}`, 'Content-Type': 'application/json' }, timeout: 30000, httpsAgent: new (require('https').Agent)({ rejectUnauthorized: false }) }); if (!response.data.ok) { throw new Error(`Slack API error: ${response.data.error}`); } } return response.data; }, (error) => { console.error('Slack error details:', error.message); const retryableErrors = ['rate_limited', 'timeout', 'network_error', 'temporary']; return retryableErrors.some(err => error.message.toLowerCase().includes(err)); }, { maxAttempts: 2, baseDelay: 1000, maxDelay: 5000 }); if (!result.success) { console.error('Slack reporter failed:', result.error?.message); throw result.error || new Error('Failed to send Slack message'); } console.log(`Security scan results sent to Slack`); } catch (error) { const reporterError = error instanceof Error ? error : new Error(String(error)); console.error('Slack reporter error:', reporterError.message); throw new errors_1.ReporterError(`Failed to send Slack report: ${reporterError.message}`, this.name, { recoverable: true, retryable: this.isRetryableError(reporterError), cause: reporterError, context: { operation: 'report', channel: this.config?.channel } }); } } async isAvailable() { return !!(process.env.SLACK_WEBHOOK_URL || (process.env.SLACK_TOKEN && process.env.SLACK_CHANNEL)); } getConfigSchema() { return { type: 'object', properties: { token: { type: 'string', description: 'Slack Bot User OAuth Token' }, channel: { type: 'string', description: 'Slack channel name or ID' }, username: { type: 'string', description: 'Custom username for the bot' }, icon_emoji: { type: 'string', description: 'Custom emoji icon for the bot' }, thread_ts: { type: 'string', description: 'Thread timestamp to reply in thread' } }, required: ['token', 'channel'] }; } generateSlackMessage(results, options) { const summary = this.generateSummary(results); const scanTime = new Date().toLocaleString(); let repoName = options?.repo || 'Repository'; let repoUrl = ''; // Extract repo name and URL for GitHub repos if (typeof repoName === 'string' && repoName.includes('github.com')) { repoUrl = repoName; const match = repoName.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?(?:\/|$)/); if (match) { repoName = match[1]; } } const text = results.length > 0 ? `🚨 Security Alert: ${results.length} issues found in ${repoName}` : `āœ… Security Scan Clean: No issues found in ${repoName}`; const blocks = [ { type: 'header', text: { type: 'plain_text', text: 'šŸ”’ SupaMend Security Scan Results' } }, { type: 'section', fields: [ { type: 'mrkdwn', text: repoUrl ? `*Repository:*\n<${repoUrl}|${repoName}>` : `*Repository:*\n${repoName}` }, { type: 'mrkdwn', text: `*Scan Time:*\n${scanTime}` }, { type: 'mrkdwn', text: `*Total Issues:*\n${results.length}` }, { type: 'mrkdwn', text: `*Scanners Used:*\n${Object.keys(summary.scanners).length}` } ] } ]; if (results.length > 0) { // Add severity breakdown with emojis const severityEmojis = { critical: 'šŸ”“', high: '🟠', medium: '🟔', low: '🟢' }; const severityText = Object.entries(summary.severity) .map(([sev, count]) => `${severityEmojis[sev] || '⚪'} *${sev.toUpperCase()}:* ${count}`) .join('\n'); blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*Severity Breakdown:*\n${severityText}` } }); // Add scanner breakdown const scannerText = Object.entries(summary.scanners) .map(([scanner, count]) => `• ${scanner}: ${count} issues`) .join('\n'); blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*Scanner Results:*\n${scannerText}` } }); // Add critical issues with details const criticalIssues = results.filter(r => r.severity === 'critical').slice(0, 3); if (criticalIssues.length > 0) { const criticalText = criticalIssues.map(issue => `🚨 *${issue.title}*\n` + ` šŸ“ File: \`${issue.file || 'N/A'}\`${issue.line ? `:${issue.line}` : ''}\n` + ` šŸ” Scanner: ${issue.scanner} | Type: ${issue.type}\n` + ` šŸ“ ${(issue.description || 'No description').substring(0, 120)}${(issue.description || '').length > 120 ? '...' : ''}` + `${issue.rule ? `\n šŸ“‹ Rule: ${issue.rule}` : ''}`).join('\n\n'); blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*🚨 Critical Issues (${criticalIssues.length}/${results.filter(r => r.severity === 'critical').length}):*\n${criticalText}` } }); } // Add high priority issues const highIssues = results.filter(r => r.severity === 'high').slice(0, 5); if (highIssues.length > 0) { const highText = highIssues.map(issue => `āš ļø *${issue.title}* (${issue.scanner})\n` + ` šŸ“ \`${issue.file || 'N/A'}\`${issue.line ? `:${issue.line}` : ''} | Type: ${issue.type}\n` + ` šŸ“ ${(issue.description || 'No description').substring(0, 80)}${(issue.description || '').length > 80 ? '...' : ''}`).join('\n\n'); blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*āš ļø High Priority Issues (${highIssues.length}/${results.filter(r => r.severity === 'high').length}):*\n${highText}` } }); } // Add medium issues summary if any const mediumIssues = results.filter(r => r.severity === 'medium'); if (mediumIssues.length > 0) { const mediumText = mediumIssues.slice(0, 3).map(issue => `🟔 *${issue.title}* (${issue.scanner}) - \`${issue.file || 'N/A'}\`${issue.line ? `:${issue.line}` : ''}`).join('\n'); blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*🟔 Medium Priority Issues (${Math.min(mediumIssues.length, 3)}/${mediumIssues.length}):*\n${mediumText}${mediumIssues.length > 3 ? '\n_...and more_' : ''}` } }); } // Add files affected summary const uniqueFiles = [...new Set(results.map(r => r.file).filter(Boolean))]; if (uniqueFiles.length > 0) { blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*šŸ“ Files Affected (${uniqueFiles.length}):*\n${uniqueFiles.slice(0, 10).map(f => `• \`${f}\``).join('\n')}${uniqueFiles.length > 10 ? '\n_...and more_' : ''}` } }); } } else { // No issues found blocks.push({ type: 'section', text: { type: 'mrkdwn', text: 'šŸŽ‰ *Excellent!* No security issues detected.\nāœ… Your code is secure and ready for deployment.' } }); } // Add footer blocks.push({ type: 'context', elements: [ { type: 'mrkdwn', text: `šŸ›”ļø Powered by SupaMend Security Scanner | Scan ID: ${Date.now()}` } ] }); const result = { text, blocks, ...(this.config.username && { username: this.config.username }), ...(this.config.icon_emoji && { icon_emoji: this.config.icon_emoji }), ...(this.config.thread_ts && { thread_ts: this.config.thread_ts }) }; if (this.config.channel) { result.channel = this.config.channel; } return result; } 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 = [ 'rate_limited', 'timeout', 'network', 'connection', 'temporary', 'resource temporarily unavailable' ]; return retryablePatterns.some(pattern => error.message.toLowerCase().includes(pattern)); } } exports.SlackReporter = SlackReporter; exports.default = new SlackReporter(); //# sourceMappingURL=slack.js.map