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
*/
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
*/

View file

@ -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',

0
src/logo.js Normal file
View file

View file

@ -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,