add support for multiple spaces

This commit is contained in:
Charlotte Croce 2025-04-16 18:01:35 -04:00
parent 7988853b57
commit 351c4e4ef4
10 changed files with 498 additions and 92 deletions

View file

@ -3,9 +3,9 @@
* *
* Provides block templates for displaying Sigma rule conversion results in Slack * Provides block templates for displaying Sigma rule conversion results in Slack
*/ */
const logger = require('../utils/logger'); const logger = require('../../utils/logger');
const { getFileName } = require('../utils/file_utils'); const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename); const FILE_NAME = getFileName(__filename);
/** /**
@ -93,11 +93,11 @@ function getConversionResultBlocks(conversionResult) {
type: 'button', type: 'button',
text: { text: {
type: 'plain_text', type: 'plain_text',
text: 'send_sigma_rule_to_siem', text: '🚀 Send to Elasticsearch',
emoji: true emoji: true
}, },
value: `send_sigma_rule_to_siem_${rule.id}`, value: `select_space_for_rule_${rule.id}`,
action_id: 'send_sigma_rule_to_siem' action_id: 'select_space_for_rule'
}, },
] ]
}); });

View file

@ -4,9 +4,9 @@
* Creates Slack Block Kit blocks for displaying Sigma rule explanations * Creates Slack Block Kit blocks for displaying Sigma rule explanations
* *
*/ */
const logger = require('../utils/logger'); const logger = require('../../utils/logger');
const { getFileName } = require('../utils/file_utils'); const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename); const FILE_NAME = getFileName(__filename);
/** /**

View file

@ -4,11 +4,10 @@
* Generates Slack Block Kit blocks for displaying Sigma rule search results * Generates Slack Block Kit blocks for displaying Sigma rule search results
* Includes pagination controls for navigating large result sets * Includes pagination controls for navigating large result sets
* *
* @author Fylgja Development Team
*/ */
const logger = require('../utils/logger'); const logger = require('../../utils/logger');
const { getFileName } = require('../utils/file_utils'); const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename); const FILE_NAME = getFileName(__filename);
/** /**

View file

@ -0,0 +1,127 @@
/**
* space_selection_block.js
*
* Provides block templates for displaying Elasticsearch space selection in Slack
*/
const logger = require('../../utils/logger');
const { getAllSpaces } = require('../../services/elastic/elastic_api_service');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Generate blocks for space selection for a specific rule
*
* @param {string} ruleId - The ID of the rule to send to a space
* @param {Object} ruleInfo - Optional rule title and other information
* @returns {Array} Array of blocks for Slack message
*/
function getSpaceSelectionBlocks(ruleId, ruleInfo = {}) {
logger.debug(`${FILE_NAME}: Generating space selection blocks for rule: ${ruleId}`);
// Get all configured spaces
const spaces = getAllSpaces();
if (!spaces || spaces.length === 0) {
logger.warn(`${FILE_NAME}: No spaces configured for selection blocks`);
return [{
type: 'section',
text: {
type: 'mrkdwn',
text: 'No Elasticsearch spaces are configured. Please update your configuration.'
}
}];
}
// Create header block
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: `Select a Space for Rule: ${ruleInfo.title || ruleId}`,
emoji: true
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `Please select which Elasticsearch space you want to send this rule to:`
}
},
{
type: 'divider'
}
];
// Create buttons for each space
// If we have many spaces, we need to create multiple action blocks
// Slack only allows 5 buttons per actions block
const BUTTONS_PER_ACTION = 5;
let actionBlocks = [];
let currentActionBlock = {
type: 'actions',
elements: []
};
// Add buttons for each space
spaces.forEach((space, index) => {
// Create the button for this space
const button = {
type: 'button',
text: {
type: 'plain_text',
text: `${space.emoji || ''} ${space.name}`,
emoji: true
},
value: `send_rule_to_space_${ruleId}_${space.id}`,
action_id: `send_rule_to_space_${space.id}`
};
// Add button to current action block
currentActionBlock.elements.push(button);
// If we've reached the button limit or this is the last space,
// add the current action block to our collection
if (currentActionBlock.elements.length === BUTTONS_PER_ACTION || index === spaces.length - 1) {
actionBlocks.push(currentActionBlock);
// Start a new action block if we have more spaces
if (index < spaces.length - 1) {
currentActionBlock = {
type: 'actions',
elements: []
};
}
}
});
// Add all the action blocks to our blocks array
blocks.push(...actionBlocks);
// Add a cancel button
blocks.push({
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'Cancel',
emoji: true
},
style: 'danger',
value: `cancel_space_selection`,
action_id: 'cancel_space_selection'
}
]
});
logger.debug(`${FILE_NAME}: Generated ${blocks.length} blocks for space selection`);
return blocks;
}
module.exports = {
getSpaceSelectionBlocks
};

View file

@ -3,9 +3,9 @@
* *
* Creates Slack Block Kit blocks for displaying Sigma rule database statistics * Creates Slack Block Kit blocks for displaying Sigma rule database statistics
*/ */
const logger = require('../utils/logger'); const logger = require('../../utils/logger');
const { getFileName } = require('../utils/file_utils'); const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename); const FILE_NAME = getFileName(__filename);
/** /**

View file

@ -4,9 +4,9 @@
* Creates Slack Block Kit blocks for displaying Sigma rule YAML content * Creates Slack Block Kit blocks for displaying Sigma rule YAML content
* *
*/ */
const logger = require('../utils/logger'); const logger = require('../../utils/logger');
const { getFileName } = require('../utils/file_utils'); const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename); const FILE_NAME = getFileName(__filename);
/** /**

View file

@ -1,3 +1,8 @@
/*
* appConfig.js
*
* retrives configuration data from fylgja.yml file
*/
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const yaml = require('js-yaml'); const yaml = require('js-yaml');
@ -60,10 +65,19 @@ module.exports = {
// Elasticsearch configuration from YAML // Elasticsearch configuration from YAML
ELASTICSEARCH_CONFIG: { ELASTICSEARCH_CONFIG: {
apiEndpoint: yamlConfig?.elastic?.['api-endpoint'] || protocol: yamlConfig?.elasticsearch?.protocol || "http",
"http://localhost:5601/api/detection_engine/rules", hosts: yamlConfig?.elasticsearch?.hosts || ["localhost:9200"],
credentials: yamlConfig?.elastic?.['elastic-authentication-credentials'] || username: yamlConfig?.elasticsearch?.username || process.env.ELASTIC_USERNAME || "elastic",
"elastic:changeme" password: yamlConfig?.elasticsearch?.password || process.env.ELASTIC_PASSWORD || "changeme",
apiEndpoint: yamlConfig?.elasticsearch?.api_endpoint || "http://localhost:5601/api/detection_engine/rules",
spaces: yamlConfig?.elasticsearch?.spaces || [
{
name: "Default",
id: "default",
indexPattern: "logs-*",
emoji: "🔍"
}
]
}, },
// Logging configuration from YAML // Logging configuration from YAML

View file

@ -8,11 +8,13 @@ const { handleError } = require('../../utils/error_handler');
const { explainSigmaRule, getSigmaRuleYaml } = require('../../services/sigma/sigma_details_service'); const { explainSigmaRule, getSigmaRuleYaml } = require('../../services/sigma/sigma_details_service');
const { convertRuleToBackend } = require('../../services/sigma/sigma_backend_converter'); const { convertRuleToBackend } = require('../../services/sigma/sigma_backend_converter');
const { searchSigmaRules } = require('../../services/sigma/sigma_search_service'); const { searchSigmaRules } = require('../../services/sigma/sigma_search_service');
const { getYamlViewBlocks } = require('../../blocks/sigma_view_yaml_block'); const { getYamlViewBlocks } = require('../../blocks/sigma/sigma_view_yaml_block');
const { getSearchResultBlocks } = require('../../blocks/sigma_search_results_block'); const { getSearchResultBlocks } = require('../../blocks/sigma/sigma_search_results_block');
const { getConversionResultBlocks } = require('../../blocks/sigma_conversion_block'); const { getConversionResultBlocks } = require('../../blocks/sigma/sigma_conversion_block');
const { getRuleExplanationBlocks } = require('../../blocks/sigma_details_block'); const { getRuleExplanationBlocks } = require('../../blocks/sigma/sigma_details_block');
const { sendRuleToSiem } = require('../../services/elastic/elastic_api_service'); const { sendRuleToSiem } = require('../../services/elastic/elastic_api_service');
const { getSpaceSelectionBlocks } = require('../../blocks/sigma/sigma_space_selection_block');
const { getAllSpaces } = require('../../services/elastic/elastic_api_service');
const { SIGMA_CLI_CONFIG, ELASTICSEARCH_CONFIG } = require('../../config/appConfig'); const { SIGMA_CLI_CONFIG, ELASTICSEARCH_CONFIG } = require('../../config/appConfig');
@ -543,8 +545,214 @@ const registerActionHandlers = (app) => {
}); });
logger.info(`${FILE_NAME}: All sigma action handlers registered successfully`); logger.info(`${FILE_NAME}: All sigma action handlers registered successfully`);
// Handle space selection button click
app.action('select_space_for_rule', async ({ body, ack, respond }) => {
try {
await ack();
logger.debug(`${FILE_NAME}: select_space_for_rule 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 select space for',
replace_original: false,
response_type: 'ephemeral'
});
return;
}
// Extract rule ID from value
const actionValue = body.actions[0].value;
const ruleId = actionValue.replace('select_space_for_rule_', '');
// Get rule information to display in the space selection message
const explainResult = await explainSigmaRule(ruleId);
const ruleInfo = explainResult.success ? explainResult.explanation : { title: ruleId };
// Generate blocks for space selection
const blocks = getSpaceSelectionBlocks(ruleId, ruleInfo);
// Show space selection options
await respond({
blocks: blocks,
replace_original: false,
response_type: 'ephemeral'
});
} catch (error) {
await handleError(error, `${FILE_NAME}: select_space_for_rule action`, respond, {
replaceOriginal: false
});
}
});
// Handle space selection cancel button
app.action('cancel_space_selection', async ({ body, ack, respond }) => {
try {
await ack();
await respond({
text: 'Space selection cancelled.',
replace_original: false,
response_type: 'ephemeral'
});
} catch (error) {
await handleError(error, `${FILE_NAME}: cancel_space_selection action`, respond, {
replaceOriginal: false
});
}
});
// Dynamic handler for all space selection buttons
// This uses a pattern matcher to match any action ID that starts with "send_rule_to_space_"
app.action(/^send_rule_to_space_(.*)$/, async ({ body, action, ack, respond }) => {
try {
await ack();
logger.debug(`${FILE_NAME}: Space selection action received: ${JSON.stringify(action)}`);
// Extract rule ID and space ID from the action value
const actionValue = action.value;
const parts = actionValue.split('_');
const spaceId = parts.pop(); // Last part is the space ID
const ruleId = actionValue.match(/send_rule_to_space_(.+)_/)[1]; // Extract full UUID
logger.info(`${FILE_NAME}: Selected space ${spaceId} for rule ${ruleId}`);
// Get space info
const spaces = getAllSpaces();
const selectedSpace = spaces.find(s => s.id === spaceId);
if (!selectedSpace) {
logger.error(`${FILE_NAME}: Space not found: ${spaceId}`);
await respond({
text: `Error: Space "${spaceId}" not found in configuration`,
replace_original: false,
response_type: 'ephemeral'
});
return;
}
// Inform user that processing is happening
await respond({
text: `Sending rule ${ruleId} to ${selectedSpace.emoji || ''} ${selectedSpace.name} space...`,
replace_original: false,
response_type: 'ephemeral'
});
// Get the converted rule in Elasticsearch format
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 to space ${spaceId}`);
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";
// Set index pattern from space configuration if available
if (selectedSpace.indexPattern) {
rulePayload.index = Array.isArray(selectedSpace.indexPattern)
? selectedSpace.indexPattern
: [selectedSpace.indexPattern];
logger.debug(`${FILE_NAME}: Setting index pattern from space config: ${JSON.stringify(rulePayload.index)}`);
}
// 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 the selected Elasticsearch space
try {
const result = await sendRuleToSiem(rulePayload, spaceId);
if (result.success) {
logger.info(`${FILE_NAME}: Successfully sent rule ${ruleId} to space ${spaceId}`);
await respond({
text: `✅ Success! Rule "${rulePayload.name}" has been added to the ${selectedSpace.emoji || ''} ${selectedSpace.name} space in Elasticsearch.`,
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 the ${selectedSpace.name} space: ${result.message}`,
replace_original: false,
response_type: 'ephemeral'
});
}
} catch (error) {
await handleError(error, `${FILE_NAME}: send_rule_to_space action`, respond, {
replaceOriginal: false
});
}
} catch (error) {
await handleError(error, `${FILE_NAME}: send_rule_to_space action`, respond, {
replaceOriginal: false
});
}
});
};
module.exports = { module.exports = {
registerActionHandlers, registerActionHandlers,
processRuleDetails, processRuleDetails,

View file

@ -6,7 +6,7 @@
const { searchSigmaRules } = require('../../services/sigma/sigma_search_service'); const { searchSigmaRules } = require('../../services/sigma/sigma_search_service');
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
const { handleError } = require('../../utils/error_handler'); const { handleError } = require('../../utils/error_handler');
const { getSearchResultBlocks } = require('../../blocks/sigma_search_results_block'); const { getSearchResultBlocks } = require('../../blocks/sigma/sigma_search_results_block');
const { getFileName } = require('../../utils/file_utils'); const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename); const FILE_NAME = getFileName(__filename);

View file

@ -12,32 +12,76 @@ const FILE_NAME = 'elastic_api_service.js';
/** /**
* Get Elasticsearch configuration with credentials * Get Elasticsearch configuration with credentials
* *
* @param {string} spaceId - Optional space ID to get configuration for
* @returns {Object} Configuration object with URL and credentials * @returns {Object} Configuration object with URL and credentials
*/ */
const getElasticConfig = () => { const getElasticConfig = (spaceId = null) => {
return { // Default config
url: ELASTICSEARCH_CONFIG.apiEndpoint.split('/api/')[0] || process.env.ELASTIC_URL, const config = {
username: ELASTICSEARCH_CONFIG.credentials.split(':')[0] || process.env.ELASTIC_USERNAME, protocol: ELASTICSEARCH_CONFIG.protocol || "http",
password: ELASTICSEARCH_CONFIG.credentials.split(':')[1] || process.env.ELASTIC_PASSWORD, hosts: ELASTICSEARCH_CONFIG.hosts || ["localhost:9200"],
username: ELASTICSEARCH_CONFIG.username || process.env.ELASTIC_USERNAME,
password: ELASTICSEARCH_CONFIG.password || process.env.ELASTIC_PASSWORD,
apiEndpoint: ELASTICSEARCH_CONFIG.apiEndpoint apiEndpoint: ELASTICSEARCH_CONFIG.apiEndpoint
}; };
// If space ID provided, find the specific space
if (spaceId) {
const space = ELASTICSEARCH_CONFIG.spaces.find(s => s.id === spaceId);
if (space) {
// Apply space-specific configuration overrides if they exist
return {
...config,
space: space
};
}
}
return config;
}; };
/** /**
* Send a rule to Elasticsearch SIEM * Get all configured Elasticsearch spaces
*
* @returns {Array} List of all configured spaces
*/
const getAllSpaces = () => {
return ELASTICSEARCH_CONFIG.spaces || [];
};
/**
* Send a rule to Elasticsearch SIEM in a specific space
* *
* @param {Object} rulePayload - The rule payload to send to Elasticsearch * @param {Object} rulePayload - The rule payload to send to Elasticsearch
* @param {string} spaceId - The ID of the space to send the rule to
* @returns {Promise<Object>} - Object containing success status and response/error information * @returns {Promise<Object>} - Object containing success status and response/error information
*/ */
const sendRuleToSiem = async (rulePayload) => { const sendRuleToSiem = async (rulePayload, spaceId = 'default') => {
logger.info(`${FILE_NAME}: Sending rule to Elasticsearch SIEM`); logger.info(`${FILE_NAME}: Sending rule to Elasticsearch SIEM in space: ${spaceId}`);
try { try {
const elasticConfig = getElasticConfig(); const elasticConfig = getElasticConfig(spaceId);
const apiUrl = elasticConfig.apiEndpoint; const baseApiUrl = elasticConfig.apiEndpoint;
// Construct space-specific URL if needed
let apiUrl = baseApiUrl;
if (spaceId && spaceId !== 'default') {
// Insert space ID into URL: http://localhost:5601/api/detection_engine/rules
// becomes http://localhost:5601/s/space-id/api/detection_engine/rules
const urlParts = baseApiUrl.split('/api/');
apiUrl = `${urlParts[0]}/s/${spaceId}/api/${urlParts[1]}`;
}
logger.debug(`${FILE_NAME}: Using Elasticsearch API URL: ${apiUrl}`); logger.debug(`${FILE_NAME}: Using Elasticsearch API URL: ${apiUrl}`);
// Add index pattern to rule if provided by space config
if (elasticConfig.space && elasticConfig.space.indexPattern && !rulePayload.index) {
rulePayload.index = Array.isArray(elasticConfig.space.indexPattern)
? elasticConfig.space.indexPattern
: [elasticConfig.space.indexPattern];
logger.debug(`${FILE_NAME}: Adding index pattern to rule: ${JSON.stringify(rulePayload.index)}`);
}
// Send the request to Elasticsearch // Send the request to Elasticsearch
const response = await axios({ const response = await axios({
method: 'post', method: 'post',
@ -55,18 +99,19 @@ const sendRuleToSiem = async (rulePayload) => {
// Process the response // Process the response
if (response.status >= 200 && response.status < 300) { if (response.status >= 200 && response.status < 300) {
logger.info(`${FILE_NAME}: Successfully sent rule to SIEM`); logger.info(`${FILE_NAME}: Successfully sent rule to SIEM in space: ${spaceId}`);
return { return {
success: true, success: true,
status: response.status, status: response.status,
data: response.data data: response.data,
space: elasticConfig.space
}; };
} else { } else {
logger.error(`${FILE_NAME}: Error sending rule to SIEM. Status: ${response.status}, Response: ${JSON.stringify(response.data)}`); logger.error(`${FILE_NAME}: Error sending rule to SIEM. Status: ${response.status}, Response: ${JSON.stringify(response.data)}`);
return { return {
success: false, success: false,
status: response.status, status: response.status,
message: `Failed to add rule to SIEM. Status: ${response.status}`, message: `Failed to add rule to SIEM in space ${spaceId}. Status: ${response.status}`,
data: response.data data: response.data
}; };
} }
@ -92,6 +137,7 @@ const sendRuleToSiem = async (rulePayload) => {
* @param {Object} options - Request options * @param {Object} options - Request options
* @param {string} options.method - HTTP method (get, post, put, delete) * @param {string} options.method - HTTP method (get, post, put, delete)
* @param {string} options.endpoint - API endpoint (appended to base URL) * @param {string} options.endpoint - API endpoint (appended to base URL)
* @param {string} options.spaceId - Optional space ID
* @param {Object} options.data - Request payload * @param {Object} options.data - Request payload
* @param {Object} options.params - URL parameters * @param {Object} options.params - URL parameters
* @param {Object} options.headers - Additional headers * @param {Object} options.headers - Additional headers
@ -99,14 +145,24 @@ const sendRuleToSiem = async (rulePayload) => {
*/ */
const makeElasticRequest = async (options) => { const makeElasticRequest = async (options) => {
try { try {
const elasticConfig = getElasticConfig(); const elasticConfig = getElasticConfig(options.spaceId);
const baseUrl = elasticConfig.url; const baseUrl = elasticConfig.protocol + '://' + elasticConfig.hosts[0];
// Build the full URL - use provided endpoint or default API endpoint // Build the full URL - use provided endpoint or default API endpoint
const url = options.endpoint ? let url = options.endpoint ?
`${baseUrl}${options.endpoint.startsWith('/') ? '' : '/'}${options.endpoint}` : `${baseUrl}${options.endpoint.startsWith('/') ? '' : '/'}${options.endpoint}` :
elasticConfig.apiEndpoint; elasticConfig.apiEndpoint;
// Handle space in URL if needed
if (options.spaceId && options.spaceId !== 'default') {
// Insert space ID into URL: http://localhost:5601/api/detection_engine/rules
// becomes http://localhost:5601/s/space-id/api/detection_engine/rules
if (url.includes('/api/')) {
const urlParts = url.split('/api/');
url = `${urlParts[0]}/s/${options.spaceId}/api/${urlParts[1]}`;
}
}
logger.debug(`${FILE_NAME}: Making ${options.method} request to: ${url}`); logger.debug(`${FILE_NAME}: Making ${options.method} request to: ${url}`);
// Set up default headers // Set up default headers
@ -133,7 +189,8 @@ const makeElasticRequest = async (options) => {
return { return {
success: response.status >= 200 && response.status < 300, success: response.status >= 200 && response.status < 300,
status: response.status, status: response.status,
data: response.data data: response.data,
space: elasticConfig.space
}; };
} catch (error) { } catch (error) {
logger.error(`${FILE_NAME}: Error in Elasticsearch API request: ${error.message}`); logger.error(`${FILE_NAME}: Error in Elasticsearch API request: ${error.message}`);
@ -150,5 +207,6 @@ const makeElasticRequest = async (options) => {
module.exports = { module.exports = {
sendRuleToSiem, sendRuleToSiem,
makeElasticRequest, makeElasticRequest,
getElasticConfig getElasticConfig,
getAllSpaces
}; };