first commit

This commit is contained in:
Charlotte Croce 2025-04-07 12:22:06 -04:00
commit 7988853b57
43 changed files with 8415 additions and 0 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.venv
.env
node_modules/
fylgja.log
fylgja.yml
slack.yml
sigma.db
sigma-repo/

48
README.md Normal file
View file

@ -0,0 +1,48 @@
# fylgja
Manage your Elastic Stack threat detection ruleset through a Slack frontend
## Features
- [Sigma](https://github.com/SigmaHQ/sigma) integration:
- Imports the Sigma rule repository to an SQLite database
- Search rules by keyword
- Convert rules into SIEM format
- Upload generated rules to Elastic
- All without leaving the Slack channel!
## Setup
### Clone Repo
```
git clone https://codeberg.org/charlottecroce/fylgja.git
cd fylgja/
```
### Install requirements
```
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
### Install sigma-cli elasticsearch plugin
```
sigma plugin install elasticsearch
```
### Create the database
```
npm run update-db
```
### YAML Configuration
- Copy the example config: `cp fylgja.example.yml fylgja.yml`
- Edit `fylgja.yml` and replace the placeholder values with your real API keys
### Slack Configuration
- Copy the example config: `cp slack.example.yml slack.yml`
- Edit `slack.yml` and replace the placeholder values with your real server domain name
> this should probably be all included in a setup script or something
> [!Important]
> While detection rules are stored in Elasticsearch, in my case, they are managed through the Kibana API. This has not been tested on other frontend APIs.

37
fylgja.example.yml Normal file
View file

@ -0,0 +1,37 @@
# Fylgja Configuration File
# This file contains all configurable settings for the Fylgja Slack bot
# Slack settings
slack:
bot_token: "xoxb-TOKEN_HERE"
signing_secret: "SIGNING_SECRET_HERE"
# Server settings
server:
port: 3000
# Paths configuration
paths:
sigma_repo_dir: "./sigma-repo"
db_path: "./sigma.db"
# Sigma settings
sigma:
sigma-cli:
path: "./.venv/bin/sigma"
backend: "lucene"
target: "ecs_windows"
format: "siem_rule_ndjson"
repo:
url: "https://github.com/SigmaHQ/sigma.git"
branch: "main"
# Elastic settings
elastic:
api-endpoint: "http://localhost:5601/api/detection_engine/rules"
elastic-authentication-credentials: "elastic:changeme"
# Logging settings
logging:
level: "debug"
file: "./logs/fylgja.log"

3221
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "fylgja",
"version": "1.0.0",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"ngrok": "ngrok http 3000 --log=stdout --url=tolerant-bull-ideal.ngrok-free.app",
"dev": "concurrently \"npm run start\" \"npm run ngrok\"",
"update-db": "node src/sigma_db/sigma_db_initialize.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "Fylgja - Threat detection rule Slack interface",
"dependencies": {
"@slack/bolt": "^4.2.1",
"axios": "^1.6.7",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"glob": "^8.1.0",
"js-yaml": "^4.1.0",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"concurrently": "^9.1.2"
}
}

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
sigma-cli

81
slack.example.yml Normal file
View file

@ -0,0 +1,81 @@
display_information:
name: fylgja
description: threat detection engine bot
background_color: "#344d59"
features:
bot_user:
display_name: fylgja
always_online: false
slash_commands:
- command: /rule
url: http://SERVER_DOMAIN_NAME/slack/events
description: Convert, explain, search, view, or test Sigma rules
should_escape: false
- command: /sigma-create
url: http://SERVER_DOMAIN_NAME/slack/events
description: Convert a Sigma rule to configured output format
usage_hint: "[id]"
should_escape: false
- command: /sigma-details
url: http://SERVER_DOMAIN_NAME/slack/events
description: Get an explanation of a Sigma rule
usage_hint: "[id]"
should_escape: false
- command: /sigma-search
url: http://SERVER_DOMAIN_NAME/slack/events
description: Search for Sigma rules
usage_hint: "[keyword]"
should_escape: false
- command: /sigma-view
url: http://SERVER_DOMAIN_NAME/slack/events
description: View rule definition
usage_hint: "[id] [space]"
should_escape: false
- command: /sigma-test
url: http://SERVER_DOMAIN_NAME/slack/events
description: Test a Sigma rule with event log
usage_hint: "[event log]"
should_escape: false
- command: /sigma-config
url: http://SERVER_DOMAIN_NAME/slack/events
description: Update Sigma rule conversion configuration
usage_hint: siem [value] OR lang [value] OR output [value] OR update
should_escape: false
- command: /sigma-stats
url: http://SERVER_DOMAIN_NAME/slack/events
description: Show stats about Sigma rules
should_escape: false
- command: /alerts
url: http://SERVER_DOMAIN_NAME/slack/events
description: List alerts with IDs
usage_hint: "[space]"
should_escape: false
- command: /case
url: http://SERVER_DOMAIN_NAME/slack/events
description: Create an Elasticsearch case
usage_hint: "[id]"
should_escape: false
- command: /stats
url: http://SERVER_DOMAIN_NAME/slack/events
description: Show statistics
should_escape: false
oauth_config:
scopes:
bot:
- app_mentions:read
- chat:write
- im:history
- im:read
- commands
settings:
event_subscriptions:
request_url: http://SERVER_DOMAIN_NAME/slack/events
bot_events:
- app_mention
- message.im
interactivity:
is_enabled: true
request_url: http://SERVER_DOMAIN_NAME/slack/events
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false

138
src/app.js Normal file
View file

@ -0,0 +1,138 @@
/**
* app.js
*
* Main application file for Fylgja Slack bot
* Initializes the Slack Bolt app with custom ExpressReceiver Registers command handlers
*
*/
const { App, ExpressReceiver } = require('@slack/bolt');
const fs = require('fs');
const logger = require('./utils/logger');
const { SIGMA_CLI_PATH, SIGMA_CLI_CONFIG, SLACK_CONFIG } = require('./config/appConfig');
const { getFileName } = require('./utils/file_utils');
const FILE_NAME = getFileName(__filename);
// Import individual command handlers
const sigmaDetailsHandler = require('./handlers/sigma/sigma_details_handler');
const sigmaSearchHandler = require('./handlers/sigma/sigma_search_handler');
const sigmaCreateHandler = require('./handlers/sigma/sigma_create_handler');
const sigmaActionHandlers = require('./handlers/sigma/sigma_action_handlers');
//const configCommand = require('./commands/config/index.js');
//const alertsCommand = require('./commands/alerts/index.js');
//const caseCommand = require('./commands/case/index.js');
//const statsCommand = require('./commands/stats/index.js');
// Verify sigma-cli is installed
if (!fs.existsSync(SIGMA_CLI_PATH)) {
logger.error(`Error: Sigma CLI not found at specified path: ${SIGMA_CLI_PATH}`);
process.exit(1);
}
// Log the loaded configuration
logger.info(`Loaded sigma-cli configuration: ${JSON.stringify(SIGMA_CLI_CONFIG)}`);
/**
* Create a custom ExpressReceiver for more control over the HTTP server
*/
const expressReceiver = new ExpressReceiver({
signingSecret: SLACK_CONFIG.signingSecret,
processBeforeResponse: true
});
/**
* Initialize the Slack app with the custom receiver
*/
const app = new App({
token: SLACK_CONFIG.botToken,
receiver: expressReceiver
});
// Register individual command handlers for all sigma commands
logger.info('Registering command handlers');
// Register sigma command handlers directly
app.command('/sigma-create', async ({ command, ack, respond }) => {
try {
await ack();
logger.info(`Received sigma-create command: ${command.text}`);
await sigmaCreateHandler.handleCommand(command, respond);
} catch (error) {
logger.error(`Error handling sigma-create command: ${error.message}`);
logger.debug(`Error stack: ${error.stack}`);
await respond({
text: `An error occurred: ${error.message}`,
response_type: 'ephemeral'
});
}
});
app.command('/sigma-details', async ({ command, ack, respond }) => {
try {
await ack();
logger.info(`Received sigma-details command: ${command.text}`);
await sigmaDetailsHandler.handleCommand(command, respond);
} catch (error) {
logger.error(`Error handling sigma-details command: ${error.message}`);
logger.debug(`Error stack: ${error.stack}`);
await respond({
text: `An error occurred: ${error.message}`,
response_type: 'ephemeral'
});
}
});
app.command('/sigma-search', async ({ command, ack, respond }) => {
try {
await ack();
logger.info(`Received sigma-search command: ${command.text}`);
await sigmaSearchHandler.handleCommand(command, respond);
} catch (error) {
logger.error(`Error handling sigma-search command: ${error.message}`);
logger.debug(`Error stack: ${error.stack}`);
await respond({
text: `An error occurred: ${error.message}`,
response_type: 'ephemeral'
});
}
});
app.command('/sigma-stats', async ({ command, ack, respond }) => {
try {
await ack();
logger.info(`Received sigma-stats command`);
const sigmaStatsHandler = require('./handlers/sigma/sigma_stats_handler');
await sigmaStatsHandler.handleCommand(command, respond);
} catch (error) {
logger.error(`Error handling sigma-stats command: ${error.message}`);
logger.debug(`Error stack: ${error.stack}`);
await respond({
text: `An error occurred: ${error.message}`,
response_type: 'ephemeral'
});
}
});
// Register all button action handlers from centralized module
sigmaActionHandlers.registerActionHandlers(app);
/**
* Listen for any message in DMs
* This allows users to interact with the bot directly
*/
app.message(async ({ message, say }) => {
// Only respond to DMs (message.channel starts with 'D')
if (message.channel.startsWith('D')) {
logger.info(`DM received from user ${message.user}`);
logger.debug(`DM content: ${message.text}`);
// For now, we're just logging DMs but not responding
// Uncomment below to enable responses to DMs
// await say(`I received your message: "${message.text}"`);
}
});
/**
* Export the configured app for use in the main server file
*/
module.exports = app;

View file

@ -0,0 +1,124 @@
/**
* sigma_conversion_block.js
*
* Provides block templates for displaying Sigma rule conversion results in Slack
*/
const logger = require('../utils/logger');
const { getFileName } = require('../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Generate blocks for displaying a Sigma rule conversion result
*
* @param {Object} conversionResult - The result of the conversion operation
* @returns {Array} Array of blocks for Slack message
*/
function getConversionResultBlocks(conversionResult) {
logger.debug(`${FILE_NAME}: Generating blocks for conversion result`);
if (!conversionResult || !conversionResult.success) {
logger.warn(`${FILE_NAME}: Invalid conversion result provided for block generation`);
return [{
type: 'section',
text: {
type: 'mrkdwn',
text: 'Error: Failed to generate conversion result blocks'
}
}];
}
const rule = conversionResult.rule || {
id: 'unknown',
title: 'Unknown Rule',
description: 'No rule metadata available'
};
const details = conversionResult.conversionDetails || {
backend: 'lucene',
target: 'ecs_windows',
format: 'siem_rule_ndjson'
};
// Truncate output if it's too long for Slack
let output = conversionResult.output || '';
const maxOutputLength = 2900; // Slack has a limit of ~3000 chars in a code block
const isTruncated = output.length > maxOutputLength;
if (isTruncated) {
output = output.substring(0, maxOutputLength) + '... [truncated]';
}
// Create the blocks
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: `Converted Rule: ${rule.title}`,
emoji: true
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Rule ID:* ${rule.id}\n*Description:* ${rule.description}`
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Conversion Settings:*\nBackend: \`${details.backend}\` | Target: \`${details.target}\` | Format: \`${details.format}\``
}
},
{
type: 'divider'
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Converted Output:*${isTruncated ? ' (truncated for display)' : ''}\n\`\`\`\n${output}\n\`\`\``
}
}
];
// Action buttons
blocks.push({
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'send_sigma_rule_to_siem',
emoji: true
},
value: `send_sigma_rule_to_siem_${rule.id}`,
action_id: 'send_sigma_rule_to_siem'
},
]
});
// Warning if output was truncated
if (isTruncated) {
blocks.push({
type: 'context',
elements: [
{
type: 'mrkdwn',
text: ':warning: The output was truncated for display in Slack. Use the copy button to get the full content.'
}
]
});
}
logger.debug(`${FILE_NAME}: Generated ${blocks.length} blocks for conversion result`);
return blocks;
}
module.exports = {
getConversionResultBlocks
};

View file

@ -0,0 +1,298 @@
/**
* sigma_details_block.js
*
* Creates Slack Block Kit blocks for displaying Sigma rule explanations
*
*/
const logger = require('../utils/logger');
const { getFileName } = require('../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Create Slack block kit blocks for rule explanation
*
* @param {Object} details - The rule details object containing all rule metadata
* @returns {Array} Formatted Slack blocks ready for display
*/
function getRuleExplanationBlocks(details) {
logger.debug(`${FILE_NAME}: Creating rule explanation blocks for rule: ${details?.id || 'unknown'}`);
if (!details) {
logger.error('Failed to create explanation blocks: No details object provided');
return [
{
type: 'section',
text: {
type: 'mrkdwn',
text: 'Error: No explanation data provided'
}
}
];
}
// Map severity levels to emojis for visual representation
const severityConfig = {
'critical': { emoji: '🔴', text: 'Critical' },
'high': { emoji: '🟠', text: 'High' },
'medium': { emoji: '🟡', text: 'Medium' },
'low': { emoji: '🟢', text: 'Low' },
'informational': { emoji: '🔵', text: 'Info' }
};
// Normalize severity to lowercase for matching
const normalizedSeverity = (details.severity || '').toLowerCase();
const severityInfo = severityConfig[normalizedSeverity] || { emoji: '⚪', text: details.severity || 'Unknown' };
// Create a formatted severity indicator
const severityDisplay = `${severityInfo.emoji} *${severityInfo.text}*`;
logger.debug(`Rule severity: ${normalizedSeverity} (${severityDisplay})`);
/**
* Format tags with MITRE links where applicable
*
* @param {Array} tags - Array of tag strings to format
* @returns {Array} Array of formatted tags with links where appropriate
*/
const formatTags = (tags = []) => {
if (!tags || tags.length === 0 || (tags.length === 2 && tags.includes('error'))) {
logger.debug('No valid tags to format');
return [];
}
logger.debug(`Formatting ${tags.length} tags`);
return tags.map(tag => {
let formattedTag = tag.trim();
let url = '';
let displayText = formattedTag;
// Handle MITRE ATT&CK Technique IDs
if (/^T\d{4}(\.\d{3})?$/.test(formattedTag)) {
// Technique ID (e.g., T1234 or T1234.001)
url = `https://attack.mitre.org/techniques/${formattedTag}/`;
logger.debug(`Formatted MITRE technique: ${formattedTag}`);
}
// Handle MITRE ATT&CK Tactic IDs
else if (/^TA\d{4}$/.test(formattedTag)) {
// Tactic ID (e.g., TA0001)
url = `https://attack.mitre.org/tactics/${formattedTag}/`;
logger.debug(`Formatted MITRE tactic: ${formattedTag}`);
}
// Handle CWE IDs
else if (/^S\d{4}$/.test(formattedTag)) {
// CWE ID
url = `https://cwe.mitre.org/data/definitions/${formattedTag.substring(1)}.html`;
logger.debug(`Formatted CWE: ${formattedTag}`);
}
// Handle attack.* tactics
else if (formattedTag.startsWith('attack.')) {
const tacticName = formattedTag.substring(7); // Remove 'attack.' prefix
// Handle specific techniques with T#### format
if (/^t\d{4}(\.\d{3})?$/.test(tacticName)) {
const techniqueId = tacticName.toUpperCase();
url = `https://attack.mitre.org/techniques/${techniqueId}/`;
displayText = techniqueId;
logger.debug(`Formatted MITRE technique from attack. format: ${techniqueId}`);
}
// Handle regular tactics
else {
// Map common tactics to their MITRE ATT&CK IDs
const tacticMappings = {
'reconnaissance': 'TA0043',
'resourcedevelopment': 'TA0042',
'initialaccess': 'TA0001',
'execution': 'TA0002',
'persistence': 'TA0003',
'privilegeescalation': 'TA0004',
'defenseevasion': 'TA0005',
'credentialaccess': 'TA0006',
'discovery': 'TA0007',
'lateralmovement': 'TA0008',
'collection': 'TA0009',
'command-and-control': 'TA0011',
'exfiltration': 'TA0010',
'impact': 'TA0040'
};
// Remove hyphens and convert to lowercase for matching
const normalizedTactic = tacticName.toLowerCase().replace(/-/g, '');
if (tacticMappings[normalizedTactic]) {
url = `https://attack.mitre.org/tactics/${tacticMappings[normalizedTactic]}/`;
logger.debug(`Mapped tactic ${tacticName} to ${tacticMappings[normalizedTactic]}`);
} else {
// If we don't have a specific mapping, try a search
url = `https://attack.mitre.org/search/?q=${encodeURIComponent(tacticName)}`;
logger.debug(`Created search URL for unmapped tactic: ${tacticName}`);
}
// Format the display text with proper capitalization
displayText = tacticName.replace(/-/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
}
// Handle CVE IDs
else if (/^CVE-\d{4}-\d{4,}$/i.test(formattedTag)) {
url = `https://nvd.nist.gov/vuln/detail/${formattedTag.toUpperCase()}`;
displayText = formattedTag.toUpperCase();
logger.debug(`Formatted CVE: ${displayText}`);
}
if (url) {
return `<${url}|${displayText}>`;
} else {
return displayText;
}
});
};
// Define header based on title - check if it contains error messages
const isErrorMessage = details.title.toLowerCase().includes('error') ||
details.title.toLowerCase().includes('missing');
if (isErrorMessage) {
logger.warn(`Rule appears to have errors: ${details.title}`);
}
// Start with header block
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: details.title || 'Rule Explanation',
emoji: true
}
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `ID: ${details.id || 'Unknown'}`
}
]
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Severity:* ${severityDisplay}`
},
{
type: 'mrkdwn',
text: `*Author:* ${details.author || 'Unknown'}`
}
]
}
];
// Add divider for visual separation
blocks.push({ type: 'divider' });
// Add description section
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*Description:*\n${details.description || 'No description available'}`
}
});
// Detection explanation section - only add if not an error case or has useful detection info
if (!isErrorMessage || details.detectionExplanation !== 'Content missing') {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*What This Rule Detects:*\n${details.detectionExplanation || 'No detection information available'}`
}
});
}
// False positives section - only add if not an error case with N/A values
if (details.falsePositives && !details.falsePositives.includes('N/A - Content missing')) {
const fpItems = Array.isArray(details.falsePositives)
? details.falsePositives.map(item => `${item}`).join('\n')
: `${details.falsePositives}`;
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*Possible False Positives:*\n${fpItems || 'None specified'}`
}
});
}
// Add tags if they exist and are formatted
const formattedTags = formatTags(details.tags);
if (formattedTags.length > 0) {
logger.debug(`Added ${formattedTags.length} formatted tags to the block`);
blocks.push({
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*Tags:* ${formattedTags.join(' | ')}`
}
]
});
}
// If this is an error message, add a troubleshooting section
if (isErrorMessage) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: ':warning: *Troubleshooting:*\nThis rule appears to have issues in the database. You may want to check the rule import process or run a database maintenance task to fix this issue.'
}
});
}
// Add action buttons for better interactivity
blocks.push({
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'View YAML',
emoji: true
},
action_id: 'view_yaml',
value: `view_yaml_${details.id}`
},
{
type: 'button',
text: {
type: 'plain_text',
text: 'Convert to SIEM Rule',
emoji: true
},
action_id: 'convert_rule_to_siem',
value: `convert_rule_to_siem_${details.id}`
},
]
});
// Add a divider at the end
blocks.push({
type: 'divider'
});
logger.debug(`Created ${blocks.length} blocks for rule explanation`);
return blocks;
}
module.exports = {
getRuleExplanationBlocks
};

View file

@ -0,0 +1,206 @@
/**
* sigma_search_results_block.js
*
* Generates Slack Block Kit blocks for displaying Sigma rule search results
* Includes pagination controls for navigating large result sets
*
* @author Fylgja Development Team
*/
const logger = require('../utils/logger');
const { getFileName } = require('../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Generate blocks for Slack UI to display search results with pagination
*
* @param {string} keyword - The search keyword used for the query
* @param {Array} results - Array of rule results from the search
* @param {Object} pagination - Pagination information object
* @returns {Array} - Slack blocks for displaying results
*/
const getSearchResultBlocks = (keyword, results, pagination = {}) => {
logger.debug(`${FILE_NAME}: Creating search result blocks for keyword: "${keyword}"`);
// Add debug for input validation
logger.debug(`${FILE_NAME}: Results type: ${typeof results}, isArray: ${Array.isArray(results)}, length: ${Array.isArray(results) ? results.length : 'N/A'}`);
logger.debug(`${FILE_NAME}: Pagination: ${JSON.stringify(pagination)}`);
// Ensure results is always an array
const safeResults = Array.isArray(results) ? results : [];
// Default pagination values if not provided
const pagingInfo = {
currentPage: pagination.currentPage || 1,
pageSize: pagination.pageSize || 10,
totalPages: pagination.totalPages || 0,
totalResults: pagination.totalResults || 0,
hasMore: pagination.hasMore || false
};
logger.debug(`${FILE_NAME}: Processing ${safeResults.length} search results (page ${pagingInfo.currentPage} of ${pagingInfo.totalPages}, total: ${pagingInfo.totalResults})`);
// Initialize with header block that includes pagination info
const blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*Search Results for "${keyword}"*\n${
pagingInfo.totalResults > 0
? `Showing ${safeResults.length} of ${pagingInfo.totalResults} matching rules (page ${pagingInfo.currentPage} of ${pagingInfo.totalPages})`
: `Found ${safeResults.length} matching rules:`
}`
}
}
];
// Debug log as we build blocks
logger.debug(`${FILE_NAME}: Added header block`);
// Add blocks for each result if we have any
if (safeResults.length === 0) {
logger.debug(`${FILE_NAME}: No search results to display`);
blocks.push({
"type": "section",
"text": {
"type": "mrkdwn",
"text": pagingInfo.totalResults > 0
? "No rules on this page. Try a different page."
: "No matching rules found."
}
});
} else {
logger.debug(`${FILE_NAME}: Creating blocks for ${safeResults.length} search results`);
safeResults.forEach((rule, index) => {
// Ensure rule is an object with expected properties
const safeRule = rule || {};
const ruleId = safeRule.id || 'unknown';
logger.debug(`${FILE_NAME}: Adding result #${index + 1}: ${ruleId} - ${safeRule.title || 'Untitled'}`);
// Combine rule information and action button into a single line
blocks.push({
"type": "section",
"text": {
"type": "mrkdwn",
"text": `*${safeRule.title || 'Untitled Rule'}*\nID: \`${ruleId}\``
},
"accessory": {
"type": "button",
"text": {
"type": "plain_text",
"text": "Details",
"emoji": true
},
"value": ruleId,
"action_id": "view_rule_details"
}
});
// Add a divider between results (except after the last one)
if (index < safeResults.length - 1) {
blocks.push({
"type": "divider"
});
}
});
}
// Debug log for pagination controls
logger.debug(`${FILE_NAME}: Checking if pagination controls needed (totalPages: ${pagingInfo.totalPages})`);
// Add pagination navigation if there are multiple pages
if (pagingInfo.totalPages > 1) {
// Add a divider before pagination controls
blocks.push({
"type": "divider"
});
// Create pagination navigation buttons
const paginationButtons = [];
// Previous page button (if not on first page)
if (pagingInfo.currentPage > 1) {
paginationButtons.push({
"type": "button",
"text": {
"type": "plain_text",
"text": "Previous",
"emoji": true
},
"value": JSON.stringify({
keyword,
page: pagingInfo.currentPage - 1,
pageSize: pagingInfo.pageSize
}),
"action_id": "search_prev_page"
});
logger.debug(`${FILE_NAME}: Added Previous page button for page ${pagingInfo.currentPage - 1}`);
}
// Next page button (if there are more pages)
if (pagingInfo.hasMore) {
paginationButtons.push({
"type": "button",
"text": {
"type": "plain_text",
"text": "Next",
"emoji": true
},
"value": JSON.stringify({
keyword,
page: pagingInfo.currentPage + 1,
pageSize: pagingInfo.pageSize
}),
"action_id": "search_next_page"
});
logger.debug(`${FILE_NAME}: Added Next page button for page ${pagingInfo.currentPage + 1}`);
}
// Add the pagination buttons block if we have buttons to show
if (paginationButtons.length > 0) {
blocks.push({
"type": "actions",
"elements": paginationButtons
});
logger.debug(`${FILE_NAME}: Added ${paginationButtons.length} pagination buttons`);
}
// Add page indicator text
blocks.push({
"type": "context",
"elements": [
{
"type": "plain_text",
"text": `Page ${pagingInfo.currentPage} of ${pagingInfo.totalPages}`,
"emoji": true
}
]
});
logger.debug(`${FILE_NAME}: Added page indicator text`);
}
logger.debug(`${FILE_NAME}: Created ${blocks.length} blocks for search results`);
// Final validation of blocks array
if (!Array.isArray(blocks) || blocks.length === 0) {
logger.error(`${FILE_NAME}: Generated blocks is not a valid array or is empty`);
// Return a minimal valid blocks array
return [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": `Search Results for "${keyword}": Unable to generate proper blocks. Please try again.`
}
}
];
}
return blocks;
};
module.exports = {
getSearchResultBlocks
};

View file

@ -0,0 +1,269 @@
/**
* sigma_stats_block.js
*
* Creates Slack Block Kit blocks for displaying Sigma rule database statistics
*/
const logger = require('../utils/logger');
const { getFileName } = require('../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Create Slack block kit blocks for statistics display
*
* @param {Object} stats - The statistics object with all statistical data
* @returns {Array} Formatted Slack blocks ready for display
*/
function getStatsBlocks(stats) {
logger.debug(`${FILE_NAME}: Creating statistics display blocks`);
if (!stats) {
logger.error(`${FILE_NAME}: Failed to create statistics blocks: No stats object provided`);
return [
{
type: 'section',
text: {
type: 'mrkdwn',
text: 'Error: No statistics data provided'
}
}
];
}
// Format the date for display
const formatDate = (dateString) => {
if (!dateString) return 'Unknown';
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch (error) {
return dateString;
}
};
// Start with header block
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: 'Sigma Rule Database Statistics',
emoji: true
}
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Last updated: ${formatDate(stats.lastUpdate)}`
}
]
}
];
// Add divider for visual separation
blocks.push({ type: 'divider' });
// Overall statistics section
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Overall Statistics*'
}
});
blocks.push({
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Total Rules:* ${stats.totalRules.toLocaleString()}`
},
{
type: 'mrkdwn',
text: `*Database Health:* ${stats.databaseHealth.contentPercentage}% Complete`
}
]
});
// Add divider for visual separation
blocks.push({ type: 'divider' });
// Operating system breakdown
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Rules by Operating System*'
}
});
blocks.push({
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Windows:* ${stats.operatingSystems.windows.toLocaleString()} rules`
},
{
type: 'mrkdwn',
text: `*Linux:* ${stats.operatingSystems.linux.toLocaleString()} rules`
},
{
type: 'mrkdwn',
text: `*macOS:* ${stats.operatingSystems.macos.toLocaleString()} rules`
},
{
type: 'mrkdwn',
text: `*Other/Unknown:* ${stats.operatingSystems.other.toLocaleString()} rules`
}
]
});
// Add divider for visual separation
blocks.push({ type: 'divider' });
// Severity breakdown
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Rules by Severity Level*'
}
});
// Create a colorful representation of severity levels
const severityEmoji = {
'critical': '🔴',
'high': '🟠',
'medium': '🟡',
'low': '🟢',
'informational': '🔵'
};
let severityFields = [];
stats.severityLevels.forEach(level => {
const emoji = severityEmoji[level.level?.toLowerCase()] || '⚪';
severityFields.push({
type: 'mrkdwn',
text: `*${emoji} ${level.level ? (level.level.charAt(0).toUpperCase() + level.level.slice(1)) : 'Unknown'}:* ${level.count.toLocaleString()} rules`
});
});
// Ensure we have an even number of fields for layout
if (severityFields.length % 2 !== 0) {
severityFields.push({
type: 'mrkdwn',
text: ' ' // Empty space to balance fields
});
}
blocks.push({
type: 'section',
fields: severityFields
});
// Add divider for visual separation
blocks.push({ type: 'divider' });
// Top MITRE ATT&CK tactics
if (stats.mitreTactics && stats.mitreTactics.length > 0) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Top MITRE ATT&CK Tactics*'
}
});
const mitreFields = stats.mitreTactics.map(tactic => {
// Format tactic name for better readability
const formattedTactic = tactic.tactic
.replace(/-/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return {
type: 'mrkdwn',
text: `*${formattedTactic}:* ${tactic.count.toLocaleString()} rules`
};
});
// Split into multiple sections if needed for layout
for (let i = 0; i < mitreFields.length; i += 2) {
const sectionFields = mitreFields.slice(i, Math.min(i + 2, mitreFields.length));
// If we have an odd number at the end, add an empty field
if (sectionFields.length === 1) {
sectionFields.push({
type: 'mrkdwn',
text: ' ' // Empty space to balance fields
});
}
blocks.push({
type: 'section',
fields: sectionFields
});
}
blocks.push({ type: 'divider' });
}
// Top authors
if (stats.topAuthors && stats.topAuthors.length > 0) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Top Rule Authors*'
}
});
const authorFields = stats.topAuthors.map(author => ({
type: 'mrkdwn',
text: `*${author.name || 'Unknown'}:* ${author.count.toLocaleString()} rules`
}));
// Split into multiple sections if needed for layout
for (let i = 0; i < authorFields.length; i += 2) {
const sectionFields = authorFields.slice(i, Math.min(i + 2, authorFields.length));
// If we have an odd number at the end, add an empty field
if (sectionFields.length === 1) {
sectionFields.push({
type: 'mrkdwn',
text: ' ' // Empty space to balance fields
});
}
blocks.push({
type: 'section',
fields: sectionFields
});
}
}
// Add a footer
blocks.push({ type: 'divider' });
blocks.push({
type: 'context',
elements: [
{
type: 'mrkdwn',
text: 'Use `/sigma-search [keyword]` to search for specific rules and `/sigma-details [id]` to get detailed information about a rule.'
}
]
});
logger.debug(`${FILE_NAME}: Created ${blocks.length} blocks for statistics display`);
return blocks;
}
module.exports = {
getStatsBlocks
};

View file

@ -0,0 +1,83 @@
/**
* sigma_view_yaml_block.js
*
* Creates Slack Block Kit blocks for displaying Sigma rule YAML content
*
*/
const logger = require('../utils/logger');
const { getFileName } = require('../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Create Slack block kit blocks for displaying YAML content
*
* @param {string} ruleId - The ID of the rule
* @param {string} yamlContent - The YAML content to display
* @returns {Array} Formatted Slack blocks ready for display
*/
function getYamlViewBlocks(ruleId, yamlContent) {
logger.debug(`${FILE_NAME}: Creating YAML view blocks for rule: ${ruleId || 'unknown'}`);
if (!yamlContent) {
logger.warn(`${FILE_NAME}: Empty YAML content for rule: ${ruleId}`);
return [
{
type: 'header',
text: {
type: 'plain_text',
text: `YAML for Rule: ${ruleId}`,
emoji: true
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: '_No YAML content available_'
}
}
];
}
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: `YAML for Rule: ${ruleId}`,
emoji: true
}
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: '```\n' + yamlContent + '```'
}
}
];
blocks.push({
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: 'Convert to SIEM Rule',
emoji: true
},
action_id: 'convert_rule_to_siem',
value: `convert_rule_to_siem_${ruleId}`
},
]
});
logger.debug(`${FILE_NAME}: Created ${blocks.length} blocks for YAML view`);
return blocks;
}
module.exports = {
getYamlViewBlocks
};

83
src/config/appConfig.js Normal file
View file

@ -0,0 +1,83 @@
const path = require('path');
const fs = require('fs');
const yaml = require('js-yaml');
// Load YAML configuration file
let yamlConfig = {};
try {
const configPath = path.join(__dirname, '..', '..', 'fylgja.yml');
const fileContents = fs.readFileSync(configPath, 'utf8');
yamlConfig = yaml.load(fileContents);
console.log('Successfully loaded fylgja.yml configuration');
} catch (error) {
console.error(`Error loading fylgja.yml: ${error.message}`);
console.log('Using default configuration values');
// Default values will be used if file cannot be loaded
}
// Base directory for resolving relative paths from config
const baseDir = path.join(__dirname, '..', '..');
// Configuration paths
module.exports = {
SLACK_CONFIG: {
botToken: yamlConfig?.slack?.bot_token || process.env.SLACK_BOT_TOKEN,
signingSecret: yamlConfig?.slack?.signing_secret || process.env.SLACK_SIGNING_SECRET
},
// Server configuration from YAML (with fallback to env vars)
SERVER_CONFIG: {
port: parseInt(yamlConfig?.server?.port || process.env.PORT || 3000)
},
// Path configurations
SIGMA_REPO_DIR: yamlConfig?.paths?.sigma_repo_dir
? path.resolve(baseDir, yamlConfig.paths.sigma_repo_dir)
: path.join(baseDir, 'sigma-repo'),
DB_PATH: yamlConfig?.paths?.db_path
? path.resolve(baseDir, yamlConfig.paths.db_path)
: path.resolve(baseDir, 'sigma.db'),
// Load SIGMA_CLI_PATH from YAML, env, or use default path
SIGMA_CLI_PATH: yamlConfig?.sigma?.['sigma-cli']?.path
? path.resolve(baseDir, yamlConfig.sigma['sigma-cli'].path)
: path.join(process.env.VIRTUAL_ENV || './.venv', 'bin', 'sigma'),
// Sigma CLI configuration from YAML
SIGMA_CLI_CONFIG: {
backend: yamlConfig?.sigma?.['sigma-cli']?.backend || "lucene",
target: yamlConfig?.sigma?.['sigma-cli']?.target || "ecs_windows",
format: yamlConfig?.sigma?.['sigma-cli']?.format || "siem_rule_ndjson"
},
// Sigma Repository configuration from YAML
SIGMA_REPO_CONFIG: {
url: yamlConfig?.sigma?.repo?.url || "https://github.com/SigmaHQ/sigma.git",
branch: yamlConfig?.sigma?.repo?.branch || "main"
},
// Elasticsearch configuration from YAML
ELASTICSEARCH_CONFIG: {
apiEndpoint: yamlConfig?.elastic?.['api-endpoint'] ||
"http://localhost:5601/api/detection_engine/rules",
credentials: yamlConfig?.elastic?.['elastic-authentication-credentials'] ||
"elastic:changeme"
},
// Logging configuration from YAML
LOGGING_CONFIG: {
level: yamlConfig?.logging?.level || "info",
file: yamlConfig?.logging?.file || "./logs/fylgja.log"
},
// Default configuration (fallback)
DEFAULT_CONFIG: {
siem: 'elasticsearch',
lang: 'lucene',
output: 'ndjson',
repoUrl: yamlConfig?.sigma?.repo?.url || "https://github.com/SigmaHQ/sigma.git",
repoBranch: yamlConfig?.sigma?.repo?.branch || "main"
}
};

View file

View file

View file

@ -0,0 +1,88 @@
//
// config_handler.js
// handle the /sigma-config command
//
const util = require('util');
const { exec } = require('child_process');
const { SIGMA_CLI_PATH } = require('../../config/constants');
const { loadConfig, updateConfig } = require('../../config/config-manager');
const { updateSigmaDatabase } = require('../../services/sigma/sigma_repository_service');
const logger = require('../../utils/logger');
// Promisify exec for async/await usage
const execPromise = util.promisify(exec);
module.exports = (app) => {
app.command('/sigma-config', async ({ command, ack, respond }) => {
await ack();
logger.info(`Sigma config command received: ${command.text}`);
const args = command.text.split(' ');
if (args.length === 0 || args[0] === '') {
// Display current configuration
const config = loadConfig();
logger.info('Displaying current configuration');
await respond(`Current configuration:\nSIEM: ${config.siem}\nLanguage: ${config.lang}\nOutput: ${config.output}`);
return;
}
const configType = args[0];
if (configType === 'update') {
logger.info('Starting database update from command');
try {
await respond('Updating Sigma database... This may take a moment.');
await updateSigmaDatabase();
logger.info('Database update completed from command');
await respond('Sigma database updated successfully');
} catch (error) {
logger.error(`Database update failed: ${error.message}`);
await respond(`Error updating Sigma database: ${error.message}`);
}
return;
}
if (args.length < 2) {
logger.warn(`Invalid config command format: ${command.text}`);
await respond(`Invalid command format. Usage: /sigma-config ${configType} [value]`);
return;
}
const configValue = args[1];
const config = loadConfig();
if (configType === 'siem') {
// Verify the SIEM backend is installed
logger.info(`Attempting to change SIEM to: ${configValue}`);
try {
await execPromise(`${SIGMA_CLI_PATH} list targets | grep ${configValue}`);
updateConfig('siem', configValue);
logger.info(`SIEM configuration updated to: ${configValue}`);
await respond(`SIEM configuration updated to: ${configValue}`);
} catch (error) {
logger.error(`SIEM backend '${configValue}' not found or not installed`);
await respond(`Error: SIEM backend '${configValue}' not found or not installed. Please install it with: sigma plugin install ${configValue}`);
}
} else if (configType === 'lang') {
logger.info(`Changing language to: ${configValue}`);
updateConfig('lang', configValue);
await respond(`Language configuration updated to: ${configValue}`);
} else if (configType === 'output') {
// Check if output format is supported by the current backend
logger.info(`Attempting to change output format to: ${configValue}`);
try {
await execPromise(`${SIGMA_CLI_PATH} list formats ${config.siem} | grep ${configValue}`);
updateConfig('output', configValue);
logger.info(`Output configuration updated to: ${configValue}`);
await respond(`Output configuration updated to: ${configValue}`);
} catch (error) {
logger.error(`Output format '${configValue}' not supported by SIEM backend '${config.siem}'`);
await respond(`Error: Output format '${configValue}' not supported by SIEM backend '${config.siem}'. Run 'sigma list formats ${config.siem}' to see available formats.`);
}
} else {
logger.warn(`Unknown configuration type: ${configType}`);
await respond(`Unknown configuration type: ${configType}. Available types: siem, lang, output, update`);
}
});
};

View file

@ -0,0 +1,552 @@
/**
* 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
};

View file

@ -0,0 +1,62 @@
/**
* sigma_create_handler.js
*
* Handles Sigma rule conversion requests from Slack commands
* Action handlers moved to sigma_action_handlers.js
*/
const logger = require('../../utils/logger');
const { handleError } = require('../../utils/error_handler');
const { processRuleConversion } = require('./sigma_action_handlers');
const { SIGMA_CLI_CONFIG } = require('../../config/appConfig');
const FILE_NAME = 'sigma_create_handler.js';
/**
* Handle the sigma-create command for converting Sigma rules
*
* @param {Object} command - The Slack command object
* @param {Function} respond - Function to send response back to Slack
*/
const handleCommand = async (command, respond) => {
try {
logger.debug(`${FILE_NAME}: Processing sigma-create command: ${JSON.stringify(command.text)}`);
if (!command || !command.text) {
logger.warn(`${FILE_NAME}: Empty command received for sigma-create`);
await respond('Invalid command. Usage: /sigma-create [id]');
return;
}
// Extract rule ID and parameters
const params = command.text.trim().split(/\s+/);
const ruleId = params[0];
if (!ruleId) {
logger.warn(`${FILE_NAME}: Missing rule ID in sigma-create command`);
await respond('Invalid command: missing rule ID. Usage: /sigma-create [id]');
return;
}
await respond({
text: 'Processing your request... This may take a moment.',
response_type: 'ephemeral'
});
// Use configuration from YAML through constants
const config = {
backend: SIGMA_CLI_CONFIG.backend,
target: SIGMA_CLI_CONFIG.target,
format: SIGMA_CLI_CONFIG.format
};
await processRuleConversion(ruleId, config, respond, false, 'in_channel');
} catch (error) {
await handleError(error, `${FILE_NAME}: Create command handler`, respond, {
responseType: 'ephemeral'
});
}
};
module.exports = {
handleCommand
};

View file

@ -0,0 +1,56 @@
/**
* sigma_details_handler.js
*
* Handles Sigma rule details requests from Slack commands
* Processes requests for rule explanations
*/
const logger = require('../../utils/logger');
const { handleError } = require('../../utils/error_handler');
const { explainSigmaRule } = require('../../services/sigma/sigma_details_service');
const { processRuleDetails } = require('./sigma_action_handlers');
const FILE_NAME = 'sigma_details_handler.js';
/**
* Handle the sigma-details command for Sigma rules
*
* @param {Object} command - The Slack command object
* @param {Function} respond - Function to send response back to Slack
*/
const handleCommand = async (command, respond) => {
try {
logger.debug(`${FILE_NAME}: Processing sigma-details command: ${JSON.stringify(command.text)}`);
if (!command || !command.text) {
logger.warn(`${FILE_NAME}: Empty command received for sigma-details`);
await respond('Invalid command. Usage: /sigma-details [id]');
return;
}
// Extract rule ID
const ruleId = command.text.trim();
if (!ruleId) {
logger.warn(`${FILE_NAME}: Missing rule ID in sigma-details command`);
await respond('Invalid command: missing rule ID. Usage: /sigma-details [id]');
return;
}
// Inform user we're processing
await respond({
text: 'Processing your request... This may take a moment.',
response_type: 'ephemeral'
});
// Use the shared processRuleDetails function from action handlers
await processRuleDetails(ruleId, respond, false, 'in_channel');
} catch (error) {
await handleError(error, `${FILE_NAME}: Details command handler`, respond, {
responseType: 'ephemeral'
});
}
};
module.exports = {
handleCommand
};

View file

@ -0,0 +1,171 @@
/**
* sigma_search_handler.js
*
* Handles Sigma rule search requests from Slack commands
*/
const { searchSigmaRules } = require('../../services/sigma/sigma_search_service');
const logger = require('../../utils/logger');
const { handleError } = require('../../utils/error_handler');
const { getSearchResultBlocks } = require('../../blocks/sigma_search_results_block');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
const MAX_RESULTS_PER_PAGE = 10;
const MAX_RESULTS_THRESHOLD = 99;
/**
* Handle the sigma-search command for Sigma rules
* Searches for rules based on keywords and displays results with pagination
*
* @param {Object} command - The Slack command object
* @param {Function} respond - Function to send response back to Slack
*/
const handleCommand = async (command, respond) => {
try {
logger.debug(`${FILE_NAME}: Processing sigma-search command: ${JSON.stringify(command.text)}`);
if (!command || !command.text) {
logger.warn(`${FILE_NAME}: Empty command received for sigma-search`);
await respond('Invalid command. Usage: /sigma-search [keyword]');
return;
}
// Extract search keyword and check for pagination parameters
let keyword = command.text.trim();
let page = 1;
let pageSize = MAX_RESULTS_PER_PAGE;
// Check for pagination format: keyword page=X
const pagingMatch = keyword.match(/(.+)\s+page=(\d+)$/i);
if (pagingMatch) {
keyword = pagingMatch[1].trim();
page = parseInt(pagingMatch[2], 10) || 1;
logger.debug(`${FILE_NAME}: Detected pagination request: "${keyword}" page ${page}`);
}
// Check for page size format: keyword limit=X
const limitMatch = keyword.match(/(.+)\s+limit=(\d+)$/i);
if (limitMatch) {
keyword = limitMatch[1].trim();
pageSize = parseInt(limitMatch[2], 10) || MAX_RESULTS_PER_PAGE;
// Ensure the page size is within reasonable limits
pageSize = Math.min(Math.max(pageSize, 1), 100);
logger.debug(`${FILE_NAME}: Detected page size request: "${keyword}" limit ${pageSize}`);
}
if (!keyword) {
logger.warn(`${FILE_NAME}: Missing keyword in sigma-search command`);
await respond('Invalid command: missing keyword. Usage: /sigma-search [keyword]');
return;
}
logger.info(`${FILE_NAME}: Searching for rules with keyword: ${keyword} (page ${page}, size ${pageSize})`);
logger.debug(`${FILE_NAME}: Search keyword length: ${keyword.length}`);
await respond({
text: 'Searching for rules... This may take a moment.',
response_type: 'ephemeral'
});
// Search for rules using the service function with pagination
const searchResult = await searchSigmaRules(keyword, page, pageSize);
logger.debug(`${FILE_NAME}: Search result status: ${searchResult.success}`);
logger.debug(`${FILE_NAME}: Found ${searchResult.results?.length || 0} results out of ${searchResult.pagination?.totalResults || 0} total matches`);
logger.debug(`${FILE_NAME}: About to generate blocks for search results`);
if (!searchResult.success) {
logger.error(`${FILE_NAME}: Search failed: ${searchResult.message}`);
await respond({
text: `Search failed: ${searchResult.message}`,
response_type: 'ephemeral'
});
return;
}
// Get total count for validation
const totalCount = searchResult.pagination?.totalResults || 0;
// Check if search returned too many results
if (totalCount > MAX_RESULTS_THRESHOLD) {
logger.warn(`${FILE_NAME}: Search for "${keyword}" returned too many results (${totalCount}), displaying first page with warning`);
// Continue processing but add a notification
searchResult.tooManyResults = true;
}
if (!searchResult.results || searchResult.results.length === 0) {
if (totalCount > 0) {
logger.warn(`${FILE_NAME}: No rules found on page ${page} for "${keyword}", but ${totalCount} total matches exist`);
await respond({
text: `No rules found on page ${page} for "${keyword}". Try a different page or refine your search.`,
response_type: 'ephemeral'
});
} else {
logger.warn(`${FILE_NAME}: No rules found matching "${keyword}"`);
await respond({
text: `No rules found matching "${keyword}"`,
response_type: 'ephemeral'
});
}
return;
}
// Generate blocks with pagination support
let blocks;
try {
logger.debug(`${FILE_NAME}: Calling getSearchResultBlocks with ${searchResult.results.length} results`);
// If we have too many results, add a warning block at the beginning
if (searchResult.tooManyResults) {
blocks = getSearchResultBlocks(keyword, searchResult.results, searchResult.pagination);
// Insert warning at the beginning of blocks (after the header)
blocks.splice(1, 0, {
"type": "section",
"text": {
"type": "mrkdwn",
"text": `:warning: Your search for "${keyword}" returned ${totalCount} results, which is a lot. Displaying the first page. Consider using a more specific keyword for narrower results.`
}
});
} else {
blocks = getSearchResultBlocks(keyword, searchResult.results, searchResult.pagination);
}
logger.debug(`${FILE_NAME}: Successfully generated ${blocks?.length || 0} blocks`);
} catch (blockError) {
// Use error handler for block generation errors
await handleError(blockError, `${FILE_NAME}: Block generation`, respond, {
responseType: 'in_channel',
customMessage: `Found ${searchResult.results.length} of ${totalCount} rules matching "${keyword}" (page ${page} of ${searchResult.pagination?.totalPages || 1}). Use /sigma-details [id] to view details.`
});
return;
}
// Add debug log before sending response
logger.debug(`${FILE_NAME}: About to send response with ${blocks?.length || 0} blocks`);
// Determine if this should be visible to everyone or just the user
const isEphemeral = totalCount > 20;
// Respond with the search results
await respond({
blocks: blocks,
response_type: isEphemeral ? 'ephemeral' : 'in_channel'
});
// Add debug log after sending response
logger.debug(`${FILE_NAME}: Response sent successfully`);
} catch (error) {
// Use error handler for unexpected errors
await handleError(error, `${FILE_NAME}: Search command handler`, respond, {
responseType: 'ephemeral'
});
}
};
module.exports = {
handleCommand
};

View file

@ -0,0 +1,68 @@
/**
* sigma_stats_handler.js
*
* Handles Sigma rule statistics requests from Slack commands
* Processes requests for database statistics
*/
const logger = require('../../utils/logger');
const { handleError } = require('../../utils/error_handler');
const { getSigmaStats } = require('../../services/sigma/sigma_stats_service');
const { getStatsBlocks } = require('../../blocks/sigma_stats_block');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Handle the sigma-stats command for Sigma rules
*
* @param {Object} command - The Slack command object
* @param {Function} respond - Function to send response back to Slack
*/
const handleCommand = async (command, respond) => {
try {
logger.info(`${FILE_NAME}: Processing sigma-stats command`);
await respond({
text: 'Gathering Sigma rule statistics... This may take a moment.',
response_type: 'ephemeral'
});
// Get statistics from service
const statsResult = await getSigmaStats();
if (!statsResult.success) {
logger.error(`${FILE_NAME}: Failed to retrieve statistics: ${statsResult.message}`);
await respond({
text: `Error retrieving statistics: ${statsResult.message}`,
response_type: 'ephemeral'
});
return;
}
// Generate blocks for displaying statistics
let blocks;
try {
blocks = getStatsBlocks(statsResult.stats);
} catch (blockError) {
await handleError(blockError, `${FILE_NAME}: Block generation`, respond, {
responseType: 'ephemeral',
customMessage: 'Error generating statistics view'
});
return;
}
// Return the response
await respond({
blocks: blocks,
response_type: 'in_channel'
});
} catch (error) {
await handleError(error, `${FILE_NAME}: Stats command handler`, respond, {
responseType: 'ephemeral'
});
}
};
module.exports = {
handleCommand
};

View file

@ -0,0 +1,3 @@
//
// stats_handler.js
//

63
src/index.js Normal file
View file

@ -0,0 +1,63 @@
/**
* index.js
*
* Entry point for the Fylgja application.
* This module initializes the Slack bot server using configuration from fylgja.yml.
* It handles startup errors and provides logging.
*/
const app = require('./app');
const logger = require('./utils/logger');
const { version } = require('../package.json');
const { SERVER_CONFIG } = require('./config/appConfig');
// Start the app
const PORT = SERVER_CONFIG.port;
const ENV = process.env.NODE_ENV || 'development';
const { getFileName } = require('./utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Gracefully handles server shutdown
* Logs shutdown information and exits the process
*
* @param {string} reason - The reason for the shutdown
*/
function handleShutdown(reason) {
logger.info(`${FILE_NAME}: Shutting down server (${reason})`);
process.exit(0);
}
/**
* Immediately-invoked async function to start the server
* Allows for proper async/await error handling
*/
(async () => {
try {
logger.info(`${FILE_NAME}: Starting Fylgja Slack bot v${version} on port ${PORT} (${ENV} environment)`);
// Set up signal handlers for graceful shutdown
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
process.on('SIGINT', () => handleShutdown('SIGINT'));
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logger.error(`${FILE_NAME}: Uncaught exception: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error(`${FILE_NAME}: Unhandled rejection at: ${promise}, reason: ${reason}`);
// Continue running despite unhandled rejection
});
await app.start(PORT);
logger.info(`${FILE_NAME}: ✅ Fylgja Slack bot is running on port ${PORT}`);
logger.info(`${FILE_NAME}: Server URL: http://localhost:${PORT}`);
} catch (error) {
logger.error(`${FILE_NAME}: 💥 Fatal error starting app: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
process.exit(1);
}
})();

View file

View file

View file

@ -0,0 +1,154 @@
/**
* elastic_api_service.js
*
* Service for interacting with Elasticsearch API endpoints
*/
const axios = require('axios');
const logger = require('../../utils/logger');
const { ELASTICSEARCH_CONFIG } = require('../../config/appConfig');
const FILE_NAME = 'elastic_api_service.js';
/**
* Get Elasticsearch configuration with credentials
*
* @returns {Object} Configuration object with URL and credentials
*/
const getElasticConfig = () => {
return {
url: ELASTICSEARCH_CONFIG.apiEndpoint.split('/api/')[0] || process.env.ELASTIC_URL,
username: ELASTICSEARCH_CONFIG.credentials.split(':')[0] || process.env.ELASTIC_USERNAME,
password: ELASTICSEARCH_CONFIG.credentials.split(':')[1] || process.env.ELASTIC_PASSWORD,
apiEndpoint: ELASTICSEARCH_CONFIG.apiEndpoint
};
};
/**
* Send a rule to Elasticsearch SIEM
*
* @param {Object} rulePayload - The rule payload to send to Elasticsearch
* @returns {Promise<Object>} - Object containing success status and response/error information
*/
const sendRuleToSiem = async (rulePayload) => {
logger.info(`${FILE_NAME}: Sending rule to Elasticsearch SIEM`);
try {
const elasticConfig = getElasticConfig();
const apiUrl = elasticConfig.apiEndpoint;
logger.debug(`${FILE_NAME}: Using Elasticsearch API URL: ${apiUrl}`);
// Send the request to Elasticsearch
const response = await axios({
method: 'post',
url: apiUrl,
headers: {
'Content-Type': 'application/json',
'kbn-xsrf': 'true'
},
auth: {
username: elasticConfig.username,
password: elasticConfig.password
},
data: rulePayload
});
// Process the response
if (response.status >= 200 && response.status < 300) {
logger.info(`${FILE_NAME}: Successfully sent rule to SIEM`);
return {
success: true,
status: response.status,
data: response.data
};
} else {
logger.error(`${FILE_NAME}: Error sending rule to SIEM. Status: ${response.status}, Response: ${JSON.stringify(response.data)}`);
return {
success: false,
status: response.status,
message: `Failed to add rule to SIEM. Status: ${response.status}`,
data: response.data
};
}
} catch (error) {
logger.error(`${FILE_NAME}: API error sending rule to SIEM: ${error.message}`);
logger.debug(`${FILE_NAME}: API error details: ${error.response ? JSON.stringify(error.response.data) : 'No response data'}`);
const errorMessage = error.response && error.response.data && error.response.data.message
? error.response.data.message
: error.message;
return {
success: false,
message: errorMessage,
error: error
};
}
};
/**
* Make a generic request to an Elasticsearch API endpoint
*
* @param {Object} options - Request options
* @param {string} options.method - HTTP method (get, post, put, delete)
* @param {string} options.endpoint - API endpoint (appended to base URL)
* @param {Object} options.data - Request payload
* @param {Object} options.params - URL parameters
* @param {Object} options.headers - Additional headers
* @returns {Promise<Object>} - Response object
*/
const makeElasticRequest = async (options) => {
try {
const elasticConfig = getElasticConfig();
const baseUrl = elasticConfig.url;
// Build the full URL - use provided endpoint or default API endpoint
const url = options.endpoint ?
`${baseUrl}${options.endpoint.startsWith('/') ? '' : '/'}${options.endpoint}` :
elasticConfig.apiEndpoint;
logger.debug(`${FILE_NAME}: Making ${options.method} request to: ${url}`);
// Set up default headers
const headers = {
'Content-Type': 'application/json',
'kbn-xsrf': 'true',
...(options.headers || {})
};
// Make the request
const response = await axios({
method: options.method || 'get',
url: url,
headers: headers,
auth: {
username: elasticConfig.username,
password: elasticConfig.password
},
data: options.data || null,
params: options.params || null
});
// Return a standardized response
return {
success: response.status >= 200 && response.status < 300,
status: response.status,
data: response.data
};
} catch (error) {
logger.error(`${FILE_NAME}: Error in Elasticsearch API request: ${error.message}`);
return {
success: false,
message: error.response?.data?.message || error.message,
status: error.response?.status,
error: error
};
}
};
module.exports = {
sendRuleToSiem,
makeElasticRequest,
getElasticConfig
};

View file

View file

View file

@ -0,0 +1,153 @@
/**
* sigma_backend_converter.js
*
* Service for converting Sigma rules to various backend SIEM formats
* Uses the sigma-cli tool for conversion operations
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
const logger = require('../../utils/logger');
const { SIGMA_CLI_PATH, SIGMA_CLI_CONFIG } = require('../../config/appConfig');
const { convertSigmaRule } = require('./sigma_converter_service');
const { getRuleYamlContent } = require('../../sigma_db/sigma_db_queries');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Convert a Sigma rule to a specific backend format using the sigma-cli
*
* @param {string} ruleId - The ID of the rule to convert
* @param {Object} config - Configuration for the conversion
* @param {string} config.backend - Target backend (default from YAML config)
* @param {string} config.target - Query target (default from YAML config)
* @param {string} config.format - Output format (default from YAML config)
* @returns {Promise<Object>} Conversion result with output or error
*/
async function convertRuleToBackend(ruleId, config = {}) {
try {
// Validate configuration and set defaults from YAML config
const backend = config.backend || SIGMA_CLI_CONFIG.backend;
const target = config.target || SIGMA_CLI_CONFIG.target;
const format = config.format || SIGMA_CLI_CONFIG.format;
logger.info(`${FILE_NAME}: Converting rule ${ruleId} using backend: ${backend}, target: ${target}, format: ${format}`);
// Verify sigma-cli path
if (!fs.existsSync(SIGMA_CLI_PATH)) {
logger.error(`${FILE_NAME}: Sigma CLI not found at path: ${SIGMA_CLI_PATH}`);
return {
success: false,
message: 'Sigma CLI tool not found'
};
}
// Get the rule YAML content
const yamlResult = await getRuleYamlContent(ruleId);
if (!yamlResult.success || !yamlResult.content) {
logger.warn(`${FILE_NAME}: Failed to retrieve YAML for rule ${ruleId}: ${yamlResult.message || 'No content'}`);
return {
success: false,
message: yamlResult.message || 'Failed to retrieve rule content'
};
}
// Save the YAML to a temporary file
const tempDir = os.tmpdir();
const tempFilePath = path.join(tempDir, `sigma_rule_${ruleId}_${Date.now()}.yml`);
logger.debug(`${FILE_NAME}: Writing rule YAML to temp file: ${tempFilePath}`);
try {
fs.writeFileSync(tempFilePath, yamlResult.content);
} catch (fileError) {
logger.error(`${FILE_NAME}: Error writing temporary file: ${fileError.message}`);
return {
success: false,
message: `Error preparing rule for conversion: ${fileError.message}`
};
}
// Build the sigma-cli command
// Command syntax: sigma convert -t "$backend" -p "$target" -f "$format"
const command = `"${SIGMA_CLI_PATH}" convert -t ${backend} -p ${target} -f ${format} "${tempFilePath}"`;
// Execute the command
logger.debug(`${FILE_NAME}: Executing sigma-cli command: ${command}`);
let result;
try {
result = execSync(command, { encoding: 'utf8' });
} catch (execError) {
logger.error(`${FILE_NAME}: Sigma-cli execution error: ${execError.message}`);
// Clean up temporary file
try {
fs.unlinkSync(tempFilePath);
} catch (cleanupError) {
logger.warn(`${FILE_NAME}: Error removing temporary file: ${cleanupError.message}`);
}
return {
success: false,
message: `Error during rule conversion: ${execError.message}`
};
}
// Clean up temporary file
try {
fs.unlinkSync(tempFilePath);
} catch (cleanupError) {
logger.warn(`${FILE_NAME}: Error removing temporary file: ${cleanupError.message}`);
}
// Get rule metadata for context
const ruleData = await convertSigmaRule(ruleId);
if (!ruleData.success || !ruleData.rule) {
logger.warn(`${FILE_NAME}: Failed to get metadata for rule ${ruleId}`);
// return the conversion output
return {
success: true,
output: result.trim(),
rule: {
id: ruleId,
title: 'Unknown Rule',
description: 'Rule metadata could not be retrieved'
},
conversionDetails: {
backend,
target,
format
}
};
}
// Return the output with rule metadata
return {
success: true,
output: result.trim(),
rule: ruleData.rule,
conversionDetails: {
backend,
target,
format
}
};
} catch (error) {
logger.error(`${FILE_NAME}: Error converting rule ${ruleId} to backend: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return {
success: false,
message: `Error converting rule: ${error.message}`
};
}
}
module.exports = {
convertRuleToBackend
};

View file

@ -0,0 +1,422 @@
//
// sigma_converter_service.js
// converts Sigma rules to a structured object
//
const logger = require('../../utils/logger');
const yaml = require('js-yaml');
const { findRuleById } = require('../../sigma_db/sigma_db_queries');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Convert a Sigma rule to a structured object
* Can be called with either a rule object or a rule ID
*
* @param {Object|String} input - Either a raw rule object or a rule ID
* @param {Object} [config] - Optional configuration
* @returns {Promise<Object>} Converted rule or result object
*/
async function convertSigmaRule(input, config = null) {
// Check if we're dealing with a rule ID (string)
if (typeof input === 'string') {
try {
const ruleId = input;
logger.info(`Converting rule by ID: ${ruleId}`);
// Find the rule in database
const rawRule = await findRuleById(ruleId);
if (!rawRule) {
logger.warn(`Rule with ID ${ruleId} not found`);
return {
success: false,
message: `Rule with ID ${ruleId} not found`
};
}
// Debug: Log what we found
logger.debug(`Retrieved rule ${ruleId} from database: content ${rawRule.content ? 'present' : 'missing'}, parameters ${rawRule.parameters ? Object.keys(rawRule.parameters).length : 0}`);
// Check if content is missing (flag set by findRuleById)
if (rawRule.content_missing || !rawRule.content) {
logger.warn(`Rule with ID ${ruleId} has missing content, attempting to build from parameters`);
// Try to build from parameters
if (rawRule.parameters && Object.keys(rawRule.parameters).length > 0) {
const builtRule = buildRuleFromParameters(rawRule);
if (builtRule) {
logger.info(`Successfully built rule ${ruleId} from parameters`);
return {
success: true,
rule: builtRule,
built_from_parameters: true
};
}
}
logger.warn(`Could not build rule ${ruleId} from parameters, returning placeholder`);
return {
success: true,
rule: {
id: ruleId,
title: 'Rule Found But Content Missing',
description: `The rule with ID ${ruleId} exists in the database, but its content field is empty. This may indicate a problem with the rule import process.`,
author: 'Unknown',
level: 'unknown',
status: 'unknown',
logsource: {},
detection: { condition: 'Content missing' },
falsepositives: ['N/A - Content missing'],
tags: ['error', 'missing-content'],
references: [],
file_path: rawRule.file_path || 'unknown'
}
};
}
// Process the raw rule
const processedRule = processRuleContent(rawRule);
if (!processedRule) {
return {
success: false,
message: `Failed to process rule with ID ${ruleId}`
};
}
logger.debug(`Processing rule content for ${rawRule.id}:`);
return {
success: true,
rule: processedRule
};
} catch (error) {
logger.error(`Error converting rule by ID: ${error.message}`);
return {
success: false,
message: `Error converting rule: ${error.message}`
};
}
} else {
try {
if (!input) {
return {
success: false,
message: 'No rule data provided'
};
}
// Check for missing content
if (!input.content) {
logger.warn('Rule object has missing content, attempting to build from parameters');
// Try to build from parameters
if (input.parameters && Object.keys(input.parameters).length > 0) {
const builtRule = buildRuleFromParameters(input);
if (builtRule) {
logger.info(`Successfully built rule ${input.id} from parameters`);
return {
success: true,
rule: builtRule,
built_from_parameters: true
};
}
}
logger.warn(`Could not build rule from parameters, returning placeholder`);
return {
success: true,
rule: {
id: input.id || 'unknown',
title: 'Rule Found But Content Missing',
description: 'The rule exists in the database, but its content field is empty. This may indicate a problem with the rule import process.',
author: 'Unknown',
level: 'unknown',
status: 'unknown',
logsource: {},
detection: { condition: 'Content missing' },
falsepositives: ['N/A - Content missing'],
tags: ['error', 'missing-content'],
references: [],
file_path: input.file_path || 'unknown'
}
};
}
const processedRule = processRuleContent(input);
if (!processedRule) {
return {
success: false,
message: 'Failed to process rule object'
};
}
return {
success: true,
rule: processedRule
};
} catch (error) {
logger.error(`Error processing rule object: ${error.message}`);
return {
success: false,
message: `Error processing rule: ${error.message}`
};
}
}
}
/**
* Process rule content into a structured object
* @param {Object} rawRule - The raw rule object
* @returns {Object|null} Processed rule object
*/
function processRuleContent(rawRule) {
if (!rawRule) {
logger.warn('Cannot convert rule: rule object is null');
return null;
}
if (!rawRule.content) {
logger.warn('Cannot convert rule: missing content in rule data');
// Check if we have parameters and try to build from them
if (rawRule.parameters && Object.keys(rawRule.parameters).length > 0) {
logger.info(`Attempting to build rule ${rawRule.id} from parameters`);
return buildRuleFromParameters(rawRule);
}
return {
id: rawRule.id || 'unknown',
title: 'Error: Missing Rule Content',
description: 'The rule content could not be found in the database. This may indicate a problem with the rule import process or a corruption in the database.',
level: 'unknown',
file_path: rawRule.file_path || 'unknown',
falsepositives: ['N/A - Content missing'],
tags: ['error', 'missing-content'],
references: [],
detection: { condition: 'Content missing' }
};
}
try {
// Parse the YAML content
let parsedRule;
try {
// Log the content for debugging
logger.debug(`Parsing YAML content for rule ${rawRule.id}, content length: ${rawRule.content.length}`);
// Try different YAML parsing approaches
try {
parsedRule = yaml.load(rawRule.content);
} catch (yamlError) {
logger.warn(`Standard YAML parsing failed for ${rawRule.id}: ${yamlError.message}`);
// Try with more tolerant parsing
try {
// Try multi-document loading
const docs = [];
yaml.loadAll(rawRule.content, (doc) => {
if (doc) docs.push(doc);
});
if (docs.length > 0) {
parsedRule = docs[0]; // Take the first document
logger.debug(`Multi-document YAML parsing succeeded for ${rawRule.id}, found ${docs.length} documents`);
} else {
throw new Error('No documents found in multi-document parse');
}
} catch (multiError) {
logger.warn(`Multi-document YAML parsing failed for ${rawRule.id}: ${multiError.message}`);
// Last resort: manual extraction of key fields
parsedRule = extractFieldsManually(rawRule.content, rawRule.id);
}
}
if (!parsedRule) {
logger.warn(`Rule parsing resulted in null object for ID: ${rawRule.id}`);
parsedRule = {};
}
} catch (yamlError) {
logger.error(`YAML parsing error: ${yamlError.message}`);
logger.debug(`Problematic content (first 200 chars): ${rawRule.content.substring(0, 200)}`);
parsedRule = {};
}
// Create a new object combining database fields and parsed content
const convertedRule = {
id: rawRule.id || parsedRule.id || 'unknown',
title: parsedRule.title || 'Untitled Rule',
description: parsedRule.description || 'No description provided',
author: parsedRule.author || 'Unknown',
level: parsedRule.level || 'unknown',
status: parsedRule.status || 'unknown',
logsource: parsedRule.logsource || {},
detection: parsedRule.detection || {},
falsepositives: parsedRule.falsepositives || [],
tags: parsedRule.tags || [],
references: parsedRule.references || [],
file_path: rawRule.file_path || 'unknown'
};
logger.info(`Successfully converted rule ${convertedRule.id}`);
return convertedRule;
} catch (error) {
logger.error(`Error parsing rule: ${error.message}`);
return {
id: rawRule.id || 'unknown',
title: 'Error: Could not parse rule',
description: `Error parsing rule: ${error.message}`,
level: 'unknown',
file_path: rawRule.file_path || 'unknown',
falsepositives: [],
tags: ['error', 'parse-error'],
references: [],
detection: { condition: 'Parse error' }
};
}
}
/**
* Manual extraction of key fields from YAML content when parsing fails
* @param {string} content - The raw YAML content
* @param {string} ruleId - The rule ID
* @returns {Object} Extracted fields
*/
function extractFieldsManually(content, ruleId) {
logger.debug(`Attempting manual field extraction for rule ${ruleId}`);
const result = {
id: ruleId
};
// Simple regex patterns to extract common fields
const patterns = {
title: /title:\s*(.+)$/m,
description: /description:\s*(.+)$/m,
author: /author:\s*(.+)$/m,
level: /level:\s*(.+)$/m,
status: /status:\s*(.+)$/m
};
// Extract fields using regex
Object.entries(patterns).forEach(([field, pattern]) => {
const match = content.match(pattern);
if (match && match[1]) {
result[field] = match[1].trim();
}
});
logger.debug(`Manual extraction found ${Object.keys(result).length - 1} fields for rule ${ruleId}`);
return result;
}
/**
* Build a rule object from parameters when content is missing
* @param {Object} rawRule - The raw rule object with parameters
* @returns {Object} Reconstructed rule object
*/
function buildRuleFromParameters(rawRule) {
logger.info(`Building rule ${rawRule.id} from parameters`);
if (!rawRule || !rawRule.parameters) {
logger.warn(`Cannot build rule: missing parameters for rule ${rawRule ? rawRule.id : 'unknown'}`);
return null;
}
logger.debug(`Found ${Object.keys(rawRule.parameters).length} parameters for rule ${rawRule.id}`);
// Initialize a new rule object with essential properties
const reconstructedRule = {
id: rawRule.id,
title: rawRule.parameters.title || 'Unknown Title',
description: rawRule.parameters.description || 'No description available',
author: rawRule.parameters.author || 'Unknown',
file_path: rawRule.file_path || 'unknown',
level: rawRule.parameters.level || 'unknown',
status: rawRule.parameters.status || 'unknown',
logsource: {},
detection: { condition: rawRule.parameters['detection.condition'] || 'unknown' },
falsepositives: [],
tags: [],
references: []
};
// Process parameters to rebuild nested objects
Object.entries(rawRule.parameters).forEach(([key, value]) => {
// Handle array parameters
if (key === 'falsepositives' || key === 'tags' || key === 'references') {
if (Array.isArray(value)) {
reconstructedRule[key] = value;
} else if (typeof value === 'string') {
// Try to parse JSON string arrays
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) {
reconstructedRule[key] = parsed;
} else {
reconstructedRule[key] = [value];
}
} catch (e) {
reconstructedRule[key] = [value];
}
}
}
// Handle logsource properties
else if (key.startsWith('logsource.')) {
const prop = key.substring('logsource.'.length);
reconstructedRule.logsource[prop] = value;
}
// Handle detection properties
else if (key.startsWith('detection.') && key !== 'detection.condition') {
const prop = key.substring('detection.'.length);
const parts = prop.split('.');
let current = reconstructedRule.detection;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
}
});
logger.debug(`Reconstructed rule structure for ${rawRule.id}: ${JSON.stringify({
id: reconstructedRule.id,
title: reconstructedRule.title,
fields: Object.keys(reconstructedRule)
})}`);
return reconstructedRule;
}
/**
* Extract a readable condition string from a rule
* @param {Object} rule - The converted rule object
* @returns {String} Human-readable condition
*/
function extractDetectionCondition(rule) {
if (!rule) {
return 'No rule data available';
}
if (!rule.detection) {
return 'No detection information available';
}
if (!rule.detection.condition) {
return 'No condition specified';
}
return rule.detection.condition;
}
module.exports = {
convertSigmaRule,
extractDetectionCondition,
buildRuleFromParameters
};

View file

@ -0,0 +1,150 @@
/**
* sigma_details_service.js
*
* This service provides functionality for retrieving and explaining Sigma rules.
*/
const logger = require('../../utils/logger');
const { convertSigmaRule, extractDetectionCondition } = require('./sigma_converter_service');
const { debugRuleContent, getRuleYamlContent } = require('../../sigma_db/sigma_db_queries');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Explains a Sigma rule by providing a simplified, human-readable format
* Performs diagnostics before explanation and handles error cases
*
* @param {string} ruleId - The ID of the rule to explain
* @returns {Promise<Object>} Result object with success flag and explanation or error message
*/
async function explainSigmaRule(ruleId) {
if (!ruleId) {
logger.warn(`${FILE_NAME}: Cannot explain rule: Missing rule ID`);
return {
success: false,
message: 'Missing rule ID'
};
}
logger.info(`${FILE_NAME}: Running diagnostics for rule: ${ruleId}`);
logger.info(`${FILE_NAME}: Explaining rule ${ruleId}`);
try {
// Run diagnostics on the rule content first
const diagnosticResult = await debugRuleContent(ruleId);
logger.debug(`${FILE_NAME}: Diagnostic result: ${JSON.stringify(diagnosticResult || {})}`);
// Convert the rule ID to a structured object
const conversionResult = await convertSigmaRule(ruleId);
if (!conversionResult.success) {
logger.warn(`${FILE_NAME}: Failed to convert rule ${ruleId}: ${conversionResult.message}`);
return {
success: false,
message: conversionResult.message || `Failed to parse rule with ID ${ruleId}`
};
}
const rule = conversionResult.rule;
// Extra safety check
if (!rule) {
logger.error(`${FILE_NAME}: Converted rule is null for ID ${ruleId}`);
return {
success: false,
message: `Failed to process rule with ID ${ruleId}`
};
}
// Create a simplified explanation with safe access to properties
const explanation = {
id: rule.id || ruleId,
title: rule.title || 'Untitled Rule',
description: rule.description || 'No description provided',
author: rule.author || 'Unknown author',
severity: rule.level || 'Unknown',
detectionExplanation: extractDetectionCondition(rule),
falsePositives: Array.isArray(rule.falsepositives) ? rule.falsepositives :
typeof rule.falsepositives === 'string' ? [rule.falsepositives] :
['None specified'],
tags: Array.isArray(rule.tags) ? rule.tags : [],
references: Array.isArray(rule.references) ? rule.references : []
};
logger.info(`${FILE_NAME}: Successfully explained rule ${ruleId}`);
logger.debug(`${FILE_NAME}: Explanation properties: ${Object.keys(explanation).join(', ')}`);
return {
success: true,
explanation
};
} catch (error) {
logger.error(`${FILE_NAME}: Error explaining rule: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return {
success: false,
message: `Error explaining rule: ${error.message}`
};
}
}
/**
* Gets the raw YAML content of a Sigma rule
* Retrieves the content from the database
*
* @param {string} ruleId - The ID of the rule to get YAML for
* @returns {Promise<Object>} Result object with success flag and YAML content or error message
*/
async function getSigmaRuleYaml(ruleId) {
if (!ruleId) {
logger.warn(`${FILE_NAME}: Cannot get YAML: Missing rule ID`);
return {
success: false,
message: 'Missing rule ID'
};
}
logger.info(`${FILE_NAME}: Getting YAML content for rule: ${ruleId}`);
try {
// Get YAML content from database
const yamlResult = await getRuleYamlContent(ruleId);
if (!yamlResult.success) {
logger.warn(`${FILE_NAME}: Failed to retrieve YAML for rule ${ruleId}: ${yamlResult.message}`);
return {
success: false,
message: yamlResult.message || `Failed to retrieve YAML for rule with ID ${ruleId}`
};
}
// Add extra safety check for content
if (!yamlResult.content) {
logger.warn(`${FILE_NAME}: YAML content is empty for rule ${ruleId}`);
return {
success: true,
yaml: '',
warning: 'YAML content is empty for this rule'
};
}
logger.debug(`${FILE_NAME}: Successfully retrieved YAML content with length: ${yamlResult.content.length}`);
// Return the YAML content
return {
success: true,
yaml: yamlResult.content
};
} catch (error) {
logger.error(`${FILE_NAME}: Error retrieving YAML: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return {
success: false,
message: `Error retrieving YAML: ${error.message}`
};
}
}
module.exports = {
explainSigmaRule,
getSigmaRuleYaml
};

View file

@ -0,0 +1,188 @@
/**
* sigma_repository_service.js
*
* This service manages the Sigma rule repository and database updates.
* It provides functions to clone/update the repository and run the database
* initialization script.
*/
const { spawn } = require('child_process');
const util = require('util');
const { exec } = require('child_process');
const fs = require('fs');
const path = require('path');
const { SIGMA_REPO_DIR } = require('../../config/appConfig');
const appConfig = require('../../config/appConfig');
const logger = require('../../utils/logger');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
// Promisify exec for async/await usage
const execPromise = util.promisify(exec);
/**
* Clones or updates the Sigma repository
* Creates the repository directory if it doesn't exist
*
* @returns {Promise<boolean>} Success status of the operation
*/
async function updateSigmaRepo() {
logger.debug(`${FILE_NAME}: Starting Sigma repository update process`);
try {
// Ensure the parent directory exists
const parentDir = path.dirname(SIGMA_REPO_DIR);
if (!fs.existsSync(parentDir)) {
logger.debug(`${FILE_NAME}: Creating parent directory: ${parentDir}`);
fs.mkdirSync(parentDir, { recursive: true });
}
if (!fs.existsSync(SIGMA_REPO_DIR)) {
logger.info(`${FILE_NAME}: Cloning Sigma repository...`);
// Read config to get repo URL
const repoUrl = appConfig.SIGMA_REPO_CONFIG.url;
if (!repoUrl) {
throw new Error('Repository URL not found in configuration');
}
logger.debug(`${FILE_NAME}: Using repository URL: ${repoUrl}`);
const cloneResult = await execPromise(`git clone ${repoUrl} ${SIGMA_REPO_DIR}`);
logger.debug(`${FILE_NAME}: Clone output: ${cloneResult.stdout}`);
} else {
logger.info(`${FILE_NAME}: Updating existing Sigma repository...`);
// Check if it's actually a git repository
if (!fs.existsSync(path.join(SIGMA_REPO_DIR, '.git'))) {
logger.warn(`${FILE_NAME}: Directory exists but is not a git repository: ${SIGMA_REPO_DIR}`);
throw new Error('Directory exists but is not a git repository');
}
const pullResult = await execPromise(`cd ${SIGMA_REPO_DIR} && git pull`);
logger.debug(`${FILE_NAME}: Pull output: ${pullResult.stdout}`);
}
logger.info(`${FILE_NAME}: Sigma repository is up-to-date`);
return true;
} catch (error) {
logger.error(`${FILE_NAME}: Error updating Sigma repository: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return false;
}
}
/**
* Updates the Sigma database by running the initialization script
* Spawns a child process to run the database initialization
*
* @returns {Promise<boolean>} Success status of the operation
*/
async function updateSigmaDatabase() {
logger.info(`${FILE_NAME}: Starting database update process`);
return new Promise((resolve, reject) => {
const scriptPath = path.join(__dirname, '..', '..', 'db', 'init-sigma-db.js');
// Verify the script exists before trying to run it
if (!fs.existsSync(scriptPath)) {
logger.error(`${FILE_NAME}: Database initialization script not found at: ${scriptPath}`);
reject(new Error(`Database initialization script not found at: ${scriptPath}`));
return;
}
logger.info(`${FILE_NAME}: Running database update script: ${scriptPath}`);
const updateProcess = spawn('node', [scriptPath], {
stdio: 'pipe' // Capture output instead of inheriting
});
// Capture and log stdout
updateProcess.stdout.on('data', (data) => {
logger.debug(`${FILE_NAME}: DB Update stdout: ${data.toString().trim()}`);
});
// Capture and log stderr
updateProcess.stderr.on('data', (data) => {
logger.warn(`${FILE_NAME}: DB Update stderr: ${data.toString().trim()}`);
});
updateProcess.on('close', (code) => {
if (code === 0) {
logger.info(`${FILE_NAME}: Database update completed successfully`);
resolve(true);
} else {
logger.error(`${FILE_NAME}: Database update failed with exit code ${code}`);
reject(new Error(`Update failed with exit code ${code}`));
}
});
updateProcess.on('error', (err) => {
logger.error(`${FILE_NAME}: Failed to start database update process: ${err.message}`);
reject(err);
});
});
}
/**
* Checks the status of the Sigma repository
* Returns information about the repository including last commit
*
* @returns {Promise<Object>} Repository status information
*/
async function getSigmaRepoStatus() {
logger.debug(`${FILE_NAME}: Checking Sigma repository status`);
try {
if (!fs.existsSync(SIGMA_REPO_DIR)) {
logger.warn(`${FILE_NAME}: Sigma repository directory does not exist: ${SIGMA_REPO_DIR}`);
return {
exists: false,
message: 'Repository has not been cloned yet'
};
}
// Check if it's a git repository
if (!fs.existsSync(path.join(SIGMA_REPO_DIR, '.git'))) {
logger.warn(`${FILE_NAME}: Directory exists but is not a git repository: ${SIGMA_REPO_DIR}`);
return {
exists: true,
isRepo: false,
message: 'Directory exists but is not a git repository'
};
}
// Get last commit info
const lastCommitInfo = await execPromise(`cd ${SIGMA_REPO_DIR} && git log -1 --format="%h|%an|%ad|%s"`);
const [hash, author, date, subject] = lastCommitInfo.stdout.trim().split('|');
// Get branch info
const branchInfo = await execPromise(`cd ${SIGMA_REPO_DIR} && git branch --show-current`);
const currentBranch = branchInfo.stdout.trim();
return {
exists: true,
isRepo: true,
lastCommit: {
hash,
author,
date,
subject
},
branch: currentBranch,
path: SIGMA_REPO_DIR
};
} catch (error) {
logger.error(`${FILE_NAME}: Error getting repository status: ${error.message}`);
return {
exists: true,
error: error.message
};
}
}
module.exports = {
updateSigmaRepo,
updateSigmaDatabase,
getSigmaRepoStatus
};

View file

@ -0,0 +1,214 @@
/**
* sigma_search_service.js
*
* This service provides functionality for searching Sigma rules by keywords.
* It processes search results and returns them in a structured format.
* Supports pagination for large result sets.
*/
const { searchRules } = require('../../sigma_db/sigma_db_queries');
const logger = require('../../utils/logger');
const { convertSigmaRule } = require('./sigma_converter_service');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Searches for Sigma rules by keyword and processes the results
* Returns a structured result object with success status and paginated results
*
* @param {string} keyword - The keyword to search for
* @param {number} page - Page number (1-based index, default: 1)
* @param {number} pageSize - Number of results per page (default: 10)
* @returns {Promise<Object>} Result object with success flag and processed results with pagination info
*/
async function searchSigmaRules(keyword, page = 1, pageSize = 10) {
if (!keyword || typeof keyword !== 'string') {
logger.warn(`${FILE_NAME}: Cannot search rules: Missing or invalid keyword`);
return {
success: false,
message: 'Missing or invalid search keyword'
};
}
// Validate pagination parameters
if (typeof page !== 'number' || page < 1) {
logger.warn(`${FILE_NAME}: Invalid page number: ${page}, defaulting to 1`);
page = 1;
}
if (typeof pageSize !== 'number' || pageSize < 1 || pageSize > 100) {
logger.warn(`${FILE_NAME}: Invalid page size: ${pageSize}, defaulting to 10`);
pageSize = 10;
}
// Trim the keyword to prevent accidental whitespace issues
const trimmedKeyword = keyword.trim();
if (trimmedKeyword.length === 0) {
logger.warn(`${FILE_NAME}: Cannot search rules: Empty keyword after trimming`);
return {
success: false,
message: 'Search keyword cannot be empty'
};
}
// Calculate the offset based on page number
const offset = (page - 1) * pageSize;
logger.info(`${FILE_NAME}: Searching for Sigma rules with keyword: "${trimmedKeyword}" (page ${page}, size ${pageSize}, offset ${offset})`);
try {
// Pass pageSize and offset to the database query
const searchResult = await searchRules(trimmedKeyword, pageSize, offset);
// Defensive handling of possible return formats
let allResults = [];
let totalCount = 0;
// Log what we actually received for debugging
logger.debug(`${FILE_NAME}: Search result type: ${typeof searchResult}, isArray: ${Array.isArray(searchResult)}`);
// Handle different possible return formats
if (searchResult) {
if (Array.isArray(searchResult)) {
// Direct array of results
allResults = searchResult;
logger.debug(`${FILE_NAME}: Received array of ${allResults.length} results`);
} else if (typeof searchResult === 'object') {
// Object with results property
if (Array.isArray(searchResult.results)) {
allResults = searchResult.results;
totalCount = searchResult.totalCount || 0;
logger.debug(`${FILE_NAME}: Received object with ${allResults.length} results of ${totalCount} total matches`);
} else if (searchResult.totalCount !== undefined) {
// Object might have a different structure
totalCount = searchResult.totalCount;
logger.debug(`${FILE_NAME}: Received object with totalCount ${totalCount}`);
}
}
}
// Log what we extracted
logger.debug(`${FILE_NAME}: Extracted ${allResults.length} results for page ${page} of total ${totalCount}`);
if (allResults.length === 0 && totalCount === 0) {
logger.info(`${FILE_NAME}: No rules found matching "${trimmedKeyword}"`);
return {
success: true,
results: [],
message: `No rules found matching "${trimmedKeyword}"`,
pagination: {
currentPage: 1,
pageSize: pageSize,
totalPages: 0,
totalResults: 0,
hasMore: false
}
};
}
// Calculate total pages and pagination info based on total count from database
const totalPages = Math.ceil(totalCount / pageSize);
const hasMore = (offset + pageSize) < totalCount;
// Check if the requested page is valid
if (offset >= totalCount && totalCount > 0) {
// Return empty results but with pagination info
logger.warn(`${FILE_NAME}: Page ${page} exceeds available results (total: ${totalCount})`);
return {
success: true,
results: [],
message: `No results on page ${page}. Try a previous page.`,
pagination: {
currentPage: page,
pageSize: pageSize,
totalPages: totalPages,
totalResults: totalCount,
hasMore: false
}
};
}
// If we have results, include them with pagination info
logger.debug(`${FILE_NAME}: Returning ${allResults.length} results with pagination info (page ${page}/${totalPages}, total: ${totalCount})`);
return {
success: true,
results: allResults,
count: allResults.length,
pagination: {
currentPage: page,
pageSize: pageSize,
totalPages: totalPages,
totalResults: totalCount,
hasMore: hasMore
}
};
} catch (error) {
logger.error(`${FILE_NAME}: Error searching for rules: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return {
success: false,
message: `Error searching for rules: ${error.message}`
};
}
}
/**
* Enhanced search that returns fully converted rule objects with pagination support
* This is a more expensive operation than basic search
*
* @param {string} keyword - The keyword to search for
* @param {number} page - Page number (1-based index, default: 1)
* @param {number} pageSize - Number of results per page (default: 10)
* @returns {Promise<Object>} Result object with success flag and fully converted rule objects with pagination info
*/
async function searchAndConvertRules(keyword, page = 1, pageSize = 10) {
try {
// First perform a basic search with pagination
const searchResult = await searchSigmaRules(keyword, page, pageSize);
if (!searchResult.success || !searchResult.results || searchResult.results.length === 0) {
return searchResult;
}
logger.debug(`${FILE_NAME}: Converting ${searchResult.results.length} search results to full rule objects`);
// Convert each result to a full rule object
const convertedResults = [];
for (const result of searchResult.results) {
try {
const conversionResult = await convertSigmaRule(result.id);
if (conversionResult.success && conversionResult.rule) {
convertedResults.push(conversionResult.rule);
} else {
logger.warn(`${FILE_NAME}: Failed to convert rule ${result.id}: ${conversionResult.message || 'Unknown error'}`);
}
} catch (conversionError) {
logger.error(`${FILE_NAME}: Error converting rule ${result.id}: ${conversionError.message}`);
}
}
logger.info(`${FILE_NAME}: Successfully converted ${convertedResults.length} of ${searchResult.results.length} search results`);
// Include the pagination information from the search results
return {
success: true,
results: convertedResults,
count: convertedResults.length,
originalCount: searchResult.results.length,
pagination: searchResult.pagination
};
} catch (error) {
logger.error(`${FILE_NAME}: Error in searchAndConvertRules: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return {
success: false,
message: `Error searching and converting rules: ${error.message}`
};
}
}
module.exports = {
searchSigmaRules,
searchAndConvertRules
};

View file

@ -0,0 +1,53 @@
/**
* sigma_stats_service.js
*
* Service for retrieving and processing Sigma rule database statistics
* Provides aggregated statistical information about the rule database
*/
const logger = require('../../utils/logger');
const { getStatsFromDatabase } = require('../../sigma_db/sigma_db_queries');
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Get database statistics
* Collects various statistics about the Sigma rule database
*
* @returns {Promise<Object>} Object with success flag and statistics or error message
*/
async function getSigmaStats() {
logger.info(`${FILE_NAME}: Getting Sigma rule database statistics`);
try {
// Get statistics from database query function
const statsResult = await getStatsFromDatabase();
if (!statsResult.success) {
logger.error(`${FILE_NAME}: Failed to retrieve statistics: ${statsResult.message}`);
return {
success: false,
message: statsResult.message
};
}
logger.info(`${FILE_NAME}: Successfully collected database statistics`);
return {
success: true,
stats: statsResult.stats
};
} catch (error) {
logger.error(`${FILE_NAME}: Error processing statistics: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return {
success: false,
message: `Error processing statistics: ${error.message}`
};
}
}
module.exports = {
getSigmaStats
};

View file

@ -0,0 +1,93 @@
/**
* sigma_db_connection.js
*
* This module manages connections to the SQLite database for Sigma rules.
* It provides functions for creating and closing database connections with Promise support.
*/
const sqlite3 = require('sqlite3').verbose();
const { DB_PATH } = require('../config/appConfig');
const path = require('path');
const logger = require('../utils/logger');
const { getFileName } = require('../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Creates and returns a Promise-based connection to the SQLite database
* Adds promisified methods to the db object for easier async operations
*
* @returns {Promise<Object>} Promise resolving to the database connection object
*/
function getDbConnection() {
const absolutePath = path.resolve(DB_PATH);
logger.debug(`${FILE_NAME}: Attempting to connect to database at path: ${absolutePath}`);
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
logger.error(`${FILE_NAME}: Database connection error: ${err.message}`);
reject(err);
} else {
logger.debug(`${FILE_NAME}: Successfully connected to database at path: ${DB_PATH}`);
// Add promisified methods to db object
db.getAsync = function(sql, params) {
return new Promise((resolve, reject) => {
this.get(sql, params, function(err, row) {
if (err) {
logger.error(`${FILE_NAME}: Error in getAsync: ${err.message}`);
reject(err);
}
else resolve(row);
});
});
};
db.allAsync = function(sql, params) {
return new Promise((resolve, reject) => {
this.all(sql, params, function(err, rows) {
if (err) {
logger.error(`${FILE_NAME}: Error in allAsync: ${err.message}`);
reject(err);
}
else resolve(rows);
});
});
};
resolve(db);
}
});
});
}
/**
* Safely closes a database connection
*
* @param {Object} db - The database connection to close
* @returns {Promise<void>} Promise that resolves when the connection is closed
*/
function closeDbConnection(db) {
return new Promise((resolve, reject) => {
if (!db) {
logger.debug(`${FILE_NAME}: No database connection to close`);
resolve();
return;
}
db.close((err) => {
if (err) {
logger.error(`${FILE_NAME}: Error closing database: ${err.message}`);
reject(err);
} else {
logger.debug(`${FILE_NAME}: Database connection closed successfully`);
resolve();
}
});
});
}
module.exports = {
getDbConnection,
closeDbConnection
};

View file

@ -0,0 +1,560 @@
//
// sigma_db_initialize.js
// This script initializes the Sigma database by importing rules from the Sigma repository.
// and creating the SQLite database.
//
const fs = require('fs');
const util = require('util');
const sqlite3 = require('sqlite3').verbose();
const yaml = require('js-yaml');
const glob = util.promisify(require('glob'));
const logger = require('../utils/logger');
const { SIGMA_REPO_DIR, DB_PATH } = require('../config/appConfig');
const { updateSigmaRepo } = require('../services/sigma/sigma_repository_service');
const { getFileName } = require('../utils/file_utils');
const FILE_NAME = getFileName(__filename);
// Create database connection
function createDbConnection() {
return new Promise((resolve, reject) => {
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
reject(err);
return;
}
logger.info(`${FILE_NAME}: Connected to SQLite database at ${DB_PATH}`);
// CRITICAL FIX: Enable foreign key constraints
db.run('PRAGMA foreign_keys = ON;', (pragmaErr) => {
if (pragmaErr) {
logger.error(`${FILE_NAME}: Failed to enable foreign key constraints: ${pragmaErr.message}`);
reject(pragmaErr);
} else {
logger.info(`${FILE_NAME}: Foreign key constraints enabled`);
resolve(db);
}
});
});
});
}
// Initialize database schema
async function initializeDatabase(db) {
return new Promise((resolve, reject) => {
// Drop existing tables if they exist
db.run('DROP TABLE IF EXISTS rule_parameters', (err) => {
if (err) {
reject(err);
return;
}
db.run('DROP TABLE IF EXISTS sigma_rules', (err) => {
if (err) {
reject(err);
return;
}
// Create rules table with basic information
const createRulesTableSql = `
CREATE TABLE sigma_rules (
id TEXT PRIMARY KEY,
file_path TEXT,
content TEXT,
date DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
db.run(createRulesTableSql, (err) => {
if (err) {
reject(err);
return;
}
// Create rule_parameters table for individual parameters
const createParamsTableSql = `
CREATE TABLE rule_parameters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id TEXT,
param_name TEXT,
param_value TEXT,
param_type TEXT,
FOREIGN KEY (rule_id) REFERENCES sigma_rules(id) ON DELETE CASCADE
)
`;
db.run(createParamsTableSql, (err) => {
if (err) {
reject(err);
} else {
logger.info(`${FILE_NAME}: Database schema initialized`);
resolve();
}
});
});
});
});
});
}
// Determine if a YAML document is a Sigma rule
function isSigmaRule(doc) {
// Check for essential Sigma rule properties
return doc && doc.id && (
doc.detection ||
doc.logsource ||
doc.title
);
}
// Parse a Sigma YAML file and extract relevant fields
function parseRuleFile(filePath) {
try {
// Read the file content
const content = fs.readFileSync(filePath, 'utf8');
// Try to load as a multi-document YAML
let documents = [];
try {
yaml.loadAll(content, (doc) => {
if (doc) documents.push(doc);
});
} catch (e) {
// If multi-document parsing fails, try as a single document
logger.warn(`${FILE_NAME}: Multi-document parsing failed for ${filePath}: ${e.message}`);
try {
const doc = yaml.load(content);
if (doc) documents.push(doc);
} catch (singleError) {
logger.error(`Failed to parse ${filePath} as YAML: ${singleError.message}`);
}
}
// Filter to only include documents that look like Sigma rules
const ruleDocuments = documents.filter(isSigmaRule);
// Return array of rule objects
return ruleDocuments.map(rule => {
// Ensure rule.id exists - this is critical
if (!rule.id) {
logger.warn(`${FILE_NAME}: Rule in ${filePath} has no ID, skipping`);
return null;
}
return {
id: rule.id,
file_path: filePath,
content: content, // Explicitly assign content
parameters: extractParameters(rule)
};
}).filter(rule => rule !== null); // Remove null rules
} catch (error) {
logger.error(`${FILE_NAME}: Error parsing ${filePath}: ${error.message}`);
return [];
}
}
// Extract all parameters from a rule object, including nested ones
function extractParameters(rule) {
const params = [];
function processValue(name, value, parentKey = '') {
const fullKey = parentKey ? `${parentKey}.${name}` : name;
if (value === null || value === undefined) {
params.push({
param_name: fullKey,
param_value: '',
param_type: 'null'
});
} else if (typeof value === 'object' && !Array.isArray(value)) {
// For objects, add a parameter for the object itself
params.push({
param_name: fullKey,
param_value: JSON.stringify(value),
param_type: 'object'
});
// Then process all its properties
Object.entries(value).forEach(([k, v]) => {
processValue(k, v, fullKey);
});
} else if (Array.isArray(value)) {
// For arrays, add a parameter for the array itself
params.push({
param_name: fullKey,
param_value: JSON.stringify(value),
param_type: 'array'
});
// And also add individual array elements
value.forEach((item, index) => {
if (typeof item === 'object' && item !== null) {
processValue(index.toString(), item, fullKey);
} else {
params.push({
param_name: `${fullKey}[${index}]`,
param_value: String(item),
param_type: typeof item
});
}
});
} else {
// Handle primitive types
params.push({
param_name: fullKey,
param_value: String(value),
param_type: typeof value
});
}
}
// Process all properties in the rule
Object.entries(rule).forEach(([key, value]) => {
processValue(key, value);
});
return params;
}
// Import rules into database
async function importRules(db) {
// Find all YAML files in the entire repository
logger.info('${FILE_NAME}: Looking for rule files...');
// Get all YAML files - include both .yml and .yaml extensions
const files = await glob(`${SIGMA_REPO_DIR}/**/*.{yml,yaml}`);
logger.info(`${FILE_NAME}: Found ${files.length} total YAML files`);
// Prepare insert statements with explicit parameter naming
const insertRuleStmt = db.prepare(`
INSERT OR REPLACE INTO sigma_rules (id, file_path, content)
VALUES (?, ?, ?)
`);
const insertParamStmt = db.prepare(`
INSERT INTO rule_parameters (rule_id, param_name, param_value, param_type)
VALUES (?, ?, ?, ?)
`);
// Process each file
let importedRuleCount = 0;
let importedParamCount = 0;
let skippedFileCount = 0;
let errorCount = 0;
// Keep track of content status
let rulesWithContent = 0;
let rulesWithoutContent = 0;
for (const file of files) {
try {
const rules = parseRuleFile(file);
if (rules.length === 0) {
skippedFileCount++;
continue;
}
for (const rule of rules) {
// Begin transaction
await new Promise((resolve, reject) => {
db.run('BEGIN TRANSACTION', (err) => {
if (err) reject(err);
else resolve();
});
});
let transactionSuccessful = true;
try {
// Debug: Check content before insertion
const hasContent = !!(rule.content && rule.content.length > 0);
if (hasContent) {
rulesWithContent++;
} else {
rulesWithoutContent++;
logger.warn(`Rule ${rule.id} has no content!`);
// CRITICAL FIX: Ensure empty content is handled as empty string, not null
rule.content = '';
}
// Insert rule with explicit parameters
await new Promise((resolve, reject) => {
insertRuleStmt.run(
rule.id, // Parameter 1: id
rule.file_path, // Parameter 2: file_path
rule.content || '', // Parameter 3: content (ensure not null)
(err) => {
if (err) {
logger.error(`Error inserting rule ${rule.id}: ${err.message}`);
transactionSuccessful = false;
reject(err);
} else {
resolve();
}
}
);
});
// CRITICAL FIX: Only insert parameters if rule insertion was successful
if (transactionSuccessful) {
// Insert parameters
for (const param of rule.parameters) {
try {
await new Promise((resolve, reject) => {
insertParamStmt.run(
rule.id, // Parameter 1: rule_id
param.param_name, // Parameter 2: param_name
param.param_value, // Parameter 3: param_value
param.param_type, // Parameter 4: param_type
(err) => {
if (err) {
logger.error(`${FILE_NAME}: Error inserting parameter ${param.param_name} for rule ${rule.id}: ${err.message}`);
transactionSuccessful = false;
reject(err);
} else {
resolve();
}
}
);
});
// CRITICAL FIX: Stop processing parameters if any insertion fails
if (!transactionSuccessful) {
break;
}
importedParamCount++;
} catch (paramError) {
logger.error(`${FILE_NAME}: Error processing parameter: ${paramError.message}`);
transactionSuccessful = false;
break;
}
}
}
// CRITICAL FIX: Only commit if everything was successful
if (transactionSuccessful) {
// Commit transaction
await new Promise((resolve, reject) => {
db.run('COMMIT', (err) => {
if (err) {
transactionSuccessful = false;
reject(err);
} else {
resolve();
}
});
});
importedRuleCount++;
// Log progress every 100 rules
if (importedRuleCount % 100 === 0) {
logger.info(`${FILE_NAME}: Imported ${importedRuleCount} rules with ${importedParamCount} parameters, ${rulesWithContent} have content, ${rulesWithoutContent} missing content`);
}
} else {
// Rollback transaction if not successful
await new Promise((resolve) => {
db.run('ROLLBACK', () => resolve());
});
errorCount++;
}
} catch (error) {
// Rollback transaction on error
await new Promise((resolve) => {
db.run('ROLLBACK', () => resolve());
});
logger.error(`${FILE_NAME}: Error importing rule ${rule.id} from ${file}: ${error.message}`);
errorCount++;
}
}
} catch (error) {
logger.error(`${FILE_NAME}: Error processing file ${file}: ${error.message}`);
errorCount++;
}
}
insertRuleStmt.finalize();
insertParamStmt.finalize();
logger.info(`${FILE_NAME}: Import summary: ${importedRuleCount} rules imported with ${importedParamCount} parameters, ${skippedFileCount} files skipped, ${errorCount} errors`);
logger.info(`${FILE_NAME}: Content status: ${rulesWithContent} rules with content, ${rulesWithoutContent} rules without content`);
// Run diagnostics after import
await diagnoseContentImport(db);
}
// Diagnose rule content import issues
async function diagnoseContentImport(db) {
logger.info(`${FILE_NAME}: Starting content import diagnosis...`);
// Check total rule count
const ruleCount = await new Promise((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM sigma_rules', (err, row) => {
if (err) reject(err);
else resolve(row ? row.count : 0);
});
});
logger.info(`${FILE_NAME}: Total rules in database: ${ruleCount}`);
// Sample a few rules
const sampleRules = await new Promise((resolve, reject) => {
db.all('SELECT id, file_path, length(content) as content_length FROM sigma_rules LIMIT 5', (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
logger.info(`${FILE_NAME}: Sample rule content lengths: ${JSON.stringify(sampleRules.map(r => ({ id: r.id, content_length: r.content_length })))}`);
// Check the content column type
const tableInfo = await new Promise((resolve, reject) => {
db.all('PRAGMA table_info(sigma_rules)', (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
const contentColumn = tableInfo.find(col => col.name === 'content');
logger.info(`${FILE_NAME}: Content column info: ${JSON.stringify(contentColumn)}`);
// Check a rule's content explicitly
if (sampleRules.length > 0) {
const firstRuleId = sampleRules[0].id;
const ruleContent = await new Promise((resolve, reject) => {
db.get('SELECT content FROM sigma_rules WHERE id = ?', [firstRuleId], (err, row) => {
if (err) reject(err);
else resolve(row ? (row.content || null) : null);
});
});
logger.info(`${FILE_NAME}: First rule content check: id=${firstRuleId}, has_content=${ruleContent !== null}, type=${typeof ruleContent}, length=${ruleContent ? ruleContent.length : 0}`);
if (ruleContent) {
logger.info(`Content sample: ${ruleContent.substring(0, 100)}...`);
}
}
// Look for missing parameters
const paramCount = await new Promise((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM rule_parameters', (err, row) => {
if (err) reject(err);
else resolve(row ? row.count : 0);
});
});
logger.info(`${FILE_NAME}: Total parameters: ${paramCount}`);
// CRITICAL FIX: Check for orphaned parameters
const orphanedParamCount = await new Promise((resolve, reject) => {
db.get(`
SELECT COUNT(*) as count
FROM rule_parameters p
LEFT JOIN sigma_rules r ON p.rule_id = r.id
WHERE r.id IS NULL
`, (err, row) => {
if (err) reject(err);
else resolve(row ? row.count : 0);
});
});
logger.info(`${FILE_NAME}: Orphaned parameters (should be 0): ${orphanedParamCount}`);
if (orphanedParamCount > 0) {
logger.error(`Found ${orphanedParamCount} orphaned parameters! This indicates a foreign key constraint violation.`);
}
return {
rule_count: ruleCount,
sample_rules: sampleRules,
content_column: contentColumn,
param_count: paramCount,
orphaned_param_count: orphanedParamCount
};
}
// Create indexes on the database for better performance
async function createIndexes(db) {
return new Promise((resolve, reject) => {
const indexes = [
'CREATE INDEX IF NOT EXISTS idx_param_rule_id ON rule_parameters(rule_id)',
'CREATE INDEX IF NOT EXISTS idx_param_name ON rule_parameters(param_name)',
'CREATE INDEX IF NOT EXISTS idx_param_value ON rule_parameters(param_value)',
'CREATE INDEX IF NOT EXISTS idx_param_type ON rule_parameters(param_type)'
];
let completed = 0;
for (const indexSql of indexes) {
db.run(indexSql, (err) => {
if (err) {
reject(err);
} else {
completed++;
if (completed === indexes.length) {
logger.info('Database indexes created');
resolve();
}
}
});
}
});
}
// Main function
async function main() {
try {
// Update Sigma repository
const repoUpdated = await updateSigmaRepo();
if (!repoUpdated) {
logger.error(`${FILE_NAME}: Failed to update repository. Database may not be up-to-date.`);
}
// Connect to database
const db = await createDbConnection();
// Initialize database schema
await initializeDatabase(db);
// Import rules
await importRules(db);
// Create indexes
await createIndexes(db);
// Close database connection
db.close((err) => {
if (err) {
logger.error(`${FILE_NAME}: Error closing database: ${err.message}`);
} else {
logger.info(`${FILE_NAME}: Database connection closed`);
}
});
logger.info(`${FILE_NAME}: Database initialization completed successfully`);
process.exit(0);
} catch (error) {
logger.error(`${FILE_NAME}: Database initialization failed: ${error.message}`);
process.exit(1);
}
}
// If this script is run directly (not imported)
if (require.main === module) {
main();
}
module.exports = {
initializeDatabase,
importRules,
createIndexes
};

View file

@ -0,0 +1,585 @@
/**
*
* sigma_db_queries.js
* this script contains functions to interact with the Sigma database
*
* IMPORTANT:
* SQLite queries need explicit Promise handling when using db.all()
*
* We had an issue in that the Promise returned by db.all() wasn't being
* properly resolved in the async context. By wrapping the db.all() call in
* a new Promise and explicitly handling the callback, we ensure the query
* completes before continuing. This is important with SQLite where the
* connection state management can sometimes be tricky with async/await.
*
*/
const { getDbConnection } = require('./sigma_db_connection');
const logger = require('../utils/logger');
const { DB_PATH } = require('../config/appConfig');
const path = require('path');
const { getFileName } = require('../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/**
* Get a list of all rule IDs in the database
* Useful for bulk operations and database integrity checks
*
* @returns {Promise<Array>} Array of rule IDs or empty array on error
*/
async function getAllRuleIds() {
let db;
try {
logger.info(`${FILE_NAME}: Retrieving all rule IDs from database`);
db = await getDbConnection();
logger.debug(`${FILE_NAME}: Connected to database for retrieving all rule IDs`);
const result = await new Promise((resolve, reject) => {
db.all('SELECT id FROM sigma_rules ORDER BY id', [], (err, rows) => {
if (err) {
logger.error(`${FILE_NAME}: Error fetching all rule IDs: ${err.message}`);
reject(err);
} else {
resolve(rows || []);
}
});
});
logger.debug(`${FILE_NAME}: Retrieved ${result.length} rule IDs from database`);
return result.map(row => row.id);
} catch (error) {
logger.error(`${FILE_NAME}: Error retrieving all rule IDs: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return [];
} finally {
if (db) {
try {
await db.close();
logger.debug(`${FILE_NAME}: Database connection closed after retrieving all rule IDs`);
} catch (closeError) {
logger.warn(`${FILE_NAME}: Error closing database: ${closeError.message}`);
}
}
}
}
/**
* Find a Sigma rule by its ID
* Retrieves rule data and associated parameters from the database
*
* @param {string} ruleId - The ID of the rule to find
* @returns {Promise<Object|null>} The rule object or null if not found
*/
async function findRuleById(ruleId) {
if (!ruleId) {
logger.warn(`${FILE_NAME}: Cannot find rule: Missing rule ID`);
return null;
}
let db;
try {
db = await getDbConnection();
logger.debug(`${FILE_NAME}: Connected to database for rule lookup: ${ruleId}`);
// Get the base rule using promisified method
const rule = await db.getAsync('SELECT * FROM sigma_rules WHERE id = ?', [ruleId]);
if (!rule) {
logger.warn(`${FILE_NAME}: Rule with ID ${ruleId} not found in database`);
return null;
}
logger.debug(`${FILE_NAME}: Found base rule with ID ${ruleId}, content length: ${rule.content ? rule.content.length : 0}`);
// Get parameters using promisified method
const paramsAsync = await db.allAsync('SELECT param_name, param_value, param_type FROM rule_parameters WHERE rule_id = ?', [ruleId]);
logger.debug(`${FILE_NAME}: Params query returned ${paramsAsync ? paramsAsync.length : 0} results via allAsync`);
// Check if content is missing
if (!rule.content) {
logger.warn(`${FILE_NAME}: Rule with ID ${ruleId} exists but has no content`);
rule.content_missing = true;
}
// Get all parameters for this rule with case-insensitive matching
try {
const params = await new Promise((resolve, reject) => {
db.all(
'SELECT param_name, param_value, param_type FROM rule_parameters WHERE LOWER(rule_id) = LOWER(?)',
[ruleId],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
logger.debug(`${FILE_NAME}: Retrieved ${params ? params.length : 0} parameters for rule ${ruleId}`);
// Validate params is an array
if (params && Array.isArray(params)) {
// Attach parameters to the rule object
rule.parameters = {};
for (const param of params) {
if (param && param.param_name) {
// Convert value based on type
let value = param.param_value;
if (param.param_type === 'object' || param.param_type === 'array') {
try {
value = JSON.parse(param.param_value);
} catch (parseError) {
logger.warn(`${FILE_NAME}: Failed to parse JSON for parameter ${param.param_name}: ${parseError.message}`);
}
} else if (param.param_type === 'boolean') {
value = param.param_value === 'true';
} else if (param.param_type === 'number') {
value = Number(param.param_value);
}
rule.parameters[param.param_name] = value;
}
}
logger.debug(`${FILE_NAME}: Successfully processed ${Object.keys(rule.parameters).length} parameters for rule ${ruleId}`);
} else {
logger.warn(`${FILE_NAME}: Parameters for rule ${ruleId} not available or not iterable`);
rule.parameters = {};
}
} catch (paramError) {
logger.error(`${FILE_NAME}: Error fetching parameters for rule ${ruleId}: ${paramError.message}`);
logger.debug(`${FILE_NAME}: Parameter error stack: ${paramError.stack}`);
rule.parameters = {};
}
return rule;
} catch (error) {
logger.error(`${FILE_NAME}: Error finding rule ${ruleId}: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return null;
} finally {
// Close the database connection if it was opened
if (db && typeof db.close === 'function') {
try {
await db.close();
logger.debug(`${FILE_NAME}: Database connection closed after rule lookup`);
} catch (closeError) {
logger.warn(`${FILE_NAME}: Error closing database connection: ${closeError.message}`);
}
}
}
}
/**
* Search for Sigma rules by keyword in rule titles
* Performs a case-insensitive search and returns matching rules with pagination
*
* @param {string} keyword - The keyword to search for
* @param {number} limit - Maximum number of results to return (default: 10)
* @param {number} offset - Number of results to skip (for pagination, default: 0)
* @returns {Promise<Object>} Object with results array and total count
*/
async function searchRules(keyword, limit = 10, offset = 0) {
if (!keyword) {
logger.warn(`${FILE_NAME}: Empty search keyword provided`);
return { results: [], totalCount: 0 };
}
// Sanitize keyword to prevent SQL injection
const sanitizedKeyword = keyword.replace(/'/g, "''");
logger.info(`${FILE_NAME}: Searching for rules with keyword in title: ${sanitizedKeyword} (limit: ${limit}, offset: ${offset})`);
let db;
try {
// Make sure we properly await the DB connection
db = await getDbConnection();
logger.debug(`${FILE_NAME}: Database connection established for search`);
// First get the total count of matching rules (for pagination info)
const countQuery = `
SELECT COUNT(*) as count
FROM rule_parameters
WHERE param_name = 'title'
AND INSTR(LOWER(param_value), LOWER(?)) > 0
`;
const countResult = await new Promise((resolve, reject) => {
db.get(countQuery, [sanitizedKeyword], (err, row) => {
if (err) {
logger.error(`${FILE_NAME}: Count query error: ${err.message}`);
reject(err);
} else {
resolve(row || { count: 0 });
}
});
});
const totalCount = countResult.count;
logger.debug(`${FILE_NAME}: Total matching rules for "${sanitizedKeyword}": ${totalCount}`);
// Use parameterized query instead of string interpolation for better security
const instrQuery = `
SELECT rule_id, param_value AS title
FROM rule_parameters
WHERE param_name = 'title'
AND INSTR(LOWER(param_value), LOWER(?)) > 0
LIMIT ? OFFSET ?
`;
const results = await new Promise((resolve, reject) => {
db.all(instrQuery, [sanitizedKeyword, limit, offset], (err, rows) => {
if (err) {
logger.error(`${FILE_NAME}: Search query error: ${err.message}`);
reject(err);
} else {
logger.debug(`${FILE_NAME}: Search query returned ${rows ? rows.length : 0} results`);
resolve(rows || []);
}
});
});
logger.debug(`${FILE_NAME}: Search results page for keyword "${sanitizedKeyword}": ${results.length} matches (page ${Math.floor(offset / limit) + 1})`);
return {
results: results.map(r => ({ id: r.rule_id, title: r.title })),
totalCount
};
} catch (error) {
logger.error(`${FILE_NAME}: Error in search operation: ${error.message}`);
logger.debug(`${FILE_NAME}: Search error stack: ${error.stack}`);
return { results: [], totalCount: 0 };
} finally {
// Make sure we properly close the connection
if (db) {
try {
await new Promise((resolve) => db.close(() => resolve()));
logger.debug(`${FILE_NAME}: Database connection closed after search operation`);
} catch (closeError) {
logger.error(`${FILE_NAME}: Error closing database connection after search: ${closeError.message}`);
}
}
}
}
/**
* Debug function to retrieve detailed information about a rule's content
* Useful for diagnosing issues with rule retrieval and content parsing
*
* @param {string} ruleId - The ID of the rule to debug
* @returns {Promise<Object|null>} Object containing debug information or null on error
*/
async function debugRuleContent(ruleId) {
if (!ruleId) {
logger.warn(`${FILE_NAME}: Cannot debug rule: Missing rule ID`);
return null;
}
let db;
try {
db = await getDbConnection();
const absolutePath = path.resolve(DB_PATH);
logger.debug(`${FILE_NAME}: Debug function connecting to DB at path: ${absolutePath}`);
// Get raw rule record
const rule = await db.get('SELECT id, file_path, length(content) as content_length, typeof(content) as content_type FROM sigma_rules WHERE id = ?', [ruleId]);
if (!rule) {
logger.warn(`${FILE_NAME}: Rule with ID ${ruleId} not found during debug operation`);
return { error: 'Rule not found', ruleId };
}
// Return just the rule information without the undefined variables
return {
rule,
ruleId
};
} catch (error) {
logger.error(`${FILE_NAME}: Debug error for rule ${ruleId}: ${error.message}`);
logger.debug(`${FILE_NAME}: Debug error stack: ${error.stack}`);
return {
error: error.message,
stack: error.stack,
ruleId
};
} finally {
if (db) {
try {
await db.close();
logger.debug(`${FILE_NAME}: Database connection closed after debug operation`);
} catch (closeError) {
logger.warn(`${FILE_NAME}: Error closing database after debug: ${closeError.message}`);
}
}
}
}
/**
* Get the raw YAML content of a Sigma rule
* Retrieves the content field from the database which should contain YAML
*
* @param {string} ruleId - The ID of the rule
* @returns {Promise<Object>} Object with success flag and content or error message
*/
async function getRuleYamlContent(ruleId) {
if (!ruleId) {
logger.warn(`${FILE_NAME}: Cannot get YAML content: Missing rule ID`);
return { success: false, message: 'Missing rule ID' };
}
let db;
try {
logger.info(`${FILE_NAME}: Fetching YAML content for rule: ${ruleId}`);
logger.debug(`${FILE_NAME}: Rule ID type: ${typeof ruleId}, length: ${ruleId.length}`);
db = await getDbConnection();
logger.debug(`${FILE_NAME}: Connected to database for YAML retrieval`);
// Debug query before execution
const debugResult = await db.get('SELECT id, typeof(content) as content_type, length(content) as content_length FROM sigma_rules WHERE id = ?', [ruleId]);
logger.debug(`${FILE_NAME}: Debug query result: ${JSON.stringify(debugResult || 'not found')}`);
if (!debugResult) {
logger.warn(`${FILE_NAME}: Rule with ID ${ruleId} not found in debug query`);
return { success: false, message: 'Rule not found' };
}
// Get actual content
const rule = await new Promise((resolve, reject) => {
db.get('SELECT content FROM sigma_rules WHERE id = ?', [ruleId], (err, row) => {
if (err) {
logger.error(`${FILE_NAME}: Content query error: ${err.message}`);
reject(err);
} else {
resolve(row || null);
}
});
});
logger.debug(`${FILE_NAME}: Content query result for ${ruleId}: ${rule ? 'Found' : 'Not found'}`);
if (!rule) {
logger.warn(`${FILE_NAME}: Rule with ID ${ruleId} not found in content query`);
return { success: false, message: 'Rule not found' };
}
if (!rule.content) {
logger.warn(`${FILE_NAME}: Rule with ID ${ruleId} found but has no content`);
return { success: false, message: 'Rule found but content is empty' };
}
logger.debug(`${FILE_NAME}: Content retrieved successfully for ${ruleId}, type: ${typeof rule.content}, length: ${rule.content.length}`);
return { success: true, content: rule.content };
} catch (error) {
logger.error(`${FILE_NAME}: Error retrieving YAML content for ${ruleId}: ${error.message}`);
logger.debug(`${FILE_NAME}: YAML retrieval error stack: ${error.stack}`);
return { success: false, message: `Error retrieving YAML: ${error.message}` };
} finally {
if (db) {
try {
await db.close();
logger.debug(`${FILE_NAME}: Database connection closed after YAML retrieval`);
} catch (closeError) {
logger.warn(`${FILE_NAME}: Error closing database after YAML retrieval: ${closeError.message}`);
}
}
}
}
/**
* Get statistics about Sigma rules in the database
* Collects counts, categories, and other aggregate information
*
* @returns {Promise<Object>} Object with various statistics about the rules
*/
async function getStatsFromDatabase() {
let db;
try {
db = await getDbConnection();
logger.debug(`${FILE_NAME}: Connected to database for statistics`);
// Get total rule count
const totalRulesResult = await new Promise((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM sigma_rules', (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
const totalRules = totalRulesResult.count;
// Get last update time
const lastUpdateResult = await new Promise((resolve, reject) => {
db.get('SELECT MAX(date) as last_update FROM sigma_rules', (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
const lastUpdate = lastUpdateResult.last_update;
// Get rules by log source count (Windows, Linux, macOS)
const windowsRulesResult = await new Promise((resolve, reject) => {
db.get(`
SELECT COUNT(DISTINCT rule_id) as count
FROM rule_parameters
WHERE param_name = 'logsource' AND
param_value LIKE '%"product":"windows"%'`,
(err, row) => {
if (err) reject(err);
else resolve(row);
});
});
const windowsRules = windowsRulesResult.count || 0;
const linuxRulesResult = await new Promise((resolve, reject) => {
db.get(`
SELECT COUNT(DISTINCT rule_id) as count
FROM rule_parameters
WHERE param_name = 'logsource' AND
param_value LIKE '%"product":"linux"%'`,
(err, row) => {
if (err) reject(err);
else resolve(row);
});
});
const linuxRules = linuxRulesResult.count || 0;
const macosRulesResult = await new Promise((resolve, reject) => {
db.get(`
SELECT COUNT(DISTINCT rule_id) as count
FROM rule_parameters
WHERE param_name = 'logsource' AND
param_value LIKE '%"product":"macos"%'`,
(err, row) => {
if (err) reject(err);
else resolve(row);
});
});
const macosRules = macosRulesResult.count || 0;
// Get rules by severity level
const severityStats = await new Promise((resolve, reject) => {
db.all(`
SELECT param_value AS level, COUNT(DISTINCT rule_id) as count
FROM rule_parameters
WHERE param_name = 'level'
GROUP BY param_value
ORDER BY
CASE
WHEN param_value = 'critical' THEN 1
WHEN param_value = 'high' THEN 2
WHEN param_value = 'medium' THEN 3
WHEN param_value = 'low' THEN 4
WHEN param_value = 'informational' THEN 5
ELSE 6
END`,
(err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
// Get top 5 rule authors
const topAuthors = await new Promise((resolve, reject) => {
db.all(`
SELECT param_value AS author, COUNT(*) as count
FROM rule_parameters
WHERE param_name = 'author'
GROUP BY param_value
ORDER BY count DESC
LIMIT 5`,
(err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
// Get empty content count (rules with missing YAML)
const emptyContentResult = await new Promise((resolve, reject) => {
db.get(`
SELECT COUNT(*) as count
FROM sigma_rules
WHERE content IS NULL OR content = ''`,
(err, row) => {
if (err) reject(err);
else resolve(row);
});
});
const emptyContentCount = emptyContentResult.count;
// Get MITRE ATT&CK tactics statistics
const mitreStats = await new Promise((resolve, reject) => {
db.all(`
SELECT param_value AS tag, COUNT(DISTINCT rule_id) as count
FROM rule_parameters
WHERE param_name = 'tags' AND param_value LIKE 'attack.%'
GROUP BY param_value
ORDER BY count DESC
LIMIT 10`,
(err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
// Format MITRE tactics for display
const formattedMitreTactics = mitreStats.map(item => {
const tactic = item.tag.substring(7); // Remove 'attack.' prefix
return {
tactic: tactic,
count: item.count
};
});
// Compile all statistics
const stats = {
totalRules,
lastUpdate,
operatingSystems: {
windows: windowsRules,
linux: linuxRules,
macos: macosRules,
other: totalRules - (windowsRules + linuxRules + macosRules)
},
severityLevels: severityStats.map(s => ({ level: s.level, count: s.count })),
topAuthors: topAuthors.map(a => ({ name: a.author, count: a.count })),
databaseHealth: {
emptyContentCount,
contentPercentage: totalRules > 0 ? Math.round(((totalRules - emptyContentCount) / totalRules) * 100) : 0
},
mitreTactics: formattedMitreTactics
};
return {
success: true,
stats
};
} catch (error) {
logger.error(`${FILE_NAME}: Error retrieving statistics: ${error.message}`);
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
return {
success: false,
message: `Error retrieving statistics: ${error.message}`
};
} finally {
if (db) {
try {
await new Promise((resolve) => db.close(() => resolve()));
logger.debug(`${FILE_NAME}: Database connection closed after statistics retrieval`);
} catch (closeError) {
logger.warn(`${FILE_NAME}: Error closing database: ${closeError.message}`);
}
}
}
}
module.exports = {
getAllRuleIds,
findRuleById,
searchRules,
debugRuleContent,
getRuleYamlContent,
getStatsFromDatabase
};

View file

@ -0,0 +1,42 @@
/**
* error_handler.js
*
* Provides standardized error handling for Slack responses
*/
const logger = require('./logger');
/**
* Handle errors consistently across handlers
*
* @param {Error} error - The error that occurred
* @param {string} source - Source file/function where error occurred
* @param {Function} respond - Slack respond function
* @param {Object} options - Additional options
* @param {boolean} options.replaceOriginal - Whether to replace original message
* @param {string} options.responseType - Response type (ephemeral/in_channel)
* @param {string} options.customMessage - Custom message to display instead of error
*/
const handleError = async (error, source, respond, options = {}) => {
const {
replaceOriginal = false,
responseType = 'ephemeral',
customMessage = null
} = options;
// Log the error with consistent format
logger.error(`${source}: ${error.message}`);
logger.debug(`${source}: Error stack: ${error.stack}`);
// Respond to user with appropriate message
const displayMessage = customMessage || `An unexpected error occurred: ${error.message}`;
await respond({
text: displayMessage,
replace_original: replaceOriginal,
response_type: responseType
});
};
module.exports = {
handleError
};

10
src/utils/file_utils.js Normal file
View file

@ -0,0 +1,10 @@
// file_utils.js
const path = require('path');
function getFileName(filePath) {
return path.basename(filePath);
}
module.exports = {
getFileName
};

0
src/utils/formatters.js Normal file
View file

103
src/utils/logger.js Normal file
View file

@ -0,0 +1,103 @@
/**
* logger.js
* Handles logging functionality
*/
const fs = require('fs');
const path = require('path');
const { LOGGING_CONFIG } = require('../config/appConfig');
// Define log levels and their priority (higher number = higher priority)
const LOG_LEVELS = {
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4
};
// Get configured log level from config, default to INFO if not specified
const configuredLevel = (LOGGING_CONFIG?.level || 'info').toUpperCase();
const configuredLevelValue = LOG_LEVELS[configuredLevel] || LOG_LEVELS.INFO;
// Ensure logs directory exists
const LOGS_DIR = path.join(__dirname, '..', '..', 'logs');
if (!fs.existsSync(LOGS_DIR)) {
fs.mkdirSync(LOGS_DIR, { recursive: true });
}
// Use log file from config if available, otherwise use default
const LOG_FILE = LOGGING_CONFIG?.file
? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file)
: path.join(LOGS_DIR, 'fylgja.log');
// Create logger object
const logger = {
/**
* Internal method to write log entry to file and console if level meets threshold
* @param {string} level - Log level (DEBUG, INFO, WARN, ERROR)
* @param {string} message - Log message to write
*/
_writeToFile: (level, message) => {
// Check if this log level should be displayed based on configured level
const levelValue = LOG_LEVELS[level] || 0;
if (levelValue >= configuredLevelValue) {
const timestamp = new Date().toISOString();
const logEntry = `${timestamp} ${level}: ${message}\n`;
// Append to log file
try {
fs.appendFileSync(LOG_FILE, logEntry);
} catch (err) {
console.error(`Failed to write to log file: ${err.message}`);
}
// Also log to console with appropriate method
switch (level) {
case 'ERROR':
console.error(logEntry.trim());
break;
case 'WARN':
console.warn(logEntry.trim());
break;
case 'DEBUG':
console.debug(logEntry.trim());
break;
case 'INFO':
default:
console.info(logEntry.trim());
}
}
},
/**
* Log an info message
* @param {string} message - Message to log
*/
info: (message) => logger._writeToFile('INFO', message),
/**
* Log an error message
* @param {string} message - Message to log
*/
error: (message) => logger._writeToFile('ERROR', message),
/**
* Log a warning message
* @param {string} message - Message to log
*/
warn: (message) => logger._writeToFile('WARN', message),
/**
* Log a debug message
* @param {string} message - Message to log
*/
debug: (message) => logger._writeToFile('DEBUG', message),
/**
* Get the current log level
* @returns {string} Current log level
*/
getLogLevel: () => configuredLevel
};
module.exports = logger;

0
src/utils/validators.js Normal file
View file