From 34143c42415862da8dc21f7115c8d98c12deb27b Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sun, 20 Apr 2025 18:37:20 -0400 Subject: [PATCH] refactor cli into multiple files --- src/fylgja-cli.js | 757 +----------------- src/fylgja-cli/cli.js | 438 ++++++++++ src/fylgja-cli/cli_output_manager.js | 123 +++ src/fylgja-cli/formatters/index.js | 36 + src/fylgja-cli/formatters/sigma_formatter.js | 196 +++++ src/fylgja-cli/formatters/table_formatter.js | 97 +++ src/fylgja-cli/formatters/text_formatter.js | 97 +++ .../utils/cli_logo.js} | 2 +- src/fylgja-cli/utils/colors.js | 63 ++ src/lang/command_patterns.js | 24 +- 10 files changed, 1065 insertions(+), 768 deletions(-) create mode 100644 src/fylgja-cli/cli.js create mode 100644 src/fylgja-cli/cli_output_manager.js create mode 100644 src/fylgja-cli/formatters/index.js create mode 100644 src/fylgja-cli/formatters/sigma_formatter.js create mode 100644 src/fylgja-cli/formatters/table_formatter.js create mode 100644 src/fylgja-cli/formatters/text_formatter.js rename src/{utils/cli-logo.js => fylgja-cli/utils/cli_logo.js} (99%) create mode 100644 src/fylgja-cli/utils/colors.js diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js index 3722877..e38bda7 100644 --- a/src/fylgja-cli.js +++ b/src/fylgja-cli.js @@ -1,757 +1,12 @@ +#!/usr/bin/env node /** * fylgja-cli.js * - * Interactive CLI interface + * Command-line executable entry point for Fylgja 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'); -const { generateGradientLogo } = require('./utils/cli-logo'); -const readline = require('readline'); +// Import the CLI starter function +const { startCLI } = require('./fylgja-cli/cli'); - -const colors = { - // ANSI color codes - reset: '\x1b[0m', - bright: '\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 simple color functions -const colorize = { - blue: (text) => `${colors.blue}${text}${colors.reset}`, - green: (text) => `${colors.green}${text}${colors.reset}`, - red: (text) => `${colors.red}${text}${colors.reset}`, - yellow: (text) => `${colors.yellow}${text}${colors.reset}`, - cyan: (text) => `${colors.cyan}${text}${colors.reset}`, - white: (text) => `${colors.white}${text}${colors.reset}`, - dim: (text) => `${colors.dim}${text}${colors.reset}`, -}; - -const tableColors = { - header: colorize.cyan, - key: colorize.blue, - statsKey: colorize.yellow, - count: colorize.green, - dim: colorize.dim -}; - -// 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'; - -// 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 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]; -} - -/** - * 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 - * @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; - } - - console.log(); - - // word wrapping function - function normalizeAndWrap(text, maxWidth) { - if (text === undefined || text === null) return ['']; - - // Convert to string and normalize newlines - const normalized = String(text).replace(/\n/g, ' '); - - // If text fits in one line, return it - if (normalized.length <= maxWidth) return [normalized]; - - const words = normalized.split(' '); - const lines = []; - let currentLine = ''; - - for (const word of words) { - // Skip empty words - if (!word) continue; - - // Check 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 = ''; - } - - // Handle words longer than maxWidth by splitting them - 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.length ? lines : ['']; - } - - switch (type) { - case 'search_results': - // Header - console.log( - tableColors.header( - '#'.padEnd(5) + - 'Title'.padEnd(32) + - 'OS/Product'.padEnd(15) + - 'ID'.padEnd(13) - ) - ); - - if (data.results && data.results.length > 0) { - data.results.forEach((rule, index) => { - const num = (index + 1).toString().padEnd(5); - const title = (rule.title || '').substring(0, 32).padEnd(32); - - // Extract OS/Product from tags or logsource if available - let osProduct = 'Unknown'; - if (rule.tags && Array.isArray(rule.tags)) { - // Look for os tags like windows, linux, macos - const osTags = rule.tags.filter(tag => - ['windows', 'linux', 'macos', 'unix', 'azure', 'aws', 'gcp'].includes(tag.toLowerCase()) - ); - if (osTags.length > 0) { - osProduct = osTags[0].charAt(0).toUpperCase() + osTags[0].slice(1); - } - } else if (rule.logsource && rule.logsource.product) { - osProduct = rule.logsource.product; - } - - osProduct = osProduct.substring(0, 15).padEnd(15); - const id = (rule.id || '').substring(0, 13).padEnd(13); - - console.log(`${num}${title}${osProduct}${id}`); - }); - } else { - console.log(tableColors.dim('No results found')); - } - - console.log(tableColors.count(`${data.totalCount || 0} rows in set`)); - break; - - case 'details': - const detailsKeyWidth = 24; - const detailsValueWidth = 50; - - for (const [key, value] of Object.entries(data)) { - if (typeof value !== 'object' || value === null) { - const formattedKey = tableColors.key(key.padEnd(detailsKeyWidth - 2)); - - // Handle wrapping - const lines = normalizeAndWrap(value, detailsValueWidth); - - // Print first line with the key - console.log(`${formattedKey} ${lines[0]}`); - - // Print additional lines if there are any - for (let i = 1; i < lines.length; i++) { - console.log(`${' '.repeat(detailsKeyWidth)} ${lines[i]}`); - } - } - } - break; - - case 'stats': - const statsMetricWidth = 25; - - for (const [key, value] of Object.entries(data)) { - const formattedKey = tableColors.statsKey(key.padEnd(statsMetricWidth - 2)); - const formattedValue = String(value || ''); - - console.log(`${formattedKey} ${formattedValue}`); - } - 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) && !input.trim().toLowerCase().includes('where') && !input.trim().toLowerCase().includes('with')) { - 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; - } - - // 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]; - - // Add to command history - commandHistory.push(input); - historyIndex = commandHistory.length; - - // 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) { - 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(); - } -} - -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) { - console.log(response.text); - isWaitingForResults = true; - return; // Don't show prompt after progress messages - } - - 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') { - - /* - This conversion functionality exists because the Fylgja CLI's formatting - system expects search results in a specific structure with results and - totalCount properties, while the underlying sigma search service - returns results as a direct array of rule objects. This adapter pattern allows - the system to handle different response formats from various backend services - without requiring extensive changes to either the service layer or the presentation - layer. It essentially serves as a compatibility layer between components that were - likely developed independently or evolved at different times in the project's - lifecycle, maintaining backward compatibility while allowing for flexibility - in how data is processed throughout the application. - */ - - // Try to adapt data structure if needed - let dataToFormat = response.responseData; - - // If responseData is just an array, wrap it in proper structure - if (Array.isArray(dataToFormat)) { - dataToFormat = { - results: dataToFormat, - totalCount: dataToFormat.length - }; - } - // If missing totalCount but has pagination info, adapt - else if (dataToFormat.results && - !dataToFormat.totalCount && - dataToFormat.pagination && - dataToFormat.pagination.totalResults) { - dataToFormat.totalCount = dataToFormat.pagination.totalResults; - } - - formattedData = formatSigmaSearchResults(dataToFormat); - - 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.'); - } - - // Reset waiting state and show prompt after results - isWaitingForResults = false; - 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 -- 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); -} -/** - * Start the CLI application - */ -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 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/utils/cli-logo.js b/src/fylgja-cli/utils/cli_logo.js similarity index 99% rename from src/utils/cli-logo.js rename to src/fylgja-cli/utils/cli_logo.js index 70c887f..c547d40 100644 --- a/src/utils/cli-logo.js +++ b/src/fylgja-cli/utils/cli_logo.js @@ -1,5 +1,5 @@ /** - * cli-logo.js + * cli_logo.js * * Gradient-colored ASCII logo for CLI */ 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/lang/command_patterns.js b/src/lang/command_patterns.js index 4e6240d..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,24 +25,24 @@ const commandPatterns = [ }, // Sigma search patterns { - name: 'sigma-search-complex-1', - regex: /^(search|find)\s+sigma\s+rules?\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: [4] // complex query conditions in capturing group 4 }, // Alternate form without "rules" { - name: 'sigma-search-complex-2', - regex: /^(search|find)\s+sigma\s+(where|with)\s+(.+)$/i, + 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: 'sigma-search-simple', - regex: /^(search|find)\s+sigma\s+(.+)$/i, + name: 'search-sigma-simple', + regex: /^search\s+sigma\s+(.+)$/i, action: 'search', module: 'sigma', params: [2] // keyword is in capturing group 2 @@ -56,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',