From 519c87fb049a7625fb72dd0dc60b88cfe7355c6d Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Fri, 18 Apr 2025 17:15:41 -0400 Subject: [PATCH 1/5] create CLI and sigma stats function in CLI --- fylgja-cli | 2 + fylgja-cli.md | 59 +++ package-lock.json | 65 ++- package.json | 7 +- src/fylgja-cli.js | 509 ++++++++++++++++++++++ src/handlers/sigma/sigma_stats_handler.js | 5 +- src/services/sigma/sigma_stats_service.js | 16 +- src/utils/cli_formatters.js | 171 ++++++++ src/utils/logger.js | 61 ++- 9 files changed, 849 insertions(+), 46 deletions(-) create mode 100755 fylgja-cli create mode 100644 fylgja-cli.md create mode 100644 src/fylgja-cli.js create mode 100644 src/utils/cli_formatters.js diff --git a/fylgja-cli b/fylgja-cli new file mode 100755 index 0000000..0ac5b97 --- /dev/null +++ b/fylgja-cli @@ -0,0 +1,2 @@ +#!/bin/bash +node "$(dirname "$0")/src/fylgja-cli.js" "$@" diff --git a/fylgja-cli.md b/fylgja-cli.md new file mode 100644 index 0000000..2b5a7f5 --- /dev/null +++ b/fylgja-cli.md @@ -0,0 +1,59 @@ +# Fylgja CLI Interface + +The Fylgja CLI provides an interactive command-line interface for managing SIEM rules, similar to MySQL's CLI. + +## Usage + +Start the CLI interface: + +```bash +npm run cli +``` + +Or use the direct launcher: + +```bash +./fylgja-cli +``` + +## Features + +- **Interactive Prompt**: MySQL-style prompt with command history +- **Tab Completion**: Press Tab to auto-complete commands +- **Command History**: Use Up/Down arrows to navigate previous commands +- **Formatted Output**: Table-based output formats for different commands +- **Color Coding**: Visual indicators for severity levels and result types + +## Available Commands + +### Basic Commands + +``` +search Search for Sigma rules by keyword +details Get details about a specific Sigma rule +stats Get statistics about Sigma rules database +help Display help information +exit/quit Exit the CLI +clear Clear the terminal screen +``` + +### Advanced Search Commands + +``` +search sigma rules where title contains "ransomware" +find rules where tags include privilege_escalation +search rules where logsource.category == "process_creation" +find rules where modified after 2024-01-01 +``` + +## Examples + +``` +fylgja> search rules where level is "high" +fylgja> details 5f35f6c7-80a7-4ca0-a41f-31e8ac557233 +fylgja> stats +``` + +## Integration with Slack Bot + +The CLI interface uses the same command parsing and execution logic as the Slack bot, ensuring consistency across interfaces. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4cb0601..b1c7c87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,13 @@ "license": "ISC", "dependencies": { "@slack/bolt": "^4.2.1", + "axios": "^1.6.7", + "chalk": "^5.4.1", "dotenv": "^16.4.7", "express": "^5.1.0", "glob": "^8.1.0", "js-yaml": "^4.1.0", + "readline": "^1.3.0", "sqlite3": "^5.1.7" }, "devDependencies": { @@ -659,35 +662,17 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -797,6 +782,36 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -2441,6 +2456,12 @@ "node": ">= 6" } }, + "node_modules/readline": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", + "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==", + "license": "BSD" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 1cdbd06..283c675 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "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" + "test": "echo \"Error: no test specified\" && exit 1", + "cli": "node src/fylgja-cli.js" }, "keywords": [], "author": "", @@ -16,13 +17,15 @@ "dependencies": { "@slack/bolt": "^4.2.1", "axios": "^1.6.7", + "chalk": "^5.4.1", "dotenv": "^16.4.7", "express": "^5.1.0", "glob": "^8.1.0", "js-yaml": "^4.1.0", + "readline": "^1.3.0", "sqlite3": "^5.1.7" }, "devDependencies": { "concurrently": "^9.1.2" } -} \ No newline at end of file +} diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js new file mode 100644 index 0000000..612665f --- /dev/null +++ b/src/fylgja-cli.js @@ -0,0 +1,509 @@ +/** + * fylgja-cli.js + * + * Interactive CLI interface + */ + +const readline = require('readline'); +// Import chalk with compatibility for both ESM and CommonJS +let chalk; +try { + // First try CommonJS import (chalk v4.x) + chalk = require('chalk'); +} catch (e) { + // If that fails, provide a fallback implementation + chalk = { + blue: (text) => text, + green: (text) => text, + red: (text) => text, + yellow: (text) => text, + cyan: (text) => text, + white: (text) => text, + dim: (text) => text, + hex: () => (text) => text + }; +} + +const { parseCommand } = require('./lang/command_parser'); +const logger = require('./utils/logger'); +const sigmaSearchHandler = require('./handlers/sigma/sigma_search_handler'); +const sigmaDetailsHandler = require('./handlers/sigma/sigma_details_handler'); +const sigmaStatsHandler = require('./handlers/sigma/sigma_stats_handler'); +const sigmaCreateHandler = require('./handlers/sigma/sigma_create_handler'); +const { handleCommand: handleAlerts } = require('./handlers/alerts/alerts_handler'); +const { handleCommand: handleCase } = require('./handlers/case/case_handler'); +const { handleCommand: handleConfig } = require('./handlers/config/config_handler'); +const { handleCommand: handleStats } = require('./handlers/stats/stats_handler'); + +// Import CLI formatters +const { + formatSigmaStats, + formatSigmaSearchResults, + formatSigmaDetails +} = require('./utils/cli_formatters'); + +// Set logger to CLI mode (prevents console output) +logger.setCliMode(true); + +// Try to get version, but provide fallback if package.json can't be found +let version = '1.0.0'; +try { + const packageJson = require('../package.json'); + version = packageJson.version; +} catch (e) { + console.log('Could not load package.json, using default version'); +} + +const FILE_NAME = 'fylgja-cli.js'; + +// ASCII art logo for the CLI +const ASCII_LOGO = ` +░▒▓████████▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░░▒▓██████▓▒░ +░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ +░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ +░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░ ░▒▓█▓▒▒▓███▓▒░ ░▒▓█▓▒░▒▓████████▓▒░ +░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ +░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ +░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░ +`; + +// Command history array +let commandHistory = []; +let historyIndex = -1; + +// Create the readline interface +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + completer: completer, + prompt: 'fylgja> ' +}); + +/** + * Command auto-completion function + * @param {string} line Current command line input + * @returns {Array} Array with possible completions and the substring being completed + */ +function completer(line) { + const commands = [ + 'search sigma', + 'details sigma', + 'sigma stats', + 'stats sigma', + 'search sigma rules where title contains', + 'search rules where tags include', + 'search rules where logsource.category ==', + 'search rules where modified after', + 'help', + 'exit', + 'quit', + 'clear' + ]; + + const hits = commands.filter((c) => c.startsWith(line)); + return [hits.length ? hits : commands, line]; +} + +/** + * Format CLI output similar to MySQL + * @param {Object} data The data to format + * @param {string} type The type of data (results, details, stats) + */ +function formatOutput(data, type) { + if (!data) { + console.log('No data returned from the server.'); + return; + } + + switch (type) { + case 'search_results': + console.log('\n+-------+----------------------+------------------+-------------+'); + console.log('| ID | Title | Author | Level |'); + console.log('+-------+----------------------+------------------+-------------+'); + + if (data.results && data.results.length > 0) { + data.results.forEach(rule => { + const id = (rule.id || '').padEnd(5).substring(0, 5); + const title = (rule.title || '').padEnd(20).substring(0, 20); + const author = (rule.author || 'Unknown').padEnd(16).substring(0, 16); + const level = (rule.level || 'medium').padEnd(11).substring(0, 11); + + console.log(`| ${id} | ${title} | ${author} | ${level} |`); + }); + } else { + console.log('| No results found |'); + } + + console.log('+-------+----------------------+------------------+-------------+'); + console.log(`${data.totalCount || 0} rows in set`); + break; + + case 'details': + console.log('\n+----------------------+--------------------------------------------------+'); + console.log('| Field | Value |'); + console.log('+----------------------+--------------------------------------------------+'); + + for (const [key, value] of Object.entries(data)) { + if (typeof value !== 'object' || value === null) { + const formattedKey = key.padEnd(20).substring(0, 20); + const formattedValue = String(value || '').padEnd(48).substring(0, 48); + + console.log(`| ${formattedKey} | ${formattedValue} |`); + } + } + + console.log('+----------------------+--------------------------------------------------+'); + break; + + case 'stats': + console.log('\n+--------------------+---------------+'); + console.log('| Metric | Value |'); + console.log('+--------------------+---------------+'); + + for (const [key, value] of Object.entries(data)) { + const formattedKey = key.padEnd(18).substring(0, 18); + const formattedValue = String(value || '').padEnd(13).substring(0, 13); + + console.log(`| ${formattedKey} | ${formattedValue} |`); + } + + console.log('+--------------------+---------------+'); + break; + + default: + console.log(JSON.stringify(data, null, 2)); + } +} + +/** + * Parse out any basic search keywords from a complexSearch query + * This helps with the search commands that don't quite match the expected format + * @param {string} input The complex search query + * @returns {string} Extracted keywords + */ +function extractSearchKeywords(input) { + if (!input) return ''; + + // Try to extract keywords from common patterns + if (input.includes('title contains')) { + const match = input.match(/title\s+contains\s+["']([^"']+)["']/i); + if (match) return match[1]; + } + + if (input.includes('tags include')) { + const match = input.match(/tags\s+include\s+(\S+)/i); + if (match) return match[1]; + } + + // Default - just return the input as is + return input; +} + +/** + * Process a command from the CLI + * @param {string} input User input command + */ +async function processCommand(input) { + try { + // Skip empty commands + if (!input.trim()) { + rl.prompt(); + return; + } + + // Special CLI commands + if (input.trim().toLowerCase() === 'exit' || input.trim().toLowerCase() === 'quit') { + console.log('Goodbye!'); + rl.close(); + process.exit(0); + } + + if (input.trim().toLowerCase() === 'clear') { + console.clear(); + rl.prompt(); + return; + } + + // Special case for simple search + if (input.trim().match(/^search\s+sigma\s+(.+)$/i)) { + const keyword = input.trim().match(/^search\s+sigma\s+(.+)$/i)[1]; + + // Add to command history + commandHistory.push(input); + historyIndex = commandHistory.length; + + // Create fake command object + const command = { + text: keyword, + user_id: 'cli_user', + user_name: 'cli_user', + command: '/fylgja', + channel_id: 'cli', + channel_name: 'cli' + }; + + // Create custom respond function + const respond = createRespondFunction('search', 'sigma', [keyword]); + + console.log(`Executing: module=sigma, action=search, params=[${keyword}]`); + + try { + await sigmaSearchHandler.handleCommand(command, respond); + } catch (error) { + console.error(`Error: ${error.message}`); + logger.error(`${FILE_NAME}: Command execution error: ${error.message}`); + logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`); + rl.prompt(); + } + + return; + } + + // Add to command history + commandHistory.push(input); + historyIndex = commandHistory.length; + + // Parse command using existing parser + const parsedCommand = await parseCommand(input); + + if (!parsedCommand.success) { + console.log(parsedCommand.message || "Command not recognized. Type 'help' for usage."); + rl.prompt(); + return; + } + + // Extract the command details + const { action, module, params } = parsedCommand.command; + + // Only show execution info to the user, not sending to logger + console.log(`Executing: module=${module}, action=${action}, params=[${params}]`); + + // Create fake command object similar to Slack's + const command = { + text: Array.isArray(params) && params.length > 0 ? params[0] : input, + user_id: 'cli_user', + user_name: 'cli_user', + command: '/fylgja', + channel_id: 'cli', + channel_name: 'cli' + }; + + // Special handling for complexSearch to extract keywords + if (action === 'complexSearch' && module === 'sigma' && params.length > 0) { + // Try to extract keywords from complex queries + const searchTerms = extractSearchKeywords(params[0]); + command.text = searchTerms || params[0]; + } + + // Create custom respond function for CLI + const respond = createRespondFunction(action, module, params); + + try { + switch (module) { + case 'sigma': + switch (action) { + case 'search': + await sigmaSearchHandler.handleCommand(command, respond); + break; + + case 'complexSearch': + await sigmaSearchHandler.handleComplexSearch(command, respond); + break; + + case 'details': + await sigmaDetailsHandler.handleCommand(command, respond); + break; + + case 'stats': + await sigmaStatsHandler.handleCommand(command, respond); + break; + + case 'create': + await sigmaCreateHandler.handleCommand(command, respond); + break; + + default: + console.log(`Unknown Sigma action: ${action}`); + rl.prompt(); + } + break; + + case 'alerts': + await handleAlerts(command, respond); + break; + + case 'case': + await handleCase(command, respond); + break; + + case 'config': + await handleConfig(command, respond); + break; + + case 'stats': + await handleStats(command, respond); + break; + + case 'help': + displayHelp(); + rl.prompt(); + break; + + default: + console.log(`Unknown module: ${module}`); + rl.prompt(); + } + } catch (error) { + console.error(`Error: ${error.message}`); + // Log to file but not console + logger.error(`${FILE_NAME}: Command execution error: ${error.message}`); + logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`); + rl.prompt(); + } + } catch (error) { + console.error(`Fatal error: ${error.message}`); + // Log to file but not console + logger.error(`${FILE_NAME}: Fatal error: ${error.message}`); + logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`); + rl.prompt(); + } +} + +/** + * Create a custom respond function for handling results + * @param {string} action The action being performed + * @param {string} module The module being used + * @param {Array} params The parameters for the action + * @returns {Function} A respond function for handling results + */ +function createRespondFunction(action, module, params) { + return async (response) => { + if (typeof response === 'string') { + console.log(response); + rl.prompt(); + return; + } + + // First check for the responseData property (directly from service) + if (response.responseData) { + // Format the data using the appropriate formatter + if (module === 'sigma') { + let formattedData; + + if (action === 'search' || action === 'complexSearch') { + formattedData = formatSigmaSearchResults(response.responseData); + formatOutput(formattedData, 'search_results'); + } else if (action === 'details') { + formattedData = formatSigmaDetails(response.responseData); + formatOutput(formattedData, 'details'); + } else if (action === 'stats') { + formattedData = formatSigmaStats(response.responseData); + formatOutput(formattedData, 'stats'); + } else { + console.log(JSON.stringify(response.responseData, null, 2)); + } + } else { + // For other modules, just display the JSON + console.log(JSON.stringify(response.responseData, null, 2)); + } + } + // Fallback for text-only responses + else if (response.text) { + console.log(response.text); + } else { + console.log('Command completed successfully.'); + } + + rl.prompt(); + }; +} + +/** + * Display help text + */ +function displayHelp() { + const helpText = ` +Fylgja CLI Help + +Basic Sigma Commands: +- search sigma - Search for Sigma rules by keyword +- details sigma - Get details about a specific Sigma rule +- sigma stats - Get statistics about Sigma rules database + +Advanced Sigma Search Commands: +- search sigma rules where title contains "ransomware" - Search by title +- search sigma rules where tags include privilege_escalation - Search by tags +- search sigma rules where logsource.category == "process_creation" - Search by log source +- search sigma rules where modified after 2024-01-01 - Search by modification date + + +- exit or quit - Exit the CLI +- clear - Clear the terminal screen +- help - Display this help text + `; + + console.log(helpText); +} + +/** + * Start the CLI application + */ +function startCLI() { + console.log(ASCII_LOGO); + console.log(`Fylgja CLI v${version} - Interactive SIEM Management Tool`); + console.log(`Type 'help' for usage information or 'exit' to quit\n`); + + // Set up key bindings for history navigation + rl._writeToOutput = function _writeToOutput(stringToWrite) { + if (stringToWrite === '\\u001b[A' || stringToWrite === '\\u001b[B') { + // Don't output control characters for up/down arrows + return; + } + rl.output.write(stringToWrite); + }; + + // Set up key listeners for history + rl.input.on('keypress', (char, key) => { + if (key && key.name === 'up') { + if (historyIndex > 0) { + historyIndex--; + rl.line = commandHistory[historyIndex]; + rl.cursor = rl.line.length; + rl._refreshLine(); + } + } else if (key && key.name === 'down') { + if (historyIndex < commandHistory.length - 1) { + historyIndex++; + rl.line = commandHistory[historyIndex]; + rl.cursor = rl.line.length; + rl._refreshLine(); + } else if (historyIndex === commandHistory.length - 1) { + historyIndex = commandHistory.length; + rl.line = ''; + rl.cursor = 0; + rl._refreshLine(); + } + } + }); + + rl.prompt(); + + rl.on('line', async (line) => { + await processCommand(line.trim()); + }); + + rl.on('close', () => { + console.log('Goodbye!'); + process.exit(0); + }); +} + +// Check if running directly +if (require.main === module) { + startCLI(); +} else { + // Export functions for integration with main app + module.exports = { + startCLI + }; +} \ No newline at end of file diff --git a/src/handlers/sigma/sigma_stats_handler.js b/src/handlers/sigma/sigma_stats_handler.js index 5b49a63..d92e126 100644 --- a/src/handlers/sigma/sigma_stats_handler.js +++ b/src/handlers/sigma/sigma_stats_handler.js @@ -39,7 +39,7 @@ const handleCommand = async (command, respond) => { return; } - // Generate blocks for displaying statistics + // For Slack responses, generate Block Kit blocks let blocks; try { blocks = getStatsBlocks(statsResult.stats); @@ -51,9 +51,10 @@ const handleCommand = async (command, respond) => { return; } - // Return the response + // Return the response with both blocks for Slack and responseData for CLI await respond({ blocks: blocks, + responseData: statsResult.stats, // Include raw data for CLI response_type: 'in_channel' }); } catch (error) { diff --git a/src/services/sigma/sigma_stats_service.js b/src/services/sigma/sigma_stats_service.js index 0bee7a0..20cac15 100644 --- a/src/services/sigma/sigma_stats_service.js +++ b/src/services/sigma/sigma_stats_service.js @@ -31,11 +31,25 @@ async function getSigmaStats() { }; } + // Format the data in a consistent structure for both CLI and Slack + const formattedStats = { + lastUpdate: statsResult.stats.lastUpdate, + totalRules: statsResult.stats.totalRules, + databaseHealth: statsResult.stats.databaseHealth, + operatingSystems: statsResult.stats.operatingSystems, + severityLevels: statsResult.stats.severityLevels, + mitreTactics: statsResult.stats.mitreTactics, + topAuthors: statsResult.stats.topAuthors, + // Add any other statistics needed + }; + logger.info(`${FILE_NAME}: Successfully collected database statistics`); return { success: true, - stats: statsResult.stats + stats: formattedStats, + // Include raw response data for direct use by CLI + responseData: formattedStats }; } catch (error) { logger.error(`${FILE_NAME}: Error processing statistics: ${error.message}`); diff --git a/src/utils/cli_formatters.js b/src/utils/cli_formatters.js new file mode 100644 index 0000000..49636cd --- /dev/null +++ b/src/utils/cli_formatters.js @@ -0,0 +1,171 @@ +/** + * cli_formatters.js + * + * Dedicated formatters for CLI output of various data types + * Converts raw data into formatted CLI-friendly displays + */ +const chalk = require('chalk'); + +/** + * Format Sigma statistics for CLI display + * + * @param {Object} stats - The statistics object + * @returns {Object} Formatted stats ready for CLI display + */ +function formatSigmaStats(stats) { + if (!stats) { + return { error: 'No statistics data available' }; + } + + // Format date + const formatDate = (dateString) => { + if (!dateString) return 'Unknown'; + try { + const date = new Date(dateString); + return date.toLocaleString(); + } catch (error) { + return dateString; + } + }; + + // Create a simplified object suitable for table display + const formattedStats = { + 'Last Update': formatDate(stats.lastUpdate), + 'Total Rules': stats.totalRules.toLocaleString(), + 'Database Health': `${stats.databaseHealth.contentPercentage}% Complete`, + + // OS breakdown + 'Windows Rules': stats.operatingSystems.windows.toLocaleString(), + 'Linux Rules': stats.operatingSystems.linux.toLocaleString(), + 'macOS Rules': stats.operatingSystems.macos.toLocaleString(), + 'Other OS Rules': stats.operatingSystems.other.toLocaleString(), + + // Add severity levels + ...(stats.severityLevels || []).reduce((acc, level) => { + const levelName = level.level + ? level.level.charAt(0).toUpperCase() + level.level.slice(1) + : 'Unknown'; + + acc[`${levelName} Severity`] = level.count.toLocaleString(); + return acc; + }, {}) + }; + + // Add top MITRE tactics if available + if (stats.mitreTactics && stats.mitreTactics.length > 0) { + stats.mitreTactics.forEach((tactic, index) => { + if (index < 5) { // Only include top 5 for brevity + const formattedTactic = tactic.tactic + .replace(/-/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + formattedStats[`MITRE: ${formattedTactic}`] = tactic.count.toLocaleString(); + } + }); + } + + return formattedStats; +} + +/** + * Format Sigma search results for CLI display + * + * @param {Object} searchResults - The search results object + * @returns {Object} Formatted results ready for CLI display + */ +function formatSigmaSearchResults(searchResults) { + if (!searchResults || !searchResults.results) { + return { error: 'No search results available' }; + } + + // Return a structure with results and meta info + return { + results: searchResults.results.map(rule => ({ + id: rule.id || '', + title: rule.title || '', + author: rule.author || 'Unknown', + level: rule.level || 'medium' + })), + totalCount: searchResults.totalCount || 0 + }; +} + +/** + * Format Sigma rule details for CLI display + * + * @param {Object} ruleDetails - The rule details object + * @returns {Object} Formatted details ready for CLI display + */ +function formatSigmaDetails(ruleDetails) { + if (!ruleDetails) { + return { error: 'No rule details available' }; + } + + // Filter and format the rule details for CLI display + const formattedDetails = {}; + + // Include only the most important fields for display + const fieldsToInclude = [ + 'id', 'title', 'description', 'status', 'author', + 'level', 'falsepositives', 'references', + 'created', 'modified' + ]; + + // Add detection information if available + if (ruleDetails.detection && ruleDetails.detection.condition) { + fieldsToInclude.push('detection_condition'); + formattedDetails['detection_condition'] = ruleDetails.detection.condition; + } + + // Add logsource information if available + if (ruleDetails.logsource) { + if (ruleDetails.logsource.product) { + fieldsToInclude.push('logsource_product'); + formattedDetails['logsource_product'] = ruleDetails.logsource.product; + } + + if (ruleDetails.logsource.category) { + fieldsToInclude.push('logsource_category'); + formattedDetails['logsource_category'] = ruleDetails.logsource.category; + } + + if (ruleDetails.logsource.service) { + fieldsToInclude.push('logsource_service'); + formattedDetails['logsource_service'] = ruleDetails.logsource.service; + } + } + + // Format date fields + const dateFields = ['created', 'modified']; + + for (const [key, value] of Object.entries(ruleDetails)) { + if (fieldsToInclude.includes(key)) { + // Format dates + if (dateFields.includes(key) && value) { + try { + formattedDetails[key] = new Date(value).toLocaleString(); + } catch (e) { + formattedDetails[key] = value; + } + } + // Format arrays + else if (Array.isArray(value)) { + formattedDetails[key] = value.join(', '); + } + // Default handling + else { + formattedDetails[key] = value; + } + } + } + + return formattedDetails; +} + +module.exports = { + formatSigmaStats, + formatSigmaSearchResults, + formatSigmaDetails +}; \ No newline at end of file diff --git a/src/utils/logger.js b/src/utils/logger.js index 3225e2a..29c81a8 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -1,6 +1,7 @@ /** * logger.js - * Handles logging functionality + * + * Handles logging functionality with CLI mode support */ const fs = require('fs'); const path = require('path'); @@ -25,12 +26,29 @@ if (!fs.existsSync(LOGS_DIR)) { } // Use log file from config if available, otherwise use default -const LOG_FILE = LOGGING_CONFIG?.file - ? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file) +const LOG_FILE = LOGGING_CONFIG?.file + ? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file) : path.join(LOGS_DIR, 'fylgja.log'); +// Flag to determine if we're running in CLI mode +let isCliMode = false; + // Create logger object const logger = { + /** + * Set the CLI mode flag + * @param {boolean} mode - True to enable CLI mode (no console output) + */ + setCliMode: (mode) => { + isCliMode = !!mode; + }, + + /** + * Check if running in CLI mode + * @returns {boolean} CLI mode status + */ + isCliMode: () => isCliMode, + /** * Internal method to write log entry to file and console if level meets threshold * @param {string} level - Log level (DEBUG, INFO, WARN, ERROR) @@ -39,7 +57,7 @@ const logger = { _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`; @@ -48,23 +66,28 @@ const logger = { try { fs.appendFileSync(LOG_FILE, logEntry); } catch (err) { - console.error(`Failed to write to log file: ${err.message}`); + // If in CLI mode, don't output to console + if (!isCliMode) { + 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()); + // Only log to console if not in CLI mode + if (!isCliMode) { + 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()); + } } } }, From 657a33a1895f082c311483bf016095c206e732ca Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sat, 19 Apr 2025 12:44:45 -0400 Subject: [PATCH 2/5] rename explain-sigma-rules to sigma-rule-details --- src/blocks/sigma/sigma_details_block.js | 4 +- .../sigma/actions/sigma_action_core.js | 10 +- .../sigma/actions/sigma_siem_actions.js | 6 +- src/handlers/sigma/sigma_details_handler.js | 62 +++++++++-- src/services/sigma/sigma_details_service.js | 4 +- src/services/sigma/sigma_stats_service.js | 4 +- src/utils/cli_formatters.js | 101 ++++++------------ 7 files changed, 96 insertions(+), 95 deletions(-) diff --git a/src/blocks/sigma/sigma_details_block.js b/src/blocks/sigma/sigma_details_block.js index d79a410..89ca272 100644 --- a/src/blocks/sigma/sigma_details_block.js +++ b/src/blocks/sigma/sigma_details_block.js @@ -15,7 +15,7 @@ const FILE_NAME = getFileName(__filename); * @param {Object} details - The rule details object containing all rule metadata * @returns {Array} Formatted Slack blocks ready for display */ -function getRuleExplanationBlocks(details) { +function getSigmaRuleDetailsBlocks(details) { logger.debug(`${FILE_NAME}: Creating rule explanation blocks for rule: ${details?.id || 'unknown'}`); if (!details) { @@ -294,5 +294,5 @@ function getRuleExplanationBlocks(details) { } module.exports = { - getRuleExplanationBlocks + getSigmaRuleDetailsBlocks }; diff --git a/src/handlers/sigma/actions/sigma_action_core.js b/src/handlers/sigma/actions/sigma_action_core.js index 7a9af17..dd79157 100644 --- a/src/handlers/sigma/actions/sigma_action_core.js +++ b/src/handlers/sigma/actions/sigma_action_core.js @@ -5,9 +5,9 @@ */ const logger = require('../../../utils/logger'); const { handleError } = require('../../../utils/error_handler'); -const { explainSigmaRule } = require('../../../services/sigma/sigma_details_service'); +const { getSigmaRuleDetails } = require('../../../services/sigma/sigma_details_service'); const { convertRuleToBackend } = require('../../../services/sigma/sigma_backend_converter'); -const { getRuleExplanationBlocks } = require('../../../blocks/sigma/sigma_details_block'); +const { getSigmaRuleDetailsBlocks } = require('../../../blocks/sigma/sigma_details_block'); const { getConversionResultBlocks } = require('../../../blocks/sigma/sigma_conversion_block'); const { SIGMA_CLI_CONFIG } = require('../../../config/appConfig'); @@ -38,8 +38,8 @@ const processRuleDetails = async (ruleId, respond, replaceOriginal = false, resp 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); + logger.info(`${FILE_NAME}: Calling getSigmaRuleDetails with ID: '${ruleId}'`); + const result = await getSigmaRuleDetails(ruleId); if (!result.success) { logger.error(`${FILE_NAME}: Rule details retrieval failed: ${result.message}`); @@ -66,7 +66,7 @@ const processRuleDetails = async (ruleId, respond, replaceOriginal = false, resp // Generate blocks let blocks; try { - blocks = getRuleExplanationBlocks(result.explanation); + blocks = getSigmaRuleDetailsBlocks(result.explanation); } catch (blockError) { await handleError(blockError, `${FILE_NAME}: Block generation`, respond, { replaceOriginal: replaceOriginal, diff --git a/src/handlers/sigma/actions/sigma_siem_actions.js b/src/handlers/sigma/actions/sigma_siem_actions.js index d446fcd..72fc9e3 100644 --- a/src/handlers/sigma/actions/sigma_siem_actions.js +++ b/src/handlers/sigma/actions/sigma_siem_actions.js @@ -5,7 +5,7 @@ */ const logger = require('../../../utils/logger'); const { handleError } = require('../../../utils/error_handler'); -const { explainSigmaRule } = require('../../../services/sigma/sigma_details_service'); +const { getSigmaRuleDetails } = require('../../../services/sigma/sigma_details_service'); const { convertRuleToBackend } = require('../../../services/sigma/sigma_backend_converter'); const { sendRuleToSiem } = require('../../../services/elastic/elastic_send_rule_to_siem_service'); const { getAllSpaces } = require('../../../services/elastic/elastic_api_service'); @@ -210,8 +210,8 @@ const registerSiemActions = (app) => { const ruleId = actionValue.replace('select_space_for_rule_', ''); // Get rule information to display in the space selection message - const explainResult = await explainSigmaRule(ruleId); - const ruleInfo = explainResult.success ? explainResult.explanation : { title: ruleId }; + const sigmaRuleDetailsResult = await getSigmaRuleDetails(ruleId); + const ruleInfo = sigmaRuleDetailsResult.success ? sigmaRuleDetailsResult.explanation : { title: ruleId }; // Generate blocks for space selection const blocks = getSpaceSelectionBlocks(ruleId, ruleInfo); diff --git a/src/handlers/sigma/sigma_details_handler.js b/src/handlers/sigma/sigma_details_handler.js index 55844b3..e3896e8 100644 --- a/src/handlers/sigma/sigma_details_handler.js +++ b/src/handlers/sigma/sigma_details_handler.js @@ -1,27 +1,34 @@ /** * sigma_details_handler.js * - * Handles Sigma rule details requests from Slack commands + * Handles Sigma rule details requests from both Slack commands and CLI * 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('./actions/sigma_action_core'); -const FILE_NAME = 'sigma_details_handler.js'; +const { getSigmaRuleDetails, getSigmaRuleYaml } = require('../../services/sigma/sigma_details_service'); +const { getSigmaRuleDetailsBlocks } = require('../../blocks/sigma/sigma_details_block'); +const { formatSigmaDetails } = require('../../utils/cli_formatters'); + +const { getFileName } = require('../../utils/file_utils'); +const FILE_NAME = getFileName(__filename); + /** * 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 + * @param {Object} command - The Slack command or CLI command object + * @param {Function} respond - Function to send response back to Slack or CLI */ const handleCommand = async (command, respond) => { try { - logger.debug(`${FILE_NAME}: Processing sigma-details command: ${JSON.stringify(command.text)}`); + logger.debug(`${FILE_NAME}: Processing sigma-details command: ${command.text}`); if (!command || !command.text) { logger.warn(`${FILE_NAME}: Empty command received for sigma-details`); - await respond('Invalid command. Usage: /sigma-details [id]'); + await respond({ + text: 'Invalid command. Usage: /sigma-details [id] or "details sigma [id]"', + response_type: 'ephemeral' + }); return; } @@ -30,7 +37,10 @@ const handleCommand = async (command, respond) => { if (!ruleId) { logger.warn(`${FILE_NAME}: Missing rule ID in sigma-details command`); - await respond('Invalid command: missing rule ID. Usage: /sigma-details [id]'); + await respond({ + text: 'Invalid command: missing rule ID. Usage: /sigma-details [id] or "details sigma [id]"', + response_type: 'ephemeral' + }); return; } @@ -40,14 +50,44 @@ const handleCommand = async (command, respond) => { response_type: 'ephemeral' }); - // Use the shared processRuleDetails function from action handlers - await processRuleDetails(ruleId, respond, false, 'in_channel'); + // Get the rule explanation + const sigmaRuleDetailsResult = await getSigmaRuleDetails(ruleId); + + if (!sigmaRuleDetailsResult.success) { + logger.warn(`${FILE_NAME}: Failed to explain rule ${ruleId}: ${sigmaRuleDetailsResult.message}`); + await respond({ + text: `Error: ${sigmaRuleDetailsResult.message}`, + response_type: 'ephemeral' + }); + return; + } + + // For Slack responses, generate Block Kit blocks + let blocks; + try { + // This is for Slack - get the Block Kit UI components + blocks = getSigmaRuleDetailsBlocks(sigmaRuleDetailsResult.explanation); + } catch (blockError) { + await handleError(blockError, `${FILE_NAME}: Block generation`, respond, { + responseType: 'ephemeral', + customMessage: 'Error generating rule details view' + }); + return; + } + + // Return the response with both blocks for Slack and responseData for CLI + await respond({ + blocks: blocks, // For Slack + responseData: sigmaRuleDetailsResult.explanation, // For CLI + response_type: 'in_channel' + }); } catch (error) { await handleError(error, `${FILE_NAME}: Details command handler`, respond, { responseType: 'ephemeral' }); } }; + module.exports = { handleCommand }; \ No newline at end of file diff --git a/src/services/sigma/sigma_details_service.js b/src/services/sigma/sigma_details_service.js index e0dd364..21031a0 100644 --- a/src/services/sigma/sigma_details_service.js +++ b/src/services/sigma/sigma_details_service.js @@ -17,7 +17,7 @@ const FILE_NAME = getFileName(__filename); * @param {string} ruleId - The ID of the rule to explain * @returns {Promise} Result object with success flag and explanation or error message */ -async function explainSigmaRule(ruleId) { +async function getSigmaRuleDetails(ruleId) { if (!ruleId) { logger.warn(`${FILE_NAME}: Cannot explain rule: Missing rule ID`); return { @@ -145,6 +145,6 @@ async function getSigmaRuleYaml(ruleId) { } module.exports = { - explainSigmaRule, + getSigmaRuleDetails, getSigmaRuleYaml }; \ No newline at end of file diff --git a/src/services/sigma/sigma_stats_service.js b/src/services/sigma/sigma_stats_service.js index 20cac15..81934d1 100644 --- a/src/services/sigma/sigma_stats_service.js +++ b/src/services/sigma/sigma_stats_service.js @@ -48,7 +48,9 @@ async function getSigmaStats() { return { success: true, stats: formattedStats, - // Include raw response data for direct use by CLI + // Include raw response data for direct use by CLI. + // We have one universal function in the CLI to receive responses, + // and the CLI will then format each result differently responseData: formattedStats }; } catch (error) { diff --git a/src/utils/cli_formatters.js b/src/utils/cli_formatters.js index 49636cd..44ae36a 100644 --- a/src/utils/cli_formatters.js +++ b/src/utils/cli_formatters.js @@ -6,6 +6,36 @@ */ const chalk = require('chalk'); + +/** + * Format Sigma rule details for CLI display + * @param {Object} ruleDetails - The rule details to format + * @returns {Object} Formatted rule details for CLI display + */ +function formatSigmaDetails(ruleDetails) { + if (!ruleDetails) { + return null; + } + + // Create a flattened object for display in CLI table format + const formattedDetails = { + 'ID': ruleDetails.id || 'Unknown', + 'Title': ruleDetails.title || 'Untitled Rule', + 'Description': ruleDetails.description || 'No description provided', + 'Author': ruleDetails.author || 'Unknown author', + 'Severity': ruleDetails.severity || 'Unknown', + 'Detection': ruleDetails.detectionExplanation || 'No detection specified', + 'False Positives': Array.isArray(ruleDetails.falsePositives) ? + ruleDetails.falsePositives.join(', ') : 'None specified', + 'Tags': Array.isArray(ruleDetails.tags) ? + ruleDetails.tags.join(', ') : 'None', + 'References': Array.isArray(ruleDetails.references) ? + ruleDetails.references.join(', ') : 'None' + }; + + return formattedDetails; +} + /** * Format Sigma statistics for CLI display * @@ -92,77 +122,6 @@ function formatSigmaSearchResults(searchResults) { }; } -/** - * Format Sigma rule details for CLI display - * - * @param {Object} ruleDetails - The rule details object - * @returns {Object} Formatted details ready for CLI display - */ -function formatSigmaDetails(ruleDetails) { - if (!ruleDetails) { - return { error: 'No rule details available' }; - } - - // Filter and format the rule details for CLI display - const formattedDetails = {}; - - // Include only the most important fields for display - const fieldsToInclude = [ - 'id', 'title', 'description', 'status', 'author', - 'level', 'falsepositives', 'references', - 'created', 'modified' - ]; - - // Add detection information if available - if (ruleDetails.detection && ruleDetails.detection.condition) { - fieldsToInclude.push('detection_condition'); - formattedDetails['detection_condition'] = ruleDetails.detection.condition; - } - - // Add logsource information if available - if (ruleDetails.logsource) { - if (ruleDetails.logsource.product) { - fieldsToInclude.push('logsource_product'); - formattedDetails['logsource_product'] = ruleDetails.logsource.product; - } - - if (ruleDetails.logsource.category) { - fieldsToInclude.push('logsource_category'); - formattedDetails['logsource_category'] = ruleDetails.logsource.category; - } - - if (ruleDetails.logsource.service) { - fieldsToInclude.push('logsource_service'); - formattedDetails['logsource_service'] = ruleDetails.logsource.service; - } - } - - // Format date fields - const dateFields = ['created', 'modified']; - - for (const [key, value] of Object.entries(ruleDetails)) { - if (fieldsToInclude.includes(key)) { - // Format dates - if (dateFields.includes(key) && value) { - try { - formattedDetails[key] = new Date(value).toLocaleString(); - } catch (e) { - formattedDetails[key] = value; - } - } - // Format arrays - else if (Array.isArray(value)) { - formattedDetails[key] = value.join(', '); - } - // Default handling - else { - formattedDetails[key] = value; - } - } - } - - return formattedDetails; -} module.exports = { formatSigmaStats, From fd394fff360dac573de1d6cf5b24e298e35b83fa Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sat, 19 Apr 2025 12:55:56 -0400 Subject: [PATCH 3/5] add CLI details functionality --- src/fylgja-cli.js | 10 +++++----- src/handlers/sigma/sigma_details_handler.js | 5 ++--- src/lang/command_patterns.js | 12 ++---------- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js index 612665f..e5dde90 100644 --- a/src/fylgja-cli.js +++ b/src/fylgja-cli.js @@ -428,13 +428,13 @@ Fylgja CLI Help Basic Sigma Commands: - search sigma - Search for Sigma rules by keyword - details sigma - Get details about a specific Sigma rule -- sigma stats - Get statistics about Sigma rules database +- stats sigma - Get statistics about Sigma rules database Advanced Sigma Search Commands: -- search sigma rules where title contains "ransomware" - Search by title -- search sigma rules where tags include privilege_escalation - Search by tags -- search sigma rules where logsource.category == "process_creation" - Search by log source -- search sigma rules where modified after 2024-01-01 - Search by modification date +- search sigma where title contains "ransomware" - Search by title +- search sigma where tags include privilege_escalation - Search by tags +- search sigma where logsource.category == "process_creation" - Search by log source +- search sigma where modified after 2024-01-01 - Search by modification date - exit or quit - Exit the CLI diff --git a/src/handlers/sigma/sigma_details_handler.js b/src/handlers/sigma/sigma_details_handler.js index e3896e8..079db7d 100644 --- a/src/handlers/sigma/sigma_details_handler.js +++ b/src/handlers/sigma/sigma_details_handler.js @@ -8,7 +8,6 @@ const logger = require('../../utils/logger'); const { handleError } = require('../../utils/error_handler'); const { getSigmaRuleDetails, getSigmaRuleYaml } = require('../../services/sigma/sigma_details_service'); const { getSigmaRuleDetailsBlocks } = require('../../blocks/sigma/sigma_details_block'); -const { formatSigmaDetails } = require('../../utils/cli_formatters'); const { getFileName } = require('../../utils/file_utils'); const FILE_NAME = getFileName(__filename); @@ -77,8 +76,8 @@ const handleCommand = async (command, respond) => { // Return the response with both blocks for Slack and responseData for CLI await respond({ - blocks: blocks, // For Slack - responseData: sigmaRuleDetailsResult.explanation, // For CLI + blocks: blocks, // For Slack interface + responseData: sigmaRuleDetailsResult.explanation, // For CLI interface response_type: 'in_channel' }); } catch (error) { diff --git a/src/lang/command_patterns.js b/src/lang/command_patterns.js index bcd38f3..fccdc22 100644 --- a/src/lang/command_patterns.js +++ b/src/lang/command_patterns.js @@ -17,20 +17,12 @@ const commandPatterns = [ // Sigma details patterns { - name: 'sigma-details-direct', - regex: /^(explain|get|show|display|details|info|about)\s+(rule|detection)\s+(from\s+)?sigma\s+(where\s+)?(id=|id\s+is\s+|with\s+id\s+)(.+)$/i, - action: 'details', - module: 'sigma', - params: [6] // rule ID is in capturing group 6 - }, - { - name: 'sigma-details-simple', - regex: /^(details|explain)\s+(.+)$/i, + name: 'sigma-details', + regex: /^sigma\s+(details|info|about)\s+(.+)$/i, action: 'details', module: 'sigma', params: [2] // rule ID is in capturing group 2 }, - // Sigma search patterns { name: 'sigma-search', From 845440962d238d460752323b27a5ee282f2b8aac Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sat, 19 Apr 2025 13:18:38 -0400 Subject: [PATCH 4/5] format CLI tables --- src/fylgja-cli.js | 255 +++++++++++++++++++++++++----------- src/utils/cli_formatters.js | 58 ++++++-- 2 files changed, 226 insertions(+), 87 deletions(-) diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js index e5dde90..5ef1a0a 100644 --- a/src/fylgja-cli.js +++ b/src/fylgja-cli.js @@ -36,10 +36,10 @@ const { handleCommand: handleConfig } = require('./handlers/config/config_handle const { handleCommand: handleStats } = require('./handlers/stats/stats_handler'); // Import CLI formatters -const { +const { formatSigmaStats, - formatSigmaSearchResults, - formatSigmaDetails + formatSigmaSearchResults, + formatSigmaDetails } = require('./utils/cli_formatters'); // Set logger to CLI mode (prevents console output) @@ -86,24 +86,83 @@ const rl = readline.createInterface({ */ function completer(line) { const commands = [ - 'search sigma', - 'details sigma', + 'search sigma', + 'details sigma', 'sigma stats', 'stats sigma', 'search sigma rules where title contains', 'search rules where tags include', 'search rules where logsource.category ==', 'search rules where modified after', - 'help', - 'exit', + 'help', + 'exit', 'quit', 'clear' ]; - + const hits = commands.filter((c) => c.startsWith(line)); return [hits.length ? hits : commands, line]; } +/** + * Normalize and wrap text for table display + * @param {string} text Text to normalize and wrap + * @param {number} maxWidth Maximum width per line + * @returns {string[]} Array of wrapped lines + */ +function normalizeAndWrap(text, maxWidth) { + if (!text) return ['']; + + // Convert to string and normalize newlines + text = String(text || ''); + + // Replace all literal newlines with spaces + text = text.replace(/\n/g, ' '); + + // Now apply word wrapping + if (text.length <= maxWidth) return [text]; + + const words = text.split(' '); + const lines = []; + let currentLine = ''; + + for (const word of words) { + // Skip empty words (could happen if there were multiple spaces) + if (!word) continue; + + // If adding this word would exceed max width + if ((currentLine.length + word.length + (currentLine ? 1 : 0)) > maxWidth) { + // Push current line if not empty + if (currentLine) { + lines.push(currentLine); + currentLine = ''; + } + + // If the word itself is longer than maxWidth, we need to split it + if (word.length > maxWidth) { + let remaining = word; + while (remaining.length > 0) { + const chunk = remaining.substring(0, maxWidth); + lines.push(chunk); + remaining = remaining.substring(maxWidth); + } + } else { + currentLine = word; + } + } else { + // Add word to current line + currentLine = currentLine ? `${currentLine} ${word}` : word; + } + } + + // Add the last line if not empty + if (currentLine) { + lines.push(currentLine); + } + + return lines; +} + /** * Format CLI output similar to MySQL * @param {Object} data The data to format @@ -117,59 +176,105 @@ function formatOutput(data, type) { switch (type) { case 'search_results': + // Search results table format remains the same console.log('\n+-------+----------------------+------------------+-------------+'); console.log('| ID | Title | Author | Level |'); console.log('+-------+----------------------+------------------+-------------+'); - + if (data.results && data.results.length > 0) { data.results.forEach(rule => { const id = (rule.id || '').padEnd(5).substring(0, 5); const title = (rule.title || '').padEnd(20).substring(0, 20); const author = (rule.author || 'Unknown').padEnd(16).substring(0, 16); const level = (rule.level || 'medium').padEnd(11).substring(0, 11); - + console.log(`| ${id} | ${title} | ${author} | ${level} |`); }); } else { console.log('| No results found |'); } - + console.log('+-------+----------------------+------------------+-------------+'); console.log(`${data.totalCount || 0} rows in set`); break; - + case 'details': - console.log('\n+----------------------+--------------------------------------------------+'); - console.log('| Field | Value |'); - console.log('+----------------------+--------------------------------------------------+'); - + // Set a fixed width for the entire table + const sigmaDetailsKeyWidth = 22; + const sigmaDetailsValueWidth = 50; + + // Create the table borders + const detailsHeaderLine = '╔' + '═'.repeat(sigmaDetailsKeyWidth) + '╦' + '═'.repeat(sigmaDetailsValueWidth) + '╗'; + const sigmaDetailsDividerLine = '╠' + '═'.repeat(sigmaDetailsKeyWidth) + '╬' + '═'.repeat(sigmaDetailsValueWidth) + '╣'; + const sigmaDetailsRowSeparator = '╟' + '─'.repeat(sigmaDetailsKeyWidth) + '╫' + '─'.repeat(sigmaDetailsValueWidth) + '╢'; + const sigmaDetailsFooterLine = '╚' + '═'.repeat(sigmaDetailsKeyWidth) + '╩' + '═'.repeat(sigmaDetailsValueWidth) + '╝'; + + console.log('\n' + detailsHeaderLine); + console.log(`║ ${'Field'.padEnd(sigmaDetailsKeyWidth - 2)} ║ ${'Value'.padEnd(sigmaDetailsValueWidth - 2)} ║`); + console.log(sigmaDetailsDividerLine); + + // Track whether we need to add a row separator + let isFirstRow = true; + for (const [key, value] of Object.entries(data)) { if (typeof value !== 'object' || value === null) { - const formattedKey = key.padEnd(20).substring(0, 20); - const formattedValue = String(value || '').padEnd(48).substring(0, 48); - - console.log(`| ${formattedKey} | ${formattedValue} |`); + // Add separator between rows (but not before the first row) + if (!isFirstRow) { + console.log(sigmaDetailsRowSeparator); + } + isFirstRow = false; + + const formattedKey = key.padEnd(sigmaDetailsKeyWidth - 2); + + // Handle wrapping + const lines = normalizeAndWrap(value, sigmaDetailsValueWidth - 2); + + // Print first line with the key + console.log(`║ ${formattedKey} ║ ${lines[0].padEnd(sigmaDetailsValueWidth - 2)} ║`); + + // Print additional lines if there are any + for (let i = 1; i < lines.length; i++) { + console.log(`║ ${' '.repeat(sigmaDetailsKeyWidth - 2)} ║ ${lines[i].padEnd(sigmaDetailsValueWidth - 2)} ║`); + } } } - - console.log('+----------------------+--------------------------------------------------+'); + + console.log(sigmaDetailsFooterLine); break; - case 'stats': - console.log('\n+--------------------+---------------+'); - console.log('| Metric | Value |'); - console.log('+--------------------+---------------+'); - + // Set column widths + const sigmaStatsMetricWidth = 25; + const sigmaStatsValueWidth = 26; + + // Create the table borders + const sigmaStatsHeaderLine = '╔' + '═'.repeat(sigmaStatsMetricWidth) + '╦' + '═'.repeat(sigmaStatsValueWidth) + '╗'; + const sigmaStatsDividerLine = '╠' + '═'.repeat(sigmaStatsMetricWidth) + '╬' + '═'.repeat(sigmaStatsValueWidth) + '╣'; + const sigmaStatsRowSeparator = '╟' + '─'.repeat(sigmaStatsMetricWidth) + '╫' + '─'.repeat(sigmaStatsValueWidth) + '╢'; + const sigmaStatsFooterLine = '╚' + '═'.repeat(sigmaStatsMetricWidth) + '╩' + '═'.repeat(sigmaStatsValueWidth) + '╝'; + + console.log('\n' + sigmaStatsHeaderLine); + console.log(`║ ${'Metric'.padEnd(sigmaStatsMetricWidth - 2)} ║ ${'Value'.padEnd(sigmaStatsValueWidth - 2)} ║`); + console.log(sigmaStatsDividerLine); + + // Track whether we need to add a row separator + let statsIsFirstRow = true; + for (const [key, value] of Object.entries(data)) { - const formattedKey = key.padEnd(18).substring(0, 18); - const formattedValue = String(value || '').padEnd(13).substring(0, 13); - - console.log(`| ${formattedKey} | ${formattedValue} |`); + // Add separator between rows (but not before the first row) + if (!statsIsFirstRow) { + console.log(sigmaStatsRowSeparator); + } + statsIsFirstRow = false; + + const formattedKey = key.padEnd(sigmaStatsMetricWidth - 2); + const formattedValue = String(value || '').padEnd(sigmaStatsValueWidth - 2); + + console.log(`║ ${formattedKey} ║ ${formattedValue} ║`); } - - console.log('+--------------------+---------------+'); + + console.log(sigmaStatsFooterLine); break; - + default: console.log(JSON.stringify(data, null, 2)); } @@ -183,18 +288,18 @@ function formatOutput(data, type) { */ function extractSearchKeywords(input) { if (!input) return ''; - + // Try to extract keywords from common patterns if (input.includes('title contains')) { const match = input.match(/title\s+contains\s+["']([^"']+)["']/i); if (match) return match[1]; } - + if (input.includes('tags include')) { const match = input.match(/tags\s+include\s+(\S+)/i); if (match) return match[1]; } - + // Default - just return the input as is return input; } @@ -210,28 +315,28 @@ async function processCommand(input) { rl.prompt(); return; } - + // Special CLI commands if (input.trim().toLowerCase() === 'exit' || input.trim().toLowerCase() === 'quit') { console.log('Goodbye!'); rl.close(); process.exit(0); } - + if (input.trim().toLowerCase() === 'clear') { console.clear(); rl.prompt(); return; } - + // Special case for simple search if (input.trim().match(/^search\s+sigma\s+(.+)$/i)) { const keyword = input.trim().match(/^search\s+sigma\s+(.+)$/i)[1]; - + // Add to command history commandHistory.push(input); historyIndex = commandHistory.length; - + // Create fake command object const command = { text: keyword, @@ -241,12 +346,12 @@ async function processCommand(input) { channel_id: 'cli', channel_name: 'cli' }; - + // Create custom respond function const respond = createRespondFunction('search', 'sigma', [keyword]); - + console.log(`Executing: module=sigma, action=search, params=[${keyword}]`); - + try { await sigmaSearchHandler.handleCommand(command, respond); } catch (error) { @@ -255,29 +360,29 @@ async function processCommand(input) { logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`); rl.prompt(); } - + return; } - + // Add to command history commandHistory.push(input); historyIndex = commandHistory.length; - + // Parse command using existing parser const parsedCommand = await parseCommand(input); - + if (!parsedCommand.success) { console.log(parsedCommand.message || "Command not recognized. Type 'help' for usage."); rl.prompt(); return; } - + // Extract the command details const { action, module, params } = parsedCommand.command; - + // Only show execution info to the user, not sending to logger console.log(`Executing: module=${module}, action=${action}, params=[${params}]`); - + // Create fake command object similar to Slack's const command = { text: Array.isArray(params) && params.length > 0 ? params[0] : input, @@ -287,17 +392,17 @@ async function processCommand(input) { channel_id: 'cli', channel_name: 'cli' }; - + // Special handling for complexSearch to extract keywords if (action === 'complexSearch' && module === 'sigma' && params.length > 0) { // Try to extract keywords from complex queries const searchTerms = extractSearchKeywords(params[0]); command.text = searchTerms || params[0]; } - + // Create custom respond function for CLI const respond = createRespondFunction(action, module, params); - + try { switch (module) { case 'sigma': @@ -305,50 +410,50 @@ async function processCommand(input) { case 'search': await sigmaSearchHandler.handleCommand(command, respond); break; - + case 'complexSearch': await sigmaSearchHandler.handleComplexSearch(command, respond); break; - + case 'details': await sigmaDetailsHandler.handleCommand(command, respond); break; - + case 'stats': await sigmaStatsHandler.handleCommand(command, respond); break; - + case 'create': await sigmaCreateHandler.handleCommand(command, respond); break; - + default: console.log(`Unknown Sigma action: ${action}`); rl.prompt(); } break; - + case 'alerts': await handleAlerts(command, respond); break; - + case 'case': await handleCase(command, respond); break; - + case 'config': await handleConfig(command, respond); break; - + case 'stats': await handleStats(command, respond); break; - + case 'help': displayHelp(); rl.prompt(); break; - + default: console.log(`Unknown module: ${module}`); rl.prompt(); @@ -383,13 +488,13 @@ function createRespondFunction(action, module, params) { rl.prompt(); return; } - + // First check for the responseData property (directly from service) if (response.responseData) { // Format the data using the appropriate formatter if (module === 'sigma') { let formattedData; - + if (action === 'search' || action === 'complexSearch') { formattedData = formatSigmaSearchResults(response.responseData); formatOutput(formattedData, 'search_results'); @@ -413,7 +518,7 @@ function createRespondFunction(action, module, params) { } else { console.log('Command completed successfully.'); } - + rl.prompt(); }; } @@ -441,7 +546,7 @@ Advanced Sigma Search Commands: - clear - Clear the terminal screen - help - Display this help text `; - + console.log(helpText); } @@ -452,7 +557,7 @@ function startCLI() { console.log(ASCII_LOGO); console.log(`Fylgja CLI v${version} - Interactive SIEM Management Tool`); console.log(`Type 'help' for usage information or 'exit' to quit\n`); - + // Set up key bindings for history navigation rl._writeToOutput = function _writeToOutput(stringToWrite) { if (stringToWrite === '\\u001b[A' || stringToWrite === '\\u001b[B') { @@ -461,7 +566,7 @@ function startCLI() { } rl.output.write(stringToWrite); }; - + // Set up key listeners for history rl.input.on('keypress', (char, key) => { if (key && key.name === 'up') { @@ -485,13 +590,13 @@ function startCLI() { } } }); - + rl.prompt(); - + rl.on('line', async (line) => { await processCommand(line.trim()); }); - + rl.on('close', () => { console.log('Goodbye!'); process.exit(0); diff --git a/src/utils/cli_formatters.js b/src/utils/cli_formatters.js index 44ae36a..9914284 100644 --- a/src/utils/cli_formatters.js +++ b/src/utils/cli_formatters.js @@ -6,6 +6,40 @@ */ const chalk = require('chalk'); +/** + * Wraps text at specified length + * @param {string} text - Text to wrap + * @param {number} maxLength - Maximum line length + * @returns {string} Wrapped text + */ +function wrapText(text, maxLength = 80) { + if (!text || typeof text !== 'string') { + return text; + } + + if (text.length <= maxLength) { + return text; + } + + const words = text.split(' '); + let wrappedText = ''; + let currentLine = ''; + + words.forEach(word => { + // If adding this word would exceed max length, start a new line + if ((currentLine + word).length + 1 > maxLength) { + wrappedText += currentLine.trim() + '\n'; + currentLine = word + ' '; + } else { + currentLine += word + ' '; + } + }); + + // Add the last line + wrappedText += currentLine.trim(); + + return wrappedText; +} /** * Format Sigma rule details for CLI display @@ -20,17 +54,17 @@ function formatSigmaDetails(ruleDetails) { // Create a flattened object for display in CLI table format const formattedDetails = { 'ID': ruleDetails.id || 'Unknown', - 'Title': ruleDetails.title || 'Untitled Rule', - 'Description': ruleDetails.description || 'No description provided', + 'Title': wrapText(ruleDetails.title || 'Untitled Rule', 80), + 'Description': wrapText(ruleDetails.description || 'No description provided', 80), 'Author': ruleDetails.author || 'Unknown author', 'Severity': ruleDetails.severity || 'Unknown', - 'Detection': ruleDetails.detectionExplanation || 'No detection specified', - 'False Positives': Array.isArray(ruleDetails.falsePositives) ? - ruleDetails.falsePositives.join(', ') : 'None specified', - 'Tags': Array.isArray(ruleDetails.tags) ? - ruleDetails.tags.join(', ') : 'None', - 'References': Array.isArray(ruleDetails.references) ? - ruleDetails.references.join(', ') : 'None' + 'Detection': wrapText(ruleDetails.detectionExplanation || 'No detection specified', 80), + 'False Positives': wrapText(Array.isArray(ruleDetails.falsePositives) ? + ruleDetails.falsePositives.join(', ') : 'None specified', 80), + 'Tags': wrapText(Array.isArray(ruleDetails.tags) ? + ruleDetails.tags.join(', ') : 'None', 80), + 'References': wrapText(Array.isArray(ruleDetails.references) ? + ruleDetails.references.join(', ') : 'None', 80) }; return formattedDetails; @@ -114,7 +148,7 @@ function formatSigmaSearchResults(searchResults) { return { results: searchResults.results.map(rule => ({ id: rule.id || '', - title: rule.title || '', + title: wrapText(rule.title || '', 60), // Use narrower width for table columns author: rule.author || 'Unknown', level: rule.level || 'medium' })), @@ -122,9 +156,9 @@ function formatSigmaSearchResults(searchResults) { }; } - module.exports = { formatSigmaStats, formatSigmaSearchResults, - formatSigmaDetails + formatSigmaDetails, + wrapText }; \ No newline at end of file From 2d49dbdc46b1ce54616f59d398b2596a519a5ef3 Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sat, 19 Apr 2025 13:24:52 -0400 Subject: [PATCH 5/5] update details sigma command pattern --- src/lang/command_patterns.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lang/command_patterns.js b/src/lang/command_patterns.js index fccdc22..b95c582 100644 --- a/src/lang/command_patterns.js +++ b/src/lang/command_patterns.js @@ -18,10 +18,10 @@ const commandPatterns = [ // Sigma details patterns { name: 'sigma-details', - regex: /^sigma\s+(details|info|about)\s+(.+)$/i, + regex: /^details\s+sigma\s+(.+)$/i, action: 'details', module: 'sigma', - params: [2] // rule ID is in capturing group 2 + params: [1] // rule ID is in capturing group 1 }, // Sigma search patterns {