format CLI tables

This commit is contained in:
Charlotte Croce 2025-04-19 13:18:38 -04:00
parent fd394fff36
commit 845440962d
2 changed files with 226 additions and 87 deletions

View file

@ -36,10 +36,10 @@ const { handleCommand: handleConfig } = require('./handlers/config/config_handle
const { handleCommand: handleStats } = require('./handlers/stats/stats_handler'); const { handleCommand: handleStats } = require('./handlers/stats/stats_handler');
// Import CLI formatters // Import CLI formatters
const { const {
formatSigmaStats, formatSigmaStats,
formatSigmaSearchResults, formatSigmaSearchResults,
formatSigmaDetails formatSigmaDetails
} = require('./utils/cli_formatters'); } = require('./utils/cli_formatters');
// Set logger to CLI mode (prevents console output) // Set logger to CLI mode (prevents console output)
@ -86,24 +86,83 @@ const rl = readline.createInterface({
*/ */
function completer(line) { function completer(line) {
const commands = [ const commands = [
'search sigma', 'search sigma',
'details sigma', 'details sigma',
'sigma stats', 'sigma stats',
'stats sigma', 'stats sigma',
'search sigma rules where title contains', 'search sigma rules where title contains',
'search rules where tags include', 'search rules where tags include',
'search rules where logsource.category ==', 'search rules where logsource.category ==',
'search rules where modified after', 'search rules where modified after',
'help', 'help',
'exit', 'exit',
'quit', 'quit',
'clear' 'clear'
]; ];
const hits = commands.filter((c) => c.startsWith(line)); const hits = commands.filter((c) => c.startsWith(line));
return [hits.length ? hits : commands, 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 * Format CLI output similar to MySQL
* @param {Object} data The data to format * @param {Object} data The data to format
@ -117,59 +176,105 @@ function formatOutput(data, type) {
switch (type) { switch (type) {
case 'search_results': case 'search_results':
// Search results table format remains the same
console.log('\n+-------+----------------------+------------------+-------------+'); console.log('\n+-------+----------------------+------------------+-------------+');
console.log('| ID | Title | Author | Level |'); console.log('| ID | Title | Author | Level |');
console.log('+-------+----------------------+------------------+-------------+'); console.log('+-------+----------------------+------------------+-------------+');
if (data.results && data.results.length > 0) { if (data.results && data.results.length > 0) {
data.results.forEach(rule => { data.results.forEach(rule => {
const id = (rule.id || '').padEnd(5).substring(0, 5); const id = (rule.id || '').padEnd(5).substring(0, 5);
const title = (rule.title || '').padEnd(20).substring(0, 20); const title = (rule.title || '').padEnd(20).substring(0, 20);
const author = (rule.author || 'Unknown').padEnd(16).substring(0, 16); const author = (rule.author || 'Unknown').padEnd(16).substring(0, 16);
const level = (rule.level || 'medium').padEnd(11).substring(0, 11); const level = (rule.level || 'medium').padEnd(11).substring(0, 11);
console.log(`| ${id} | ${title} | ${author} | ${level} |`); console.log(`| ${id} | ${title} | ${author} | ${level} |`);
}); });
} else { } else {
console.log('| No results found |'); console.log('| No results found |');
} }
console.log('+-------+----------------------+------------------+-------------+'); console.log('+-------+----------------------+------------------+-------------+');
console.log(`${data.totalCount || 0} rows in set`); console.log(`${data.totalCount || 0} rows in set`);
break; break;
case 'details': case 'details':
console.log('\n+----------------------+--------------------------------------------------+'); // Set a fixed width for the entire table
console.log('| Field | Value |'); const sigmaDetailsKeyWidth = 22;
console.log('+----------------------+--------------------------------------------------+'); 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)) { for (const [key, value] of Object.entries(data)) {
if (typeof value !== 'object' || value === null) { if (typeof value !== 'object' || value === null) {
const formattedKey = key.padEnd(20).substring(0, 20); // Add separator between rows (but not before the first row)
const formattedValue = String(value || '').padEnd(48).substring(0, 48); if (!isFirstRow) {
console.log(sigmaDetailsRowSeparator);
console.log(`| ${formattedKey} | ${formattedValue} |`); }
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; break;
case 'stats': case 'stats':
console.log('\n+--------------------+---------------+'); // Set column widths
console.log('| Metric | Value |'); const sigmaStatsMetricWidth = 25;
console.log('+--------------------+---------------+'); 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)) { for (const [key, value] of Object.entries(data)) {
const formattedKey = key.padEnd(18).substring(0, 18); // Add separator between rows (but not before the first row)
const formattedValue = String(value || '').padEnd(13).substring(0, 13); if (!statsIsFirstRow) {
console.log(sigmaStatsRowSeparator);
console.log(`| ${formattedKey} | ${formattedValue} |`); }
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; break;
default: default:
console.log(JSON.stringify(data, null, 2)); console.log(JSON.stringify(data, null, 2));
} }
@ -183,18 +288,18 @@ function formatOutput(data, type) {
*/ */
function extractSearchKeywords(input) { function extractSearchKeywords(input) {
if (!input) return ''; if (!input) return '';
// Try to extract keywords from common patterns // Try to extract keywords from common patterns
if (input.includes('title contains')) { if (input.includes('title contains')) {
const match = input.match(/title\s+contains\s+["']([^"']+)["']/i); const match = input.match(/title\s+contains\s+["']([^"']+)["']/i);
if (match) return match[1]; if (match) return match[1];
} }
if (input.includes('tags include')) { if (input.includes('tags include')) {
const match = input.match(/tags\s+include\s+(\S+)/i); const match = input.match(/tags\s+include\s+(\S+)/i);
if (match) return match[1]; if (match) return match[1];
} }
// Default - just return the input as is // Default - just return the input as is
return input; return input;
} }
@ -210,28 +315,28 @@ async function processCommand(input) {
rl.prompt(); rl.prompt();
return; return;
} }
// Special CLI commands // Special CLI commands
if (input.trim().toLowerCase() === 'exit' || input.trim().toLowerCase() === 'quit') { if (input.trim().toLowerCase() === 'exit' || input.trim().toLowerCase() === 'quit') {
console.log('Goodbye!'); console.log('Goodbye!');
rl.close(); rl.close();
process.exit(0); process.exit(0);
} }
if (input.trim().toLowerCase() === 'clear') { if (input.trim().toLowerCase() === 'clear') {
console.clear(); console.clear();
rl.prompt(); rl.prompt();
return; return;
} }
// Special case for simple search // Special case for simple search
if (input.trim().match(/^search\s+sigma\s+(.+)$/i)) { if (input.trim().match(/^search\s+sigma\s+(.+)$/i)) {
const keyword = input.trim().match(/^search\s+sigma\s+(.+)$/i)[1]; const keyword = input.trim().match(/^search\s+sigma\s+(.+)$/i)[1];
// Add to command history // Add to command history
commandHistory.push(input); commandHistory.push(input);
historyIndex = commandHistory.length; historyIndex = commandHistory.length;
// Create fake command object // Create fake command object
const command = { const command = {
text: keyword, text: keyword,
@ -241,12 +346,12 @@ async function processCommand(input) {
channel_id: 'cli', channel_id: 'cli',
channel_name: 'cli' channel_name: 'cli'
}; };
// Create custom respond function // Create custom respond function
const respond = createRespondFunction('search', 'sigma', [keyword]); const respond = createRespondFunction('search', 'sigma', [keyword]);
console.log(`Executing: module=sigma, action=search, params=[${keyword}]`); console.log(`Executing: module=sigma, action=search, params=[${keyword}]`);
try { try {
await sigmaSearchHandler.handleCommand(command, respond); await sigmaSearchHandler.handleCommand(command, respond);
} catch (error) { } catch (error) {
@ -255,29 +360,29 @@ async function processCommand(input) {
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`); logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
rl.prompt(); rl.prompt();
} }
return; return;
} }
// Add to command history // Add to command history
commandHistory.push(input); commandHistory.push(input);
historyIndex = commandHistory.length; historyIndex = commandHistory.length;
// Parse command using existing parser // Parse command using existing parser
const parsedCommand = await parseCommand(input); const parsedCommand = await parseCommand(input);
if (!parsedCommand.success) { if (!parsedCommand.success) {
console.log(parsedCommand.message || "Command not recognized. Type 'help' for usage."); console.log(parsedCommand.message || "Command not recognized. Type 'help' for usage.");
rl.prompt(); rl.prompt();
return; return;
} }
// Extract the command details // Extract the command details
const { action, module, params } = parsedCommand.command; const { action, module, params } = parsedCommand.command;
// Only show execution info to the user, not sending to logger // Only show execution info to the user, not sending to logger
console.log(`Executing: module=${module}, action=${action}, params=[${params}]`); console.log(`Executing: module=${module}, action=${action}, params=[${params}]`);
// Create fake command object similar to Slack's // Create fake command object similar to Slack's
const command = { const command = {
text: Array.isArray(params) && params.length > 0 ? params[0] : input, text: Array.isArray(params) && params.length > 0 ? params[0] : input,
@ -287,17 +392,17 @@ async function processCommand(input) {
channel_id: 'cli', channel_id: 'cli',
channel_name: 'cli' channel_name: 'cli'
}; };
// Special handling for complexSearch to extract keywords // Special handling for complexSearch to extract keywords
if (action === 'complexSearch' && module === 'sigma' && params.length > 0) { if (action === 'complexSearch' && module === 'sigma' && params.length > 0) {
// Try to extract keywords from complex queries // Try to extract keywords from complex queries
const searchTerms = extractSearchKeywords(params[0]); const searchTerms = extractSearchKeywords(params[0]);
command.text = searchTerms || params[0]; command.text = searchTerms || params[0];
} }
// Create custom respond function for CLI // Create custom respond function for CLI
const respond = createRespondFunction(action, module, params); const respond = createRespondFunction(action, module, params);
try { try {
switch (module) { switch (module) {
case 'sigma': case 'sigma':
@ -305,50 +410,50 @@ async function processCommand(input) {
case 'search': case 'search':
await sigmaSearchHandler.handleCommand(command, respond); await sigmaSearchHandler.handleCommand(command, respond);
break; break;
case 'complexSearch': case 'complexSearch':
await sigmaSearchHandler.handleComplexSearch(command, respond); await sigmaSearchHandler.handleComplexSearch(command, respond);
break; break;
case 'details': case 'details':
await sigmaDetailsHandler.handleCommand(command, respond); await sigmaDetailsHandler.handleCommand(command, respond);
break; break;
case 'stats': case 'stats':
await sigmaStatsHandler.handleCommand(command, respond); await sigmaStatsHandler.handleCommand(command, respond);
break; break;
case 'create': case 'create':
await sigmaCreateHandler.handleCommand(command, respond); await sigmaCreateHandler.handleCommand(command, respond);
break; break;
default: default:
console.log(`Unknown Sigma action: ${action}`); console.log(`Unknown Sigma action: ${action}`);
rl.prompt(); rl.prompt();
} }
break; break;
case 'alerts': case 'alerts':
await handleAlerts(command, respond); await handleAlerts(command, respond);
break; break;
case 'case': case 'case':
await handleCase(command, respond); await handleCase(command, respond);
break; break;
case 'config': case 'config':
await handleConfig(command, respond); await handleConfig(command, respond);
break; break;
case 'stats': case 'stats':
await handleStats(command, respond); await handleStats(command, respond);
break; break;
case 'help': case 'help':
displayHelp(); displayHelp();
rl.prompt(); rl.prompt();
break; break;
default: default:
console.log(`Unknown module: ${module}`); console.log(`Unknown module: ${module}`);
rl.prompt(); rl.prompt();
@ -383,13 +488,13 @@ function createRespondFunction(action, module, params) {
rl.prompt(); rl.prompt();
return; return;
} }
// First check for the responseData property (directly from service) // First check for the responseData property (directly from service)
if (response.responseData) { if (response.responseData) {
// Format the data using the appropriate formatter // Format the data using the appropriate formatter
if (module === 'sigma') { if (module === 'sigma') {
let formattedData; let formattedData;
if (action === 'search' || action === 'complexSearch') { if (action === 'search' || action === 'complexSearch') {
formattedData = formatSigmaSearchResults(response.responseData); formattedData = formatSigmaSearchResults(response.responseData);
formatOutput(formattedData, 'search_results'); formatOutput(formattedData, 'search_results');
@ -413,7 +518,7 @@ function createRespondFunction(action, module, params) {
} else { } else {
console.log('Command completed successfully.'); console.log('Command completed successfully.');
} }
rl.prompt(); rl.prompt();
}; };
} }
@ -441,7 +546,7 @@ Advanced Sigma Search Commands:
- clear - Clear the terminal screen - clear - Clear the terminal screen
- help - Display this help text - help - Display this help text
`; `;
console.log(helpText); console.log(helpText);
} }
@ -452,7 +557,7 @@ function startCLI() {
console.log(ASCII_LOGO); console.log(ASCII_LOGO);
console.log(`Fylgja CLI v${version} - Interactive SIEM Management Tool`); console.log(`Fylgja CLI v${version} - Interactive SIEM Management Tool`);
console.log(`Type 'help' for usage information or 'exit' to quit\n`); console.log(`Type 'help' for usage information or 'exit' to quit\n`);
// Set up key bindings for history navigation // Set up key bindings for history navigation
rl._writeToOutput = function _writeToOutput(stringToWrite) { rl._writeToOutput = function _writeToOutput(stringToWrite) {
if (stringToWrite === '\\u001b[A' || stringToWrite === '\\u001b[B') { if (stringToWrite === '\\u001b[A' || stringToWrite === '\\u001b[B') {
@ -461,7 +566,7 @@ function startCLI() {
} }
rl.output.write(stringToWrite); rl.output.write(stringToWrite);
}; };
// Set up key listeners for history // Set up key listeners for history
rl.input.on('keypress', (char, key) => { rl.input.on('keypress', (char, key) => {
if (key && key.name === 'up') { if (key && key.name === 'up') {
@ -485,13 +590,13 @@ function startCLI() {
} }
} }
}); });
rl.prompt(); rl.prompt();
rl.on('line', async (line) => { rl.on('line', async (line) => {
await processCommand(line.trim()); await processCommand(line.trim());
}); });
rl.on('close', () => { rl.on('close', () => {
console.log('Goodbye!'); console.log('Goodbye!');
process.exit(0); process.exit(0);

View file

@ -6,6 +6,40 @@
*/ */
const chalk = require('chalk'); 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 * Format Sigma rule details for CLI display
@ -20,17 +54,17 @@ function formatSigmaDetails(ruleDetails) {
// Create a flattened object for display in CLI table format // Create a flattened object for display in CLI table format
const formattedDetails = { const formattedDetails = {
'ID': ruleDetails.id || 'Unknown', 'ID': ruleDetails.id || 'Unknown',
'Title': ruleDetails.title || 'Untitled Rule', 'Title': wrapText(ruleDetails.title || 'Untitled Rule', 80),
'Description': ruleDetails.description || 'No description provided', 'Description': wrapText(ruleDetails.description || 'No description provided', 80),
'Author': ruleDetails.author || 'Unknown author', 'Author': ruleDetails.author || 'Unknown author',
'Severity': ruleDetails.severity || 'Unknown', 'Severity': ruleDetails.severity || 'Unknown',
'Detection': ruleDetails.detectionExplanation || 'No detection specified', 'Detection': wrapText(ruleDetails.detectionExplanation || 'No detection specified', 80),
'False Positives': Array.isArray(ruleDetails.falsePositives) ? 'False Positives': wrapText(Array.isArray(ruleDetails.falsePositives) ?
ruleDetails.falsePositives.join(', ') : 'None specified', ruleDetails.falsePositives.join(', ') : 'None specified', 80),
'Tags': Array.isArray(ruleDetails.tags) ? 'Tags': wrapText(Array.isArray(ruleDetails.tags) ?
ruleDetails.tags.join(', ') : 'None', ruleDetails.tags.join(', ') : 'None', 80),
'References': Array.isArray(ruleDetails.references) ? 'References': wrapText(Array.isArray(ruleDetails.references) ?
ruleDetails.references.join(', ') : 'None' ruleDetails.references.join(', ') : 'None', 80)
}; };
return formattedDetails; return formattedDetails;
@ -114,7 +148,7 @@ function formatSigmaSearchResults(searchResults) {
return { return {
results: searchResults.results.map(rule => ({ results: searchResults.results.map(rule => ({
id: rule.id || '', id: rule.id || '',
title: rule.title || '', title: wrapText(rule.title || '', 60), // Use narrower width for table columns
author: rule.author || 'Unknown', author: rule.author || 'Unknown',
level: rule.level || 'medium' level: rule.level || 'medium'
})), })),
@ -122,9 +156,9 @@ function formatSigmaSearchResults(searchResults) {
}; };
} }
module.exports = { module.exports = {
formatSigmaStats, formatSigmaStats,
formatSigmaSearchResults, formatSigmaSearchResults,
formatSigmaDetails formatSigmaDetails,
wrapText
}; };