From 845440962d238d460752323b27a5ee282f2b8aac Mon Sep 17 00:00:00 2001 From: Charlotte Croce Date: Sat, 19 Apr 2025 13:18:38 -0400 Subject: [PATCH] format CLI tables --- src/fylgja-cli.js | 255 +++++++++++++++++++++++++----------- src/utils/cli_formatters.js | 58 ++++++-- 2 files changed, 226 insertions(+), 87 deletions(-) diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js index e5dde90..5ef1a0a 100644 --- a/src/fylgja-cli.js +++ b/src/fylgja-cli.js @@ -36,10 +36,10 @@ const { handleCommand: handleConfig } = require('./handlers/config/config_handle const { handleCommand: handleStats } = require('./handlers/stats/stats_handler'); // Import CLI formatters -const { +const { formatSigmaStats, - formatSigmaSearchResults, - formatSigmaDetails + formatSigmaSearchResults, + formatSigmaDetails } = require('./utils/cli_formatters'); // Set logger to CLI mode (prevents console output) @@ -86,24 +86,83 @@ const rl = readline.createInterface({ */ function completer(line) { const commands = [ - 'search sigma', - 'details sigma', + 'search sigma', + 'details sigma', 'sigma stats', 'stats sigma', 'search sigma rules where title contains', 'search rules where tags include', 'search rules where logsource.category ==', 'search rules where modified after', - 'help', - 'exit', + 'help', + 'exit', 'quit', 'clear' ]; - + const hits = commands.filter((c) => c.startsWith(line)); return [hits.length ? hits : commands, line]; } +/** + * Normalize and wrap text for table display + * @param {string} text Text to normalize and wrap + * @param {number} maxWidth Maximum width per line + * @returns {string[]} Array of wrapped lines + */ +function normalizeAndWrap(text, maxWidth) { + if (!text) return ['']; + + // Convert to string and normalize newlines + text = String(text || ''); + + // Replace all literal newlines with spaces + text = text.replace(/\n/g, ' '); + + // Now apply word wrapping + if (text.length <= maxWidth) return [text]; + + const words = text.split(' '); + const lines = []; + let currentLine = ''; + + for (const word of words) { + // Skip empty words (could happen if there were multiple spaces) + if (!word) continue; + + // If adding this word would exceed max width + if ((currentLine.length + word.length + (currentLine ? 1 : 0)) > maxWidth) { + // Push current line if not empty + if (currentLine) { + lines.push(currentLine); + currentLine = ''; + } + + // If the word itself is longer than maxWidth, we need to split it + if (word.length > maxWidth) { + let remaining = word; + while (remaining.length > 0) { + const chunk = remaining.substring(0, maxWidth); + lines.push(chunk); + remaining = remaining.substring(maxWidth); + } + } else { + currentLine = word; + } + } else { + // Add word to current line + currentLine = currentLine ? `${currentLine} ${word}` : word; + } + } + + // Add the last line if not empty + if (currentLine) { + lines.push(currentLine); + } + + return lines; +} + /** * Format CLI output similar to MySQL * @param {Object} data The data to format @@ -117,59 +176,105 @@ function formatOutput(data, type) { switch (type) { case 'search_results': + // Search results table format remains the same console.log('\n+-------+----------------------+------------------+-------------+'); console.log('| ID | Title | Author | Level |'); console.log('+-------+----------------------+------------------+-------------+'); - + if (data.results && data.results.length > 0) { data.results.forEach(rule => { const id = (rule.id || '').padEnd(5).substring(0, 5); const title = (rule.title || '').padEnd(20).substring(0, 20); const author = (rule.author || 'Unknown').padEnd(16).substring(0, 16); const level = (rule.level || 'medium').padEnd(11).substring(0, 11); - + console.log(`| ${id} | ${title} | ${author} | ${level} |`); }); } else { console.log('| No results found |'); } - + console.log('+-------+----------------------+------------------+-------------+'); console.log(`${data.totalCount || 0} rows in set`); break; - + case 'details': - console.log('\n+----------------------+--------------------------------------------------+'); - console.log('| Field | Value |'); - console.log('+----------------------+--------------------------------------------------+'); - + // Set a fixed width for the entire table + const sigmaDetailsKeyWidth = 22; + const sigmaDetailsValueWidth = 50; + + // Create the table borders + const detailsHeaderLine = '╔' + '═'.repeat(sigmaDetailsKeyWidth) + '╦' + '═'.repeat(sigmaDetailsValueWidth) + '╗'; + const sigmaDetailsDividerLine = '╠' + '═'.repeat(sigmaDetailsKeyWidth) + '╬' + '═'.repeat(sigmaDetailsValueWidth) + '╣'; + const sigmaDetailsRowSeparator = '╟' + '─'.repeat(sigmaDetailsKeyWidth) + '╫' + '─'.repeat(sigmaDetailsValueWidth) + '╢'; + const sigmaDetailsFooterLine = '╚' + '═'.repeat(sigmaDetailsKeyWidth) + '╩' + '═'.repeat(sigmaDetailsValueWidth) + '╝'; + + console.log('\n' + detailsHeaderLine); + console.log(`║ ${'Field'.padEnd(sigmaDetailsKeyWidth - 2)} ║ ${'Value'.padEnd(sigmaDetailsValueWidth - 2)} ║`); + console.log(sigmaDetailsDividerLine); + + // Track whether we need to add a row separator + let isFirstRow = true; + for (const [key, value] of Object.entries(data)) { if (typeof value !== 'object' || value === null) { - const formattedKey = key.padEnd(20).substring(0, 20); - const formattedValue = String(value || '').padEnd(48).substring(0, 48); - - console.log(`| ${formattedKey} | ${formattedValue} |`); + // Add separator between rows (but not before the first row) + if (!isFirstRow) { + console.log(sigmaDetailsRowSeparator); + } + isFirstRow = false; + + const formattedKey = key.padEnd(sigmaDetailsKeyWidth - 2); + + // Handle wrapping + const lines = normalizeAndWrap(value, sigmaDetailsValueWidth - 2); + + // Print first line with the key + console.log(`║ ${formattedKey} ║ ${lines[0].padEnd(sigmaDetailsValueWidth - 2)} ║`); + + // Print additional lines if there are any + for (let i = 1; i < lines.length; i++) { + console.log(`║ ${' '.repeat(sigmaDetailsKeyWidth - 2)} ║ ${lines[i].padEnd(sigmaDetailsValueWidth - 2)} ║`); + } } } - - console.log('+----------------------+--------------------------------------------------+'); + + console.log(sigmaDetailsFooterLine); break; - case 'stats': - console.log('\n+--------------------+---------------+'); - console.log('| Metric | Value |'); - console.log('+--------------------+---------------+'); - + // Set column widths + const sigmaStatsMetricWidth = 25; + const sigmaStatsValueWidth = 26; + + // Create the table borders + const sigmaStatsHeaderLine = '╔' + '═'.repeat(sigmaStatsMetricWidth) + '╦' + '═'.repeat(sigmaStatsValueWidth) + '╗'; + const sigmaStatsDividerLine = '╠' + '═'.repeat(sigmaStatsMetricWidth) + '╬' + '═'.repeat(sigmaStatsValueWidth) + '╣'; + const sigmaStatsRowSeparator = '╟' + '─'.repeat(sigmaStatsMetricWidth) + '╫' + '─'.repeat(sigmaStatsValueWidth) + '╢'; + const sigmaStatsFooterLine = '╚' + '═'.repeat(sigmaStatsMetricWidth) + '╩' + '═'.repeat(sigmaStatsValueWidth) + '╝'; + + console.log('\n' + sigmaStatsHeaderLine); + console.log(`║ ${'Metric'.padEnd(sigmaStatsMetricWidth - 2)} ║ ${'Value'.padEnd(sigmaStatsValueWidth - 2)} ║`); + console.log(sigmaStatsDividerLine); + + // Track whether we need to add a row separator + let statsIsFirstRow = true; + for (const [key, value] of Object.entries(data)) { - const formattedKey = key.padEnd(18).substring(0, 18); - const formattedValue = String(value || '').padEnd(13).substring(0, 13); - - console.log(`| ${formattedKey} | ${formattedValue} |`); + // Add separator between rows (but not before the first row) + if (!statsIsFirstRow) { + console.log(sigmaStatsRowSeparator); + } + statsIsFirstRow = false; + + const formattedKey = key.padEnd(sigmaStatsMetricWidth - 2); + const formattedValue = String(value || '').padEnd(sigmaStatsValueWidth - 2); + + console.log(`║ ${formattedKey} ║ ${formattedValue} ║`); } - - console.log('+--------------------+---------------+'); + + console.log(sigmaStatsFooterLine); break; - + default: console.log(JSON.stringify(data, null, 2)); } @@ -183,18 +288,18 @@ function formatOutput(data, type) { */ 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; } @@ -210,28 +315,28 @@ async function processCommand(input) { rl.prompt(); return; } - + // Special CLI commands if (input.trim().toLowerCase() === 'exit' || input.trim().toLowerCase() === 'quit') { console.log('Goodbye!'); rl.close(); process.exit(0); } - + if (input.trim().toLowerCase() === 'clear') { console.clear(); rl.prompt(); return; } - + // Special case for simple search if (input.trim().match(/^search\s+sigma\s+(.+)$/i)) { const keyword = input.trim().match(/^search\s+sigma\s+(.+)$/i)[1]; - + // Add to command history commandHistory.push(input); historyIndex = commandHistory.length; - + // Create fake command object const command = { text: keyword, @@ -241,12 +346,12 @@ async function processCommand(input) { 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) { @@ -255,29 +360,29 @@ async function processCommand(input) { 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, @@ -287,17 +392,17 @@ async function processCommand(input) { 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': @@ -305,50 +410,50 @@ async function processCommand(input) { 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(); @@ -383,13 +488,13 @@ function createRespondFunction(action, module, params) { rl.prompt(); return; } - + // First check for the responseData property (directly from service) if (response.responseData) { // Format the data using the appropriate formatter if (module === 'sigma') { let formattedData; - + if (action === 'search' || action === 'complexSearch') { formattedData = formatSigmaSearchResults(response.responseData); formatOutput(formattedData, 'search_results'); @@ -413,7 +518,7 @@ function createRespondFunction(action, module, params) { } else { console.log('Command completed successfully.'); } - + rl.prompt(); }; } @@ -441,7 +546,7 @@ Advanced Sigma Search Commands: - clear - Clear the terminal screen - help - Display this help text `; - + console.log(helpText); } @@ -452,7 +557,7 @@ function startCLI() { console.log(ASCII_LOGO); console.log(`Fylgja CLI v${version} - Interactive SIEM Management Tool`); console.log(`Type 'help' for usage information or 'exit' to quit\n`); - + // Set up key bindings for history navigation rl._writeToOutput = function _writeToOutput(stringToWrite) { if (stringToWrite === '\\u001b[A' || stringToWrite === '\\u001b[B') { @@ -461,7 +566,7 @@ function startCLI() { } rl.output.write(stringToWrite); }; - + // Set up key listeners for history rl.input.on('keypress', (char, key) => { if (key && key.name === 'up') { @@ -485,13 +590,13 @@ function startCLI() { } } }); - + rl.prompt(); - + rl.on('line', async (line) => { await processCommand(line.trim()); }); - + rl.on('close', () => { console.log('Goodbye!'); process.exit(0); diff --git a/src/utils/cli_formatters.js b/src/utils/cli_formatters.js index 44ae36a..9914284 100644 --- a/src/utils/cli_formatters.js +++ b/src/utils/cli_formatters.js @@ -6,6 +6,40 @@ */ const chalk = require('chalk'); +/** + * Wraps text at specified length + * @param {string} text - Text to wrap + * @param {number} maxLength - Maximum line length + * @returns {string} Wrapped text + */ +function wrapText(text, maxLength = 80) { + if (!text || typeof text !== 'string') { + return text; + } + + if (text.length <= maxLength) { + return text; + } + + const words = text.split(' '); + let wrappedText = ''; + let currentLine = ''; + + words.forEach(word => { + // If adding this word would exceed max length, start a new line + if ((currentLine + word).length + 1 > maxLength) { + wrappedText += currentLine.trim() + '\n'; + currentLine = word + ' '; + } else { + currentLine += word + ' '; + } + }); + + // Add the last line + wrappedText += currentLine.trim(); + + return wrappedText; +} /** * Format Sigma rule details for CLI display @@ -20,17 +54,17 @@ function formatSigmaDetails(ruleDetails) { // Create a flattened object for display in CLI table format const formattedDetails = { 'ID': ruleDetails.id || 'Unknown', - 'Title': ruleDetails.title || 'Untitled Rule', - 'Description': ruleDetails.description || 'No description provided', + 'Title': wrapText(ruleDetails.title || 'Untitled Rule', 80), + 'Description': wrapText(ruleDetails.description || 'No description provided', 80), 'Author': ruleDetails.author || 'Unknown author', 'Severity': ruleDetails.severity || 'Unknown', - 'Detection': 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' + 'Detection': wrapText(ruleDetails.detectionExplanation || 'No detection specified', 80), + 'False Positives': wrapText(Array.isArray(ruleDetails.falsePositives) ? + ruleDetails.falsePositives.join(', ') : 'None specified', 80), + 'Tags': wrapText(Array.isArray(ruleDetails.tags) ? + ruleDetails.tags.join(', ') : 'None', 80), + 'References': wrapText(Array.isArray(ruleDetails.references) ? + ruleDetails.references.join(', ') : 'None', 80) }; return formattedDetails; @@ -114,7 +148,7 @@ function formatSigmaSearchResults(searchResults) { return { results: searchResults.results.map(rule => ({ id: rule.id || '', - title: rule.title || '', + title: wrapText(rule.title || '', 60), // Use narrower width for table columns author: rule.author || 'Unknown', level: rule.level || 'medium' })), @@ -122,9 +156,9 @@ function formatSigmaSearchResults(searchResults) { }; } - module.exports = { formatSigmaStats, formatSigmaSearchResults, - formatSigmaDetails + formatSigmaDetails, + wrapText }; \ No newline at end of file