CLI formatting. no tables just tabs. and colors

This commit is contained in:
Charlotte Croce 2025-04-20 17:21:24 -04:00
parent 282f6b74b6
commit 8c725be8a1
4 changed files with 260 additions and 108 deletions

View file

@ -3,27 +3,6 @@
* *
* Interactive CLI interface * 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 { parseCommand } = require('./lang/command_parser');
const logger = require('./utils/logger'); const logger = require('./utils/logger');
const sigmaSearchHandler = require('./handlers/sigma/sigma_search_handler'); 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: handleCase } = require('./handlers/case/case_handler');
const { handleCommand: handleConfig } = require('./handlers/config/config_handler'); const { handleCommand: handleConfig } = require('./handlers/config/config_handler');
const { handleCommand: handleStats } = require('./handlers/stats/stats_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 // Import CLI formatters
const { const {
@ -91,9 +122,17 @@ function completer(line) {
'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 sigma where title contains',
'search rules where logsource.category ==', 'search sigma rules where tags include',
'search rules where modified after', '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', 'help',
'exit', 'exit',
'quit', 'quit',
@ -163,8 +202,9 @@ function normalizeAndWrap(text, maxWidth) {
return lines; return lines;
} }
/** /**
* Format CLI output similar to MySQL * Format CLI output
* @param {Object} data The data to format * @param {Object} data The data to format
* @param {string} type The type of data (results, details, stats) * @param {string} type The type of data (results, details, stats)
*/ */
@ -174,105 +214,133 @@ function formatOutput(data, type) {
return; 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) { switch (type) {
case 'search_results': case 'search_results':
// Search results table format remains the same // Header
console.log('\n+-------+----------------------+------------------+-------------+'); console.log(
console.log('| ID | Title | Author | Level |'); tableColors.header(
console.log('+-------+----------------------+------------------+-------------+'); '#'.padEnd(5) +
'Title'.padEnd(32) +
'OS/Product'.padEnd(15) +
'ID'.padEnd(13)
)
);
if (data.results && data.results.length > 0) { if (data.results && data.results.length > 0) {
data.results.forEach(rule => { data.results.forEach((rule, index) => {
const id = (rule.id || '').padEnd(5).substring(0, 5); const num = (index + 1).toString().padEnd(5);
const title = (rule.title || '').padEnd(20).substring(0, 20); const title = (rule.title || '').substring(0, 32).padEnd(32);
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} |`); // 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 { } else {
console.log('| No results found |'); console.log(tableColors.dim('No results found'));
} }
console.log('+-------+----------------------+------------------+-------------+'); console.log(tableColors.count(`${data.totalCount || 0} rows in set`));
console.log(`${data.totalCount || 0} rows in set`);
break; break;
case 'details': case 'details':
// Set a fixed width for the entire table const detailsKeyWidth = 24;
const sigmaDetailsKeyWidth = 22; const detailsValueWidth = 50;
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) {
// Add separator between rows (but not before the first row) const formattedKey = tableColors.key(key.padEnd(detailsKeyWidth - 2));
if (!isFirstRow) {
console.log(sigmaDetailsRowSeparator);
}
isFirstRow = false;
const formattedKey = key.padEnd(sigmaDetailsKeyWidth - 2);
// Handle wrapping // Handle wrapping
const lines = normalizeAndWrap(value, sigmaDetailsValueWidth - 2); const lines = normalizeAndWrap(value, detailsValueWidth);
// Print first line with the key // 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 // Print additional lines if there are any
for (let i = 1; i < lines.length; i++) { 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; break;
case 'stats': case 'stats':
// Set column widths const statsMetricWidth = 25;
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)) { for (const [key, value] of Object.entries(data)) {
// Add separator between rows (but not before the first row) const formattedKey = tableColors.statsKey(key.padEnd(statsMetricWidth - 2));
if (!statsIsFirstRow) { const formattedValue = String(value || '');
console.log(sigmaStatsRowSeparator);
}
statsIsFirstRow = false;
const formattedKey = key.padEnd(sigmaStatsMetricWidth - 2); console.log(`${formattedKey} ${formattedValue}`);
const formattedValue = String(value || '').padEnd(sigmaStatsValueWidth - 2);
console.log(`${formattedKey}${formattedValue}`);
} }
console.log(sigmaStatsFooterLine);
break; break;
default: default:
@ -330,7 +398,7 @@ async function processCommand(input) {
} }
// 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) && !input.trim().toLowerCase().includes('where') && !input.trim().toLowerCase().includes('with')) {
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
@ -364,6 +432,42 @@ async function processCommand(input) {
return; 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 // Add to command history
commandHistory.push(input); commandHistory.push(input);
historyIndex = commandHistory.length; 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} action The action being performed
* @param {string} module The module being used * @param {string} module The module being used
* @param {Array} params The parameters for the action * @param {Array} params The parameters for the action
* @returns {Function} A respond function for handling results * @returns {Function} A respond function for handling results
*/ */
function createRespondFunction(action, module, params) { function createRespondFunction(action, module, params) {
// Keep track of whether we're waiting for results
let isWaitingForResults = false;
return async (response) => { 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') { if (typeof response === 'string') {
console.log(response); console.log(response);
rl.prompt(); rl.prompt();
@ -519,6 +641,8 @@ function createRespondFunction(action, module, params) {
console.log('Command completed successfully.'); console.log('Command completed successfully.');
} }
// Reset waiting state and show prompt after results
isWaitingForResults = false;
rl.prompt(); rl.prompt();
}; };
} }
@ -540,8 +664,10 @@ Advanced Sigma Search Commands:
- search sigma where tags include privilege_escalation - Search by tags - search sigma where tags include privilege_escalation - Search by tags
- search sigma where logsource.category == "process_creation" - Search by log source - 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 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 - exit or quit - Exit the CLI
- clear - Clear the terminal screen - clear - Clear the terminal screen
- help - Display this help text - help - Display this help text
@ -549,7 +675,6 @@ Advanced Sigma Search Commands:
console.log(helpText); console.log(helpText);
} }
/** /**
* Start the CLI application * Start the CLI application
*/ */

View file

@ -25,13 +25,28 @@ const commandPatterns = [
}, },
// Sigma search patterns // Sigma search patterns
{ {
name: 'sigma-search', name: 'sigma-search-complex-1',
regex: /^(search|find)\s+(sigma\s+)?(rules|detections)?\s*(where|with)\s+(.+)$/i, regex: /^(search|find)\s+sigma\s+rules?\s*(where|with)\s+(.+)$/i,
action: 'complexSearch', action: 'complexSearch',
module: 'sigma', 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 // Sigma create patterns
{ {
name: 'sigma-create', name: 'sigma-create',

0
src/logo.js Normal file
View file

View file

@ -6,6 +6,7 @@
*/ */
const chalk = require('chalk'); const chalk = require('chalk');
/** /**
* Wraps text at specified length * Wraps text at specified length
* @param {string} text - Text to wrap * @param {string} text - Text to wrap
@ -146,16 +147,27 @@ function formatSigmaSearchResults(searchResults) {
// Return a structure with results and meta info // Return a structure with results and meta info
return { return {
results: searchResults.results.map(rule => ({ results: searchResults.results.map(rule => {
id: rule.id || '', // Get logsource.product field
title: wrapText(rule.title || '', 60), // Use narrower width for table columns let osProduct = 'N/A';
author: rule.author || 'Unknown',
level: rule.level || 'medium' // 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 totalCount: searchResults.totalCount || 0
}; };
} }
module.exports = { module.exports = {
formatSigmaStats, formatSigmaStats,
formatSigmaSearchResults, formatSigmaSearchResults,