supamend
Version:
Pluggable DevSecOps Security Scanner with 10+ scanners and multiple reporting channels
335 lines ⢠14.3 kB
JavaScript
;
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