From 8c725be8a159cc23d042f2ade5e6d692e0f36920 Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sun, 20 Apr 2025 17:21:24 -0400 Subject: [PATCH] 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,