diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js index 5ef1a0a..e38bda7 100644 --- a/src/fylgja-cli.js +++ b/src/fylgja-cli.js @@ -1,614 +1,12 @@ +#!/usr/bin/env node /** * fylgja-cli.js * - * Interactive CLI interface + * Command-line executable entry point for Fylgja CLI */ -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 - }; -} +// Import the CLI starter function +const { startCLI } = require('./fylgja-cli/cli'); -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 +// Run the CLI application +startCLI(); \ No newline at end of file diff --git a/src/fylgja-cli/cli.js b/src/fylgja-cli/cli.js new file mode 100644 index 0000000..d70ab35 --- /dev/null +++ b/src/fylgja-cli/cli.js @@ -0,0 +1,438 @@ +/** + * cli.js + * + * Interactive CLI interface + */ +const readline = require('readline'); +const { parseCommand } = require('../lang/command_parser'); +const logger = require('../utils/logger'); +const { generateGradientLogo } = require('./utils/cli_logo'); +const outputManager = require('./cli_output_manager'); + +// Import command handlers +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'); + +// Set constants +const FILE_NAME = 'cli.js'; + +// 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'); +} + +// Command history management +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', + 'stats sigma', + 'search sigma rules where title contains', + 'search sigma where title contains', + 'search sigma rules where tags include', + 'search sigma where tags include', + 'search sigma rules where logsource.category ==', + 'search sigma where logsource.category ==', + 'search sigma rules where modified after', + 'search sigma where modified after', + 'search sigma rules where author is', + 'search sigma where author is', + 'search sigma rules where level is', + 'search sigma where level is', + 'help', + 'exit', + 'quit', + 'clear' + ]; + + const hits = commands.filter((c) => c.startsWith(line)); + return [hits.length ? hits : commands, line]; +} + +/** + * Parse out any basic search keywords from a complexSearch query + * @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; +} + +/** + * Create a custom respond function for handlers + * @param {string} action - The action being performed + * @param {string} module - The module handling the action + * @param {Array} params - The parameters for the action + * @returns {Function} A respond function for handler callbacks + */ +function createRespondFunction(action, module, params) { + // Keep track of whether we're waiting for results + let isWaitingForResults = false; + + return async (response) => { + const isProgressMessage = + typeof response === 'object' && + response.text && + !response.responseData && + (response.text.includes('moment') || + response.text.includes('searching') || + response.text.includes('processing')); + + if (isProgressMessage) { + outputManager.displayProgress(response.text); + isWaitingForResults = true; + return; // Don't show prompt after progress messages + } + + if (typeof response === 'string') { + console.log(response); + rl.prompt(); + return; + } + + // Check for the responseData property (directly from service) + if (response.responseData) { + if (module === 'sigma') { + if (action === 'search' || action === 'complexSearch') { + // Convert array response to expected format if needed + let dataToFormat = response.responseData; + + // Wrap responseData array in proper structure + if (Array.isArray(dataToFormat)) { + dataToFormat = { + results: dataToFormat, + totalCount: dataToFormat.length + }; + } + + outputManager.display(dataToFormat, 'search_results'); + } else if (action === 'details') { + outputManager.display(response.responseData, 'details'); + } else if (action === 'stats') { + outputManager.display(response.responseData, '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 { + outputManager.displaySuccess('Command completed successfully.'); + } + + // Reset waiting state and show prompt after results + isWaitingForResults = false; + rl.prompt(); + }; +} + +/** + * 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') { + outputManager.displaySuccess('Goodbye!'); + rl.close(); + process.exit(0); + } + + if (input.trim().toLowerCase() === 'clear') { + console.clear(); + rl.prompt(); + return; + } + + if (input.trim().toLowerCase() === 'help') { + outputManager.displayHelp(); + rl.prompt(); + return; + } + + // Add to command history + commandHistory.push(input); + historyIndex = commandHistory.length; + + // Special case for simple search + if (input.trim().match(/^search\s+sigma\s+(.+)$/i) && !input.trim().toLowerCase().includes('where') && !input.trim().toLowerCase().includes('with')) { + const keyword = input.trim().match(/^search\s+sigma\s+(.+)$/i)[1]; + + // 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) { + outputManager.displayError(error.message); + logger.error(`${FILE_NAME}: Command execution error: ${error.message}`); + logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`); + rl.prompt(); + } + + return; + } + + // Special case for complex search + const complexSearchMatch = input.trim().match(/^search\s+sigma\s+(rules\s+|detections\s+)?(where|with)\s+(.+)$/i); + if (complexSearchMatch) { + const complexQuery = complexSearchMatch[3]; + + // Create fake command object + const command = { + text: complexQuery, + user_id: 'cli_user', + user_name: 'cli_user', + command: '/fylgja', + channel_id: 'cli', + channel_name: 'cli' + }; + + // Create custom respond function + const respond = createRespondFunction('complexSearch', 'sigma', [complexQuery]); + + console.log(`Executing: module=sigma, action=complexSearch, params=[${complexQuery}]`); + + try { + await sigmaSearchHandler.handleComplexSearch(command, respond); + } catch (error) { + outputManager.displayError(error.message); + logger.error(`${FILE_NAME}: Command execution error: ${error.message}`); + logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`); + rl.prompt(); + } + + return; + } + + // Parse command using existing parser + const parsedCommand = await parseCommand(input); + + if (!parsedCommand.success) { + outputManager.displayWarning(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: + outputManager.displayWarning(`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; + + default: + outputManager.displayWarning(`Unknown module: ${module}`); + rl.prompt(); + } + } catch (error) { + outputManager.displayError(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) { + outputManager.displayError(`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(); + } +} + +/** + * Start the CLI + */ +function startCLI() { + console.log(generateGradientLogo()); + console.log(`Fylgja CLI v${version} - Interactive SIEM Management Tool`); + console.log(`Type 'help' for usage information or 'exit' to quit\n`); + + // Set logger to CLI mode (prevents console output) + logger.setCliMode(true); + + // 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', () => { + outputManager.displaySuccess('Goodbye!'); + process.exit(0); + }); +} + +// Check if running directly +if (require.main === module) { + startCLI(); +} else { + // Export functions for integration with main app + module.exports = { + startCLI + }; +} + diff --git a/src/fylgja-cli/cli_output_manager.js b/src/fylgja-cli/cli_output_manager.js new file mode 100644 index 0000000..3727f2f --- /dev/null +++ b/src/fylgja-cli/cli_output_manager.js @@ -0,0 +1,123 @@ +/** + * cli_output_manager.js + * + * Centralized manager for CLI output formatting and display + */ +const colors = require('./utils/colors'); +const formatters = require('./formatters'); + +/** + * CLI Output Manager + * Handles all CLI output formatting and display + */ +class CliOutputManager { + /** + * Create a new CLI Output Manager + */ + constructor() { + this.colors = colors; + } + + /** + * Display formatted output based on data type + * + * @param {any} data - Data to format and display + * @param {string} type - Type of data (search_results, details, stats) + */ + display(data, type) { + if (!data) { + console.log('No data returned from the server.'); + return; + } + + console.log(); // Empty line for spacing + + switch (type) { + case 'search_results': + const formattedResults = formatters.formatSigmaSearchResults(data); + console.log(formatters.renderSigmaSearchResults(formattedResults)); + break; + + case 'details': + const formattedDetails = formatters.formatSigmaDetails(data); + console.log(formatters.renderSigmaDetails(formattedDetails)); + break; + + case 'stats': + const formattedStats = formatters.formatSigmaStats(data); + console.log(formatters.renderSigmaStats(formattedStats)); + break; + + default: + // For unknown types, just display JSON + console.log(JSON.stringify(data, null, 2)); + } + } + + /** + * Display a progress message + * + * @param {string} message - Progress message to display + */ + displayProgress(message) { + console.log(this.colors.dim(message)); + } + + /** + * Display an error message + * + * @param {string} message - Error message to display + */ + displayError(message) { + console.error(this.colors.error(`Error: ${message}`)); + } + + /** + * Display a success message + * + * @param {string} message - Success message to display + */ + displaySuccess(message) { + console.log(this.colors.success(message)); + } + + /** + * Display a warning message + * + * @param {string} message - Warning message to display + */ + displayWarning(message) { + console.log(this.colors.warning(`Warning: ${message}`)); + } + + /** + * Display help text + */ + 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 +- search sigma rules where title contains "ransomware" - Alternative syntax +- search sigma rules where tags include privilege_escalation - Alternative syntax + +CLI Commands: +- exit or quit - Exit the CLI +- clear - Clear the terminal screen +- help - Display this help text +`; + + console.log(helpText); + } +} + +module.exports = new CliOutputManager(); \ No newline at end of file diff --git a/src/fylgja-cli/formatters/index.js b/src/fylgja-cli/formatters/index.js new file mode 100644 index 0000000..9b1b9cd --- /dev/null +++ b/src/fylgja-cli/formatters/index.js @@ -0,0 +1,36 @@ +/** + * formatters/index.js + * + * Exports all formatters for easy importing + */ + +// Text and table formatters +const textFormatter = require('./text_formatter'); +const tableFormatter = require('./table_formatter'); + +// Domain-specific formatters +const sigmaFormatter = require('./sigma_formatter'); + +// Re-export all formatters +module.exports = { + // Text utilities + wrapText: textFormatter.wrapText, + formatDate: textFormatter.formatDate, + formatNumber: textFormatter.formatNumber, + + // Table utilities + formatTable: tableFormatter.formatTable, + formatKeyValueTable: tableFormatter.formatKeyValueTable, + formatTableHeader: tableFormatter.formatTableHeader, + formatTableRow: tableFormatter.formatTableRow, + + // Sigma formatters + formatSigmaDetails: sigmaFormatter.formatSigmaDetails, + formatSigmaStats: sigmaFormatter.formatSigmaStats, + formatSigmaSearchResults: sigmaFormatter.formatSigmaSearchResults, + + // Rendering functions for CLI output + renderSigmaDetails: sigmaFormatter.renderSigmaDetails, + renderSigmaStats: sigmaFormatter.renderSigmaStats, + renderSigmaSearchResults: sigmaFormatter.renderSigmaSearchResults +}; \ No newline at end of file diff --git a/src/fylgja-cli/formatters/sigma_formatter.js b/src/fylgja-cli/formatters/sigma_formatter.js new file mode 100644 index 0000000..e552b7a --- /dev/null +++ b/src/fylgja-cli/formatters/sigma_formatter.js @@ -0,0 +1,196 @@ +/** + * sigma_formatter.js + * + * Specialized formatters for Sigma rule data + */ +const { wrapText, formatDate, formatNumber } = require('./text_formatter'); +const { formatKeyValueTable, formatTable } = require('./table_formatter'); +const colors = require('../utils/colors'); + +/** + * Format Sigma rule details for CLI display + * + * @param {Object} ruleDetails - The rule details object + * @returns {Object} Formatted rule details + */ +function formatSigmaDetails(ruleDetails) { + if (!ruleDetails) { + return null; + } + + // Create a flattened object for display in CLI table format + return { + 'ID': ruleDetails.id || 'Unknown', + 'Title': ruleDetails.title || 'Untitled Rule', + 'Description': ruleDetails.description || 'No description provided', + 'Author': ruleDetails.author || 'Unknown author', + 'Severity': ruleDetails.severity || 'Unknown', + 'Status': ruleDetails.status || 'Unknown', + 'Created': formatDate(ruleDetails.date), + 'Modified': formatDate(ruleDetails.modified), + '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' + }; +} + +/** + * Format Sigma statistics for CLI display + * + * @param {Object} stats - The statistics object + * @returns {Object} Formatted stats + */ +function formatSigmaStats(stats) { + if (!stats) { + return { error: 'No statistics data available' }; + } + + // Create a simplified object suitable for table display + const formattedStats = { + 'Last Update': formatDate(stats.lastUpdate), + 'Total Rules': formatNumber(stats.totalRules), + 'Database Health': `${stats.databaseHealth.contentPercentage}% Complete`, + + // OS breakdown + 'Windows Rules': formatNumber(stats.operatingSystems.windows), + 'Linux Rules': formatNumber(stats.operatingSystems.linux), + 'macOS Rules': formatNumber(stats.operatingSystems.macos), + 'Other OS Rules': formatNumber(stats.operatingSystems.other), + }; + + // Add severity levels + if (stats.severityLevels && Array.isArray(stats.severityLevels)) { + stats.severityLevels.forEach(level => { + const levelName = level.level + ? level.level.charAt(0).toUpperCase() + level.level.slice(1) + : 'Unknown'; + + formattedStats[`${levelName} Severity`] = formatNumber(level.count); + }); + } + + // Add top MITRE tactics if available + if (stats.mitreTactics && Array.isArray(stats.mitreTactics)) { + stats.mitreTactics.slice(0, 5).forEach(tactic => { // Only top 5 + const formattedTactic = tactic.tactic + .replace(/-/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + formattedStats[`MITRE: ${formattedTactic}`] = formatNumber(tactic.count); + }); + } + + return formattedStats; +} + +/** + * Format Sigma search results for CLI display + * + * @param {Object} searchResults - The search results object + * @returns {Object} Formatted search results + */ +function formatSigmaSearchResults(searchResults) { + if (!searchResults || !searchResults.results) { + return { error: 'No search results available' }; + } + + // Map raw results to formatted results + const formattedResults = searchResults.results.map(rule => { + // Get logsource.product field + let osProduct = 'N/A'; + + if (rule.logsource && rule.logsource.product) { + osProduct = rule.logsource.product; + } + + return { + id: rule.id || '', + title: rule.title || '', + author: rule.author || 'Unknown', + level: rule.level || 'medium', + osProduct: osProduct, + tags: rule.tags || [] + }; + }); + + return { + results: formattedResults, + totalCount: searchResults.totalCount || 0 + }; +} + +/** + * Render formatted Sigma details as a string + * + * @param {Object} formattedDetails - Formatted details object + * @returns {string} CLI-ready output + */ +function renderSigmaDetails(formattedDetails) { + if (!formattedDetails) { + return 'No details available'; + } + + return formatKeyValueTable(formattedDetails, 24, 50).join('\n'); +} + +/** + * Render formatted Sigma statistics as a string + * + * @param {Object} formattedStats - Formatted statistics object + * @returns {string} CLI-ready output + */ +function renderSigmaStats(formattedStats) { + if (!formattedStats) { + return 'No statistics available'; + } + + return formatKeyValueTable(formattedStats, 25, 30, colors.statsKey).join('\n'); +} + +/** + * Render formatted Sigma search results as a string + * + * @param {Object} formattedResults - Formatted search results object + * @returns {string} CLI-ready output + */ +function renderSigmaSearchResults(formattedResults) { + if (!formattedResults || formattedResults.error) { + return formattedResults.error || 'No search results available'; + } + + const headers = ['#', 'Title', 'OS/Product', 'ID']; + const widths = [5, 32, 15, 13]; + + // Create rows from results + const rows = formattedResults.results.map((rule, index) => { + return [ + (index + 1).toString(), + rule.title.substring(0, 31), // Trim to fit column width + rule.osProduct.substring(0, 14), + rule.id.substring(0, 12) + ]; + }); + + // Generate table + const tableLines = formatTable(headers, rows, widths); + + // Add count footer + const countLine = colors.count(`${formattedResults.totalCount} rows in set`); + + return [...tableLines, '', countLine].join('\n'); +} + +module.exports = { + formatSigmaDetails, + formatSigmaStats, + formatSigmaSearchResults, + renderSigmaDetails, + renderSigmaStats, + renderSigmaSearchResults +}; \ No newline at end of file diff --git a/src/fylgja-cli/formatters/table_formatter.js b/src/fylgja-cli/formatters/table_formatter.js new file mode 100644 index 0000000..1e05046 --- /dev/null +++ b/src/fylgja-cli/formatters/table_formatter.js @@ -0,0 +1,97 @@ +/** + * table_formatter.js + * + * Table formatting utilities for CLI output + */ +const colors = require('../utils/colors'); +const { wrapText } = require('./text_formatter'); + +/** + * Creates a formatted table row with fixed column widths + * + * @param {string[]} columns - Array of column values + * @param {number[]} widths - Array of column widths + * @param {Function[]} formatters - Array of formatting functions for each column (optional) + * @returns {string} Formatted table row + */ +function formatTableRow(columns, widths, formatters = []) { + return columns.map((value, index) => { + const formatter = formatters[index] || (text => text); + return formatter(String(value || '').padEnd(widths[index])); + }).join(''); +} + +/** + * Formats a header row for a table + * + * @param {string[]} headers - Array of header texts + * @param {number[]} widths - Array of column widths + * @returns {string} Formatted header row + */ +function formatTableHeader(headers, widths) { + return formatTableRow(headers, widths, headers.map(() => colors.header)); +} + +/** + * Formats data as a key-value table + * + * @param {Object} data - Object with key-value pairs to display + * @param {number} keyWidth - Width of the key column + * @param {number} valueWidth - Width of the value column + * @param {Function} keyFormatter - Formatting function for keys + * @returns {string[]} Array of formatted lines + */ +function formatKeyValueTable(data, keyWidth = 25, valueWidth = 50, keyFormatter = colors.key) { + const lines = []; + + for (const [key, value] of Object.entries(data)) { + if (typeof value !== 'object' || value === null) { + const formattedKey = keyFormatter(key.padEnd(keyWidth)); + + // Handle wrapping for multiline values + const wrappedLines = wrapText(value, valueWidth); + + // First line includes the key + lines.push(`${formattedKey}${wrappedLines[0] || ''}`); + + // Additional lines are indented to align with the value column + for (let i = 1; i < wrappedLines.length; i++) { + lines.push(`${' '.repeat(keyWidth)}${wrappedLines[i]}`); + } + } + } + + return lines; +} + +/** + * Creates a full table with headers and rows + * + * @param {string[]} headers - Table headers + * @param {Array>} rows - Table data rows + * @param {number[]} widths - Column widths + * @returns {string[]} Array of formatted table lines + */ +function formatTable(headers, rows, widths) { + const lines = []; + + // Add header + lines.push(formatTableHeader(headers, widths)); + + // Add separator line (optional) + // lines.push(widths.map(w => '-'.repeat(w)).join('')); + + // Add data rows + rows.forEach(row => { + lines.push(formatTableRow(row, widths)); + }); + + return lines; +} + +module.exports = { + formatTableRow, + formatTableHeader, + formatKeyValueTable, + formatTable +}; \ No newline at end of file diff --git a/src/fylgja-cli/formatters/text_formatter.js b/src/fylgja-cli/formatters/text_formatter.js new file mode 100644 index 0000000..910c44b --- /dev/null +++ b/src/fylgja-cli/formatters/text_formatter.js @@ -0,0 +1,97 @@ +/** + * text_formatter.js + * + * Common text formatting utilities for CLI output + */ + +/** + * Wraps text at specified length, handling different data types + * + * @param {any} text - Text to wrap (will be converted to string) + * @param {number} maxWidth - Maximum line width + * @returns {string[]} Array of wrapped lines + */ +function wrapText(text, maxWidth = 80) { + // Handle non-string or empty inputs + if (text === undefined || text === null) return ['']; + + // Convert to string and normalize newlines + const normalizedText = String(text).replace(/\n/g, ' '); + + // If text fits in one line, return it as a single-element array + if (normalizedText.length <= maxWidth) return [normalizedText]; + + const words = normalizedText.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; + } + + /** + * Formats a date string for display + * + * @param {string} dateString - Date string to format + * @returns {string} Formatted date string + */ + function formatDate(dateString) { + if (!dateString) return 'Unknown'; + + try { + const date = new Date(dateString); + return date.toLocaleString(); + } catch (error) { + return dateString; + } + } + + /** + * Formats a number with locale-specific thousands separators + * + * @param {number} num - Number to format + * @returns {string} Formatted number string + */ + function formatNumber(num) { + if (typeof num !== 'number') return String(num || ''); + return num.toLocaleString(); + } + + module.exports = { + wrapText, + formatDate, + formatNumber + }; \ No newline at end of file diff --git a/src/fylgja-cli/utils/cli_logo.js b/src/fylgja-cli/utils/cli_logo.js new file mode 100644 index 0000000..c547d40 --- /dev/null +++ b/src/fylgja-cli/utils/cli_logo.js @@ -0,0 +1,105 @@ +/** + * cli_logo.js + * + * Gradient-colored ASCII logo for CLI + */ + +// Define the ASCII logo +const logoLines = [ + '░▒▓████████▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░░▒▓██████▓▒░ ', + '░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ', + '░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ', + '░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░ ░▒▓█▓▒▒▓███▓▒░ ░▒▓█▓▒░▒▓████████▓▒░ ', + '░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ', + '░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ', + '░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░ ' + ]; + + // Colors (hex codes without # prefix) + const colors = { + teal: '7DE2D1', + darkTeal: '339989', + offWhite: 'FFFAFB', + lavender: '8F95D3' + }; + + // Convert hex to RGB + function hexToRgb(hex) { + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return { r, g, b }; + } + + // Linear interpolation between two colors + function interpolateColor(color1, color2, factor) { + return { + r: Math.round(color1.r + factor * (color2.r - color1.r)), + g: Math.round(color1.g + factor * (color2.g - color1.g)), + b: Math.round(color1.b + factor * (color2.b - color1.b)) + }; + } + + // Get ANSI true color escape code + function getTrueColorCode(r, g, b) { + return `\x1b[38;2;${r};${g};${b}m`; + } + + // Get ANSI 256-color escape code (fallback for terminals without true color support) + function get256ColorCode(r, g, b) { + // Convert RGB to approximate 256-color code + const ansi256 = 16 + + 36 * Math.round(r * 5 / 255) + + 6 * Math.round(g * 5 / 255) + + Math.round(b * 5 / 255); + + return `\x1b[38;5;${ansi256}m`; + } + + /** + * Generate a 2D gradient (both horizontal and vertical gradients combined) + * This creates the most dramatic effect but is more processing-intensive + * @returns {string} The gradient-colored logo + */ + function generateGradientLogo() { + // Convert hex colors to RGB (corners of the 2D gradient) + const topLeft = hexToRgb(colors.teal); + const topRight = hexToRgb(colors.darkTeal); + const bottomLeft = hexToRgb(colors.offWhite); + const bottomRight = hexToRgb(colors.lavender); + + // Initialize gradient logo + let gradientLogo = '\n'; + + // Process each line + for (let y = 0; y < logoLines.length; y++) { + const line = logoLines[y]; + const verticalPosition = y / (logoLines.length - 1); + + // Interpolate top and bottom colors + const leftColor = interpolateColor(topLeft, bottomLeft, verticalPosition); + const rightColor = interpolateColor(topRight, bottomRight, verticalPosition); + + // Process each character in the line + for (let x = 0; x < line.length; x++) { + const char = line[x]; + const horizontalPosition = x / (line.length - 1); + + // Interpolate between left and right colors + const color = interpolateColor(leftColor, rightColor, horizontalPosition); + + // Apply the color and add the character + gradientLogo += getTrueColorCode(color.r, color.g, color.b) + char; + } + + // Reset color and add newline + gradientLogo += '\x1b[0m\n'; + } + + return gradientLogo; + } + + // Export the gradient logo functions + module.exports = { + generateGradientLogo, + }; \ No newline at end of file diff --git a/src/fylgja-cli/utils/colors.js b/src/fylgja-cli/utils/colors.js new file mode 100644 index 0000000..3810479 --- /dev/null +++ b/src/fylgja-cli/utils/colors.js @@ -0,0 +1,63 @@ +/** + * colors.js + * + * Centralized color definitions and formatting functions for CLI output + * Using direct ANSI color codes instead of chalk + */ + +// Define ANSI color codes +const ansiCodes = { + // Text formatting + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + underscore: '\x1b[4m', + blink: '\x1b[5m', + reverse: '\x1b[7m', + hidden: '\x1b[8m', + + // Foreground colors + black: '\x1b[30m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + + // Background colors + bgBlack: '\x1b[40m', + bgRed: '\x1b[41m', + bgGreen: '\x1b[42m', + bgYellow: '\x1b[43m', + bgBlue: '\x1b[44m', + bgMagenta: '\x1b[45m', + bgCyan: '\x1b[46m', + bgWhite: '\x1b[47m' + }; + + // Create color formatting functions + const colors = { + // Basic formatting functions + blue: (text) => `${ansiCodes.blue}${text}${ansiCodes.reset}`, + green: (text) => `${ansiCodes.green}${text}${ansiCodes.reset}`, + red: (text) => `${ansiCodes.red}${text}${ansiCodes.reset}`, + yellow: (text) => `${ansiCodes.yellow}${text}${ansiCodes.reset}`, + cyan: (text) => `${ansiCodes.cyan}${text}${ansiCodes.reset}`, + white: (text) => `${ansiCodes.white}${text}${ansiCodes.reset}`, + dim: (text) => `${ansiCodes.dim}${text}${ansiCodes.reset}`, + bold: (text) => `${ansiCodes.bold}${text}${ansiCodes.reset}`, + + // Composite styles for specific UI elements + header: (text) => `${ansiCodes.cyan}${ansiCodes.bold}${text}${ansiCodes.reset}`, + key: (text) => `${ansiCodes.blue}${text}${ansiCodes.reset}`, + statsKey: (text) => `${ansiCodes.yellow}${text}${ansiCodes.reset}`, + value: (text) => `${ansiCodes.white}${text}${ansiCodes.reset}`, + count: (text) => `${ansiCodes.green}${text}${ansiCodes.reset}`, + error: (text) => `${ansiCodes.red}${text}${ansiCodes.reset}`, + warning: (text) => `${ansiCodes.yellow}${text}${ansiCodes.reset}`, + success: (text) => `${ansiCodes.green}${ansiCodes.bold}${text}${ansiCodes.reset}` + }; + + module.exports = colors; \ No newline at end of file diff --git a/src/handlers/sigma/sigma_search_handler.js b/src/handlers/sigma/sigma_search_handler.js index 2932b8d..7613239 100644 --- a/src/handlers/sigma/sigma_search_handler.js +++ b/src/handlers/sigma/sigma_search_handler.js @@ -147,6 +147,7 @@ const handleCommand = async (command, respond) => { // Respond with the search results await respond({ blocks: blocks, + responseData: searchResult.results, response_type: isEphemeral ? 'ephemeral' : 'in_channel' }); @@ -271,6 +272,7 @@ const handleComplexSearch = async (command, respond) => { // Respond with the search results await respond({ blocks: blocks, + responseData: searchResult.results, response_type: 'ephemeral' // Complex searches are usually more specific to the user }); diff --git a/src/lang/command_patterns.js b/src/lang/command_patterns.js index b95c582..7180deb 100644 --- a/src/lang/command_patterns.js +++ b/src/lang/command_patterns.js @@ -17,7 +17,7 @@ const commandPatterns = [ // Sigma details patterns { - name: 'sigma-details', + name: 'details-sigma', regex: /^details\s+sigma\s+(.+)$/i, action: 'details', module: 'sigma', @@ -25,13 +25,28 @@ const commandPatterns = [ }, // Sigma search patterns { - name: 'sigma-search', - regex: /^(search|find)\s+(sigma\s+)?(rules|detections)?\s*(where|with)\s+(.+)$/i, + name: 'search-sigma-complex-1', + regex: /^search\s+sigma\s+rules?\s*(where|with)\s+(.+)$/i, action: 'complexSearch', module: 'sigma', - params: [5] // complex query conditions in capturing group 5 + params: [4] // complex query conditions in capturing group 4 + }, + // Alternate form without "rules" + { + name: 'search-sigma-complex-2', + regex: /^search\s+sigma\s+(where|with)\s+(.+)$/i, + action: 'complexSearch', + module: 'sigma', + params: [3] // complex query conditions in capturing group 3 + }, + // Simple keyword search pattern + { + name: 'search-sigma-simple', + regex: /^search\s+sigma\s+(.+)$/i, + action: 'search', + module: 'sigma', + params: [2] // keyword is in capturing group 2 }, - // Sigma create patterns { name: 'sigma-create', @@ -41,16 +56,8 @@ const commandPatterns = [ params: [2] // rule ID is in capturing group 2 }, - // Sigma stats patterns { - name: 'sigma-stats-first', - regex: /^sigma\s+stats$/i, - action: 'stats', - module: 'sigma', - params: [] - }, - { - name: 'sigma-stats-second', + name: 'stats-sigma', regex: /^stats\s+sigma$/i, action: 'stats', module: 'sigma', diff --git a/src/utils/cli_formatters.js b/src/utils/cli_formatters.js index 9914284..1ef9e8e 100644 --- a/src/utils/cli_formatters.js +++ b/src/utils/cli_formatters.js @@ -6,6 +6,7 @@ */ const chalk = require('chalk'); + /** * Wraps text at specified length * @param {string} text - Text to wrap @@ -146,16 +147,27 @@ function formatSigmaSearchResults(searchResults) { // 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' - })), + results: searchResults.results.map(rule => { + // Get logsource.product field + let osProduct = 'N/A'; + + // Only use logsource.product if it exists + if (rule.logsource && rule.logsource.product) { + osProduct = rule.logsource.product; + } + + return { + id: rule.id || '', + title: wrapText(rule.title || '', 60), // Use narrower width for table columns + author: rule.author || 'Unknown', + level: rule.level || 'medium', + osProduct: osProduct, + tags: rule.tags || [] // Include the original tags for potential reference + }; + }), totalCount: searchResults.totalCount || 0 }; } - module.exports = { formatSigmaStats, formatSigmaSearchResults,