From 8c725be8a159cc23d042f2ade5e6d692e0f36920 Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sun, 20 Apr 2025 17:21:24 -0400 Subject: [PATCH 1/4] CLI formatting. no tables just tabs. and colors --- src/fylgja-cli.js | 319 ++++++++++++++++++++++++----------- src/lang/command_patterns.js | 23 ++- src/logo.js | 0 src/utils/cli_formatters.js | 26 ++- 4 files changed, 260 insertions(+), 108 deletions(-) create mode 100644 src/logo.js diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js index 5ef1a0a..70b7b15 100644 --- a/src/fylgja-cli.js +++ b/src/fylgja-cli.js @@ -3,27 +3,6 @@ * * Interactive CLI interface */ - -const readline = require('readline'); -// Import chalk with compatibility for both ESM and CommonJS -let chalk; -try { - // First try CommonJS import (chalk v4.x) - chalk = require('chalk'); -} catch (e) { - // If that fails, provide a fallback implementation - chalk = { - blue: (text) => text, - green: (text) => text, - red: (text) => text, - yellow: (text) => text, - cyan: (text) => text, - white: (text) => text, - dim: (text) => text, - hex: () => (text) => text - }; -} - const { parseCommand } = require('./lang/command_parser'); const logger = require('./utils/logger'); const sigmaSearchHandler = require('./handlers/sigma/sigma_search_handler'); @@ -34,6 +13,58 @@ const { handleCommand: handleAlerts } = require('./handlers/alerts/alerts_handle 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 readline = require('readline'); + + +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 { @@ -91,9 +122,17 @@ function completer(line) { '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', + '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', @@ -163,8 +202,9 @@ function normalizeAndWrap(text, maxWidth) { return lines; } + /** - * Format CLI output similar to MySQL + * Format CLI output * @param {Object} data The data to format * @param {string} type The type of data (results, details, stats) */ @@ -174,105 +214,133 @@ function formatOutput(data, type) { 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': - // Search results table format remains the same - console.log('\n+-------+----------------------+------------------+-------------+'); - console.log('| ID | Title | Author | Level |'); - console.log('+-------+----------------------+------------------+-------------+'); + // 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 => { - 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); + data.results.forEach((rule, index) => { + const num = (index + 1).toString().padEnd(5); + const title = (rule.title || '').substring(0, 32).padEnd(32); - console.log(`| ${id} | ${title} | ${author} | ${level} |`); + // 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('| No results found |'); + console.log(tableColors.dim('No results found')); } - console.log('+-------+----------------------+------------------+-------------+'); - console.log(`${data.totalCount || 0} rows in set`); + console.log(tableColors.count(`${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; + const detailsKeyWidth = 24; + const detailsValueWidth = 50; 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); + const formattedKey = tableColors.key(key.padEnd(detailsKeyWidth - 2)); // Handle wrapping - const lines = normalizeAndWrap(value, sigmaDetailsValueWidth - 2); + const lines = normalizeAndWrap(value, detailsValueWidth); // Print first line with the key - console.log(`║ ${formattedKey} ║ ${lines[0].padEnd(sigmaDetailsValueWidth - 2)} ║`); + console.log(`${formattedKey} ${lines[0]}`); // 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(`${' '.repeat(detailsKeyWidth)} ${lines[i]}`); } } } - - 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; + const statsMetricWidth = 25; 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 = tableColors.statsKey(key.padEnd(statsMetricWidth - 2)); + const formattedValue = String(value || ''); - const formattedKey = key.padEnd(sigmaStatsMetricWidth - 2); - const formattedValue = String(value || '').padEnd(sigmaStatsValueWidth - 2); - - console.log(`║ ${formattedKey} ║ ${formattedValue} ║`); + console.log(`${formattedKey} ${formattedValue}`); } - - console.log(sigmaStatsFooterLine); break; default: @@ -330,7 +398,7 @@ async function processCommand(input) { } // Special case for simple search - if (input.trim().match(/^search\s+sigma\s+(.+)$/i)) { + 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 @@ -364,6 +432,42 @@ async function processCommand(input) { 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; @@ -475,14 +579,32 @@ async function processCommand(input) { } /** - * Create a custom respond function for handling results + * 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) { + // Keep track of whether we're waiting for results + let isWaitingForResults = false; + return async (response) => { + // Check if this is a progress message + 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(); @@ -519,6 +641,8 @@ function createRespondFunction(action, module, params) { console.log('Command completed successfully.'); } + // Reset waiting state and show prompt after results + isWaitingForResults = false; rl.prompt(); }; } @@ -540,8 +664,10 @@ Advanced Sigma Search Commands: - 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 @@ -549,7 +675,6 @@ Advanced Sigma Search Commands: console.log(helpText); } - /** * Start the CLI application */ diff --git a/src/lang/command_patterns.js b/src/lang/command_patterns.js index b95c582..4e6240d 100644 --- a/src/lang/command_patterns.js +++ b/src/lang/command_patterns.js @@ -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: 'sigma-search-complex-1', + regex: /^(search|find)\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: 'sigma-search-complex-2', + regex: /^(search|find)\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, + action: 'search', + module: 'sigma', + params: [2] // keyword is in capturing group 2 }, - // Sigma create patterns { name: 'sigma-create', diff --git a/src/logo.js b/src/logo.js new file mode 100644 index 0000000..e69de29 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, From d2154c82ab4dff045a505d6adbe05dfe68a3cb4d Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sun, 20 Apr 2025 17:34:03 -0400 Subject: [PATCH 2/4] create CLI logo --- src/fylgja-cli.js | 15 ++---- src/logo.js | 0 src/utils/cli-logo.js | 105 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 12 deletions(-) delete mode 100644 src/logo.js create mode 100644 src/utils/cli-logo.js diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js index 70b7b15..db63a7d 100644 --- a/src/fylgja-cli.js +++ b/src/fylgja-cli.js @@ -13,6 +13,8 @@ const { handleCommand: handleAlerts } = require('./handlers/alerts/alerts_handle 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'); @@ -87,17 +89,6 @@ try { const FILE_NAME = 'fylgja-cli.js'; -// ASCII art logo for the CLI -const ASCII_LOGO = ` -░▒▓████████▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░░▒▓██████▓▒░ -░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ -░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ -░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░ ░▒▓█▓▒▒▓███▓▒░ ░▒▓█▓▒░▒▓████████▓▒░ -░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ -░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ -░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░ -`; - // Command history array let commandHistory = []; let historyIndex = -1; @@ -679,7 +670,7 @@ CLI Commands: * Start the CLI application */ function startCLI() { - console.log(ASCII_LOGO); + 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`); diff --git a/src/logo.js b/src/logo.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/utils/cli-logo.js b/src/utils/cli-logo.js new file mode 100644 index 0000000..70c887f --- /dev/null +++ b/src/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 From 853b60d7623ff7eacd0cf621a09f001f7f53f3a2 Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sun, 20 Apr 2025 18:03:55 -0400 Subject: [PATCH 3/4] add search CLI MVP, needs refactoring --- src/fylgja-cli.js | 45 +++++++++++++++++----- src/handlers/sigma/sigma_search_handler.js | 2 + 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js index db63a7d..3722877 100644 --- a/src/fylgja-cli.js +++ b/src/fylgja-cli.js @@ -569,19 +569,11 @@ async function processCommand(input) { } } -/** - * 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) { // Keep track of whether we're waiting for results let isWaitingForResults = false; return async (response) => { - // Check if this is a progress message const isProgressMessage = typeof response === 'object' && response.text && @@ -604,12 +596,46 @@ function createRespondFunction(action, module, params) { // 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); + + /* + 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); @@ -638,6 +664,7 @@ function createRespondFunction(action, module, params) { }; } + /** * Display help text */ 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 }); From 34143c42415862da8dc21f7115c8d98c12deb27b Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sun, 20 Apr 2025 18:37:20 -0400 Subject: [PATCH 4/4] 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',