fylgja/src/handlers/sigma/sigma_action_handlers.js
Charlotte Croce 7988853b57 first commit
2025-04-07 12:22:06 -04:00

552 lines
No EOL
19 KiB
JavaScript

/**
* sigma_action_handlers.js
*
* Centralized action handlers for Sigma-related Slack interactions
*/
const logger = require('../../utils/logger');
const { handleError } = require('../../utils/error_handler');
const { explainSigmaRule, getSigmaRuleYaml } = require('../../services/sigma/sigma_details_service');
const { convertRuleToBackend } = require('../../services/sigma/sigma_backend_converter');
const { searchSigmaRules } = require('../../services/sigma/sigma_search_service');
const { getYamlViewBlocks } = require('../../blocks/sigma_view_yaml_block');
const { getSearchResultBlocks } = require('../../blocks/sigma_search_results_block');
const { getConversionResultBlocks } = require('../../blocks/sigma_conversion_block');
const { getRuleExplanationBlocks } = require('../../blocks/sigma_details_block');
const { sendRuleToSiem } = require('../../services/elastic/elastic_api_service');
const { SIGMA_CLI_CONFIG, ELASTICSEARCH_CONFIG } = require('../../config/appConfig');
const FILE_NAME = 'sigma_action_handlers.js';
/**
* Process and display details for a Sigma rule
*
* @param {string} ruleId - The ID of the rule to get details for
* @param {Function} respond - Function to send response back to Slack
* @param {boolean} replaceOriginal - Whether to replace the original message
* @param {string} responseType - Response type (ephemeral or in_channel)
* @returns {Promise<void>}
*/
const processRuleDetails = async (ruleId, respond, replaceOriginal = false, responseType = 'in_channel') => {
try {
if (!ruleId) {
logger.warn(`${FILE_NAME}: Missing rule ID in processRuleDetails`);
await respond({
text: 'Error: Missing rule ID for details',
replace_original: replaceOriginal,
response_type: responseType
});
return;
}
logger.info(`${FILE_NAME}: Processing details for sigma rule: ${ruleId}`);
// Get Sigma rule details
logger.info(`${FILE_NAME}: Calling explainSigmaRule with ID: '${ruleId}'`);
const result = await explainSigmaRule(ruleId);
if (!result.success) {
logger.error(`${FILE_NAME}: Rule details retrieval failed: ${result.message}`);
await respond({
text: `Error: ${result.message}`,
replace_original: replaceOriginal,
response_type: responseType
});
return;
}
if (!result.explanation) {
logger.error(`${FILE_NAME}: Rule details succeeded but no explanation object was returned`);
await respond({
text: 'Error: Generated details were empty',
replace_original: replaceOriginal,
response_type: responseType
});
return;
}
logger.info(`${FILE_NAME}: Rule ${ruleId} details retrieved successfully`);
// Generate blocks
let blocks;
try {
blocks = getRuleExplanationBlocks(result.explanation);
} catch (blockError) {
await handleError(blockError, `${FILE_NAME}: Block generation`, respond, {
replaceOriginal: replaceOriginal,
responseType: responseType,
customMessage: `Rule ${result.explanation.id}: ${result.explanation.title}\n${result.explanation.description}`
});
return;
}
// Respond with the details
await respond({
blocks: blocks,
replace_original: replaceOriginal,
response_type: responseType
});
} catch (error) {
await handleError(error, `${FILE_NAME}: Process rule details`, respond, {
replaceOriginal: replaceOriginal,
responseType: responseType
});
}
};
/**
* Process and convert a Sigma rule to the target backend format
*
* @param {string} ruleId - The ID of the rule to convert
* @param {Object} config - Configuration for the conversion (backend, target, format)
* @param {Function} respond - Function to send response back to Slack
* @param {boolean} replaceOriginal - Whether to replace the original message
* @param {string} responseType - Response type (ephemeral or in_channel)
* @returns {Promise<void>}
*/
const processRuleConversion = async (ruleId, config, respond, replaceOriginal = false, responseType = 'in_channel') => {
try {
if (!ruleId) {
logger.warn(`${FILE_NAME}: Missing rule ID in processRuleConversion`);
await respond({
text: 'Error: Missing rule ID for conversion',
replace_original: replaceOriginal,
response_type: responseType
});
return;
}
logger.info(`${FILE_NAME}: Processing conversion for sigma rule: ${ruleId}`);
// Set default configuration from YAML config if not provided
const conversionConfig = config || {
backend: SIGMA_CLI_CONFIG.backend,
target: SIGMA_CLI_CONFIG.target,
format: SIGMA_CLI_CONFIG.format
};
await respond({
text: `Converting rule ${ruleId} using ${conversionConfig.backend}/${conversionConfig.target} to ${conversionConfig.format}...`,
replace_original: replaceOriginal,
response_type: 'ephemeral'
});
// Get the rule and convert it
const conversionResult = await convertRuleToBackend(ruleId, conversionConfig);
if (!conversionResult.success) {
logger.error(`${FILE_NAME}: Rule conversion failed: ${conversionResult.message}`);
await respond({
text: `Error: ${conversionResult.message}`,
replace_original: replaceOriginal,
response_type: responseType
});
return;
}
// Generate blocks for displaying the result
let blocks;
try {
blocks = getConversionResultBlocks(conversionResult);
} catch (blockError) {
await handleError(blockError, `${FILE_NAME}: Block generation`, respond, {
replaceOriginal: replaceOriginal,
responseType: responseType,
customMessage: `Rule ${ruleId} converted successfully. Use the following output with your SIEM:\n\`\`\`\n${conversionResult.output}\n\`\`\``
});
return;
}
// Respond with the conversion result
await respond({
blocks: blocks,
replace_original: replaceOriginal,
response_type: responseType
});
} catch (error) {
await handleError(error, `${FILE_NAME}: Process rule conversion`, respond, {
replaceOriginal: replaceOriginal,
responseType: responseType
});
}
};
/**
* Handle pagination actions (Previous, Next)
*
* @param {Object} body - The action payload body
* @param {Function} ack - Function to acknowledge the action
* @param {Function} respond - Function to send response
*/
const handlePaginationAction = async (body, ack, respond) => {
try {
await ack();
logger.debug(`${FILE_NAME}: Pagination action received: ${JSON.stringify(body.actions)}`);
if (!body || !body.actions || !body.actions[0] || !body.actions[0].value) {
logger.error(`${FILE_NAME}: Invalid pagination action payload: missing parameters`);
await respond({
text: 'Error: Could not process pagination request',
replace_original: false
});
return;
}
// Parse the action value which contains our pagination parameters
const action = body.actions[0];
let valueData;
try {
valueData = JSON.parse(action.value);
} catch (parseError) {
await handleError(parseError, `${FILE_NAME}: Pagination value parsing`, respond, {
replaceOriginal: false,
customMessage: 'Error: Invalid pagination parameters'
});
return;
}
const { keyword, page, pageSize } = valueData;
if (!keyword) {
logger.warn(`${FILE_NAME}: Missing keyword in pagination action`);
await respond({
text: 'Error: Missing search keyword in pagination request',
replace_original: false
});
return;
}
logger.info(`${FILE_NAME}: Processing pagination request for "${keyword}" (page ${page}, size ${pageSize})`);
// Perform the search with the new pagination parameters
const searchResult = await searchSigmaRules(keyword, page, pageSize);
if (!searchResult.success) {
logger.error(`${FILE_NAME}: Search failed during pagination: ${searchResult.message}`);
await respond({
text: `Error: ${searchResult.message}`,
replace_original: false
});
return;
}
// Generate the updated blocks for the search results
let blocks;
try {
blocks = getSearchResultBlocks(
keyword,
searchResult.results,
searchResult.pagination
);
} catch (blockError) {
await handleError(blockError, `${FILE_NAME}: Pagination block generation`, respond, {
replaceOriginal: false,
customMessage: `Error generating results view: ${blockError.message}`
});
return;
}
// Return the response that will update the original message
await respond({
blocks: blocks,
replace_original: true
});
} catch (error) {
await handleError(error, `${FILE_NAME}: Pagination action handler`, respond, {
replaceOriginal: false
});
}
};
/**
* Register all Sigma-related action handlers
*
* @param {Object} app - The Slack app instance
*/
const registerActionHandlers = (app) => {
logger.info(`${FILE_NAME}: Registering consolidated sigma action handlers`);
// Handle "Send to SIEM" button clicks
app.action('send_sigma_rule_to_siem', async ({ body, ack, respond }) => {
try {
await ack();
logger.debug(`${FILE_NAME}: send_sigma_rule_to_siem action received: ${JSON.stringify(body.actions)}`);
if (!body || !body.actions || !body.actions[0] || !body.actions[0].value) {
logger.error(`${FILE_NAME}: Invalid action payload: missing rule ID`);
await respond({
text: 'Error: Could not determine which rule to send',
replace_original: false,
response_type: 'ephemeral'
});
return;
}
// Extract rule ID from action value
// Value format is "send_sigma_rule_to_siem_[ruleID]"
const actionValue = body.actions[0].value;
const ruleId = actionValue.replace('send_sigma_rule_to_siem_', '');
if (!ruleId) {
logger.error(`${FILE_NAME}: Missing rule ID in action value: ${actionValue}`);
await respond({
text: 'Error: Missing rule ID in button data',
replace_original: false,
response_type: 'ephemeral'
});
return;
}
logger.info(`${FILE_NAME}: Sending rule ${ruleId} to SIEM`);
// Inform user that processing is happening
await respond({
text: `Sending rule ${ruleId} to Elasticsearch SIEM...`,
replace_original: false,
response_type: 'ephemeral'
});
// Get the converted rule in Elasticsearch format using config from YAML
const config = {
backend: SIGMA_CLI_CONFIG.backend,
target: SIGMA_CLI_CONFIG.target,
format: SIGMA_CLI_CONFIG.format
};
logger.info(`${FILE_NAME}: Converting rule ${ruleId} for SIEM export`);
const conversionResult = await convertRuleToBackend(ruleId, config);
if (!conversionResult.success) {
logger.error(`${FILE_NAME}: Rule conversion failed: ${conversionResult.message}`);
await respond({
text: `Error: Failed to convert rule for SIEM: ${conversionResult.message}`,
replace_original: false,
response_type: 'ephemeral'
});
return;
}
// Parse the converted rule JSON
let rulePayload;
try {
rulePayload = JSON.parse(conversionResult.output);
// Add required fields if not present
rulePayload.rule_id = rulePayload.rule_id || ruleId;
rulePayload.from = rulePayload.from || "now-360s";
rulePayload.to = rulePayload.to || "now";
rulePayload.interval = rulePayload.interval || "5m";
// Make sure required fields are present
if (!rulePayload.name) {
rulePayload.name = conversionResult.rule?.title || `Sigma Rule ${ruleId}`;
}
if (!rulePayload.description) {
rulePayload.description = conversionResult.rule?.description ||
`Converted from Sigma rule: ${ruleId}`;
}
if (!rulePayload.risk_score) {
// Map Sigma level to risk score
const levelMap = {
'critical': 90,
'high': 73,
'medium': 50,
'low': 25,
'informational': 10
};
rulePayload.risk_score = levelMap[conversionResult.rule?.level] || 50;
}
if (!rulePayload.severity) {
rulePayload.severity = conversionResult.rule?.level || 'medium';
}
if (!rulePayload.enabled) {
rulePayload.enabled = true;
}
} catch (parseError) {
logger.error(`${FILE_NAME}: Failed to parse converted rule JSON: ${parseError.message}`);
await respond({
text: `Error: The converted rule is not valid JSON: ${parseError.message}`,
replace_original: false,
response_type: 'ephemeral'
});
return;
}
// Send the rule to Elasticsearch using api service
try {
const result = await sendRuleToSiem(rulePayload);
if (result.success) {
logger.info(`${FILE_NAME}: Successfully sent rule ${ruleId} to SIEM`);
await respond({
text: `✅ Success! Rule "${rulePayload.name}" has been added to your Elasticsearch SIEM.`,
replace_original: false,
response_type: 'in_channel'
});
} else {
logger.error(`${FILE_NAME}: Error sending rule to SIEM: ${result.message}`);
await respond({
text: `Error: Failed to add rule to SIEM: ${result.message}`,
replace_original: false,
response_type: 'ephemeral'
});
}
} catch (error) {
await handleError(error, `${FILE_NAME}: send_sigma_rule_to_siem action`, respond, {
replaceOriginal: false
});
}
} catch (error) {
await handleError(error, `${FILE_NAME}: send_sigma_rule_to_siem action`, respond, {
replaceOriginal: false
});
}
});
// Handle View YAML button clicks
app.action('view_yaml', async ({ body, ack, respond }) => {
logger.info(`${FILE_NAME}: VIEW_YAML ACTION TRIGGERED`);
try {
await ack();
logger.debug(`${FILE_NAME}: View YAML action received: ${JSON.stringify(body.actions)}`);
if (!body || !body.actions || !body.actions[0] || !body.actions[0].value) {
logger.error(`${FILE_NAME}: Invalid action payload: missing rule ID`);
await respond({
text: 'Error: Could not determine which rule to get YAML for',
replace_original: false
});
return;
}
// Extract rule ID from button value
// Handle both formats: direct ID from search results or view_yaml_{ruleId} from details view
let ruleId = body.actions[0].value;
if (ruleId.startsWith('view_yaml_')) {
ruleId = ruleId.replace('view_yaml_', '');
}
logger.info(`${FILE_NAME}: View YAML button clicked for rule: ${ruleId}`);
// Get Sigma rule YAML
const result = await getSigmaRuleYaml(ruleId);
logger.debug(`${FILE_NAME}: YAML retrieval result: ${JSON.stringify(result, null, 2)}`);
if (!result.success) {
logger.error(`${FILE_NAME}: Rule YAML retrieval failed: ${result.message}`);
await respond({
text: `Error: ${result.message}`,
replace_original: false
});
return;
}
logger.info(`${FILE_NAME}: Rule ${ruleId} YAML retrieved successfully via button click`);
// Use the module to generate blocks
const blocks = getYamlViewBlocks(ruleId, result.yaml || '');
// Respond with the YAML content
await respond({
blocks: blocks,
replace_original: false
});
} catch (error) {
await handleError(error, `${FILE_NAME}: View YAML action`, respond, {
replaceOriginal: false
});
}
});
// Handle convert_rule_to_siem button clicks
app.action('convert_rule_to_siem', async ({ body, ack, respond }) => {
try {
await ack();
logger.debug(`${FILE_NAME}: convert_rule_to_siem action received: ${JSON.stringify(body.actions)}`);
if (!body || !body.actions || !body.actions[0] || !body.actions[0].value) {
logger.error(`${FILE_NAME}: Invalid action payload: missing rule ID`);
await respond({
text: 'Error: Could not determine which rule to convert',
replace_original: false
});
return;
}
// Extract rule ID from button value
const ruleId = body.actions[0].value.replace('convert_rule_to_siem_', '');
logger.info(`${FILE_NAME}: convert_rule_to_siem button clicked for rule: ${ruleId}`);
const config = {
backend: 'lucene',
target: 'ecs_windows',
format: 'siem_rule_ndjson'
};
await processRuleConversion(ruleId, config, respond, false, 'in_channel');
} catch (error) {
await handleError(error, `${FILE_NAME}: convert_rule_to_siem action`, respond, {
replaceOriginal: false
});
}
});
// Handle "View Rule Details" button clicks from search results
app.action('view_rule_details', async ({ body, ack, respond }) => {
logger.info(`${FILE_NAME}: VIEW_RULE_DETAILS ACTION TRIGGERED`);
try {
await ack();
logger.debug(`${FILE_NAME}: View Rule Details action received: ${JSON.stringify(body.actions)}`);
if (!body || !body.actions || !body.actions[0] || !body.actions[0].value) {
logger.error(`${FILE_NAME}: Invalid action payload: missing rule ID`);
await respond({
text: 'Error: Could not determine which rule to explain',
replace_original: false
});
return;
}
const ruleId = body.actions[0].value;
logger.info(`${FILE_NAME}: Rule details button clicked for rule ID: ${ruleId}`);
// Inform user we're processing
await respond({
text: `Processing details for rule ${ruleId}...`,
replace_original: false,
response_type: 'ephemeral'
});
await processRuleDetails(ruleId, respond, false, 'in_channel');
} catch (error) {
await handleError(error, `${FILE_NAME}: View rule details action`, respond, {
replaceOriginal: false
});
}
});
// Handle pagination button clicks
app.action('search_prev_page', async ({ body, ack, respond }) => {
await handlePaginationAction(body, ack, respond);
});
app.action('search_next_page', async ({ body, ack, respond }) => {
await handlePaginationAction(body, ack, respond);
});
logger.info(`${FILE_NAME}: All sigma action handlers registered successfully`);
};
module.exports = {
registerActionHandlers,
processRuleDetails,
processRuleConversion
};