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');
// 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);

View file

@ -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
};