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/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/fylgja-cli.js b/src/fylgja-cli.js new file mode 100644 index 0000000..5ef1a0a --- /dev/null +++ b/src/fylgja-cli.js @@ -0,0 +1,614 @@ +/** + * 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]; +} + +/** + * 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 + * @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': + // 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': + // 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) { + // 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(sigmaDetailsFooterLine); + break; + case 'stats': + // 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)) { + // 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(sigmaStatsFooterLine); + 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 +- stats sigma - Get statistics about Sigma rules database + +Advanced Sigma Search Commands: +- 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 +- 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/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..079db7d 100644 --- a/src/handlers/sigma/sigma_details_handler.js +++ b/src/handlers/sigma/sigma_details_handler.js @@ -1,27 +1,33 @@ /** * 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 { 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 +36,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 +49,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 interface + responseData: sigmaRuleDetailsResult.explanation, // For CLI interface + 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/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/lang/command_patterns.js b/src/lang/command_patterns.js index bcd38f3..b95c582 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, + name: 'sigma-details', + regex: /^details\s+sigma\s+(.+)$/i, action: 'details', module: 'sigma', - params: [6] // rule ID is in capturing group 6 + params: [1] // rule ID is in capturing group 1 }, - { - name: 'sigma-details-simple', - regex: /^(details|explain)\s+(.+)$/i, - action: 'details', - module: 'sigma', - params: [2] // rule ID is in capturing group 2 - }, - // Sigma search patterns { name: 'sigma-search', 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 0bee7a0..81934d1 100644 --- a/src/services/sigma/sigma_stats_service.js +++ b/src/services/sigma/sigma_stats_service.js @@ -31,11 +31,27 @@ 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. + // We have one universal function in the CLI to receive responses, + // and the CLI will then format each result differently + 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..9914284 --- /dev/null +++ b/src/utils/cli_formatters.js @@ -0,0 +1,164 @@ +/** + * cli_formatters.js + * + * Dedicated formatters for CLI output of various data types + * Converts raw data into formatted CLI-friendly displays + */ +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 + * @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': wrapText(ruleDetails.title || 'Untitled Rule', 80), + 'Description': wrapText(ruleDetails.description || 'No description provided', 80), + 'Author': ruleDetails.author || 'Unknown author', + 'Severity': ruleDetails.severity || 'Unknown', + '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; +} + +/** + * 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: wrapText(rule.title || '', 60), // Use narrower width for table columns + author: rule.author || 'Unknown', + level: rule.level || 'medium' + })), + totalCount: searchResults.totalCount || 0 + }; +} + +module.exports = { + formatSigmaStats, + formatSigmaSearchResults, + formatSigmaDetails, + wrapText +}; \ 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()); + } } } },