Merge pull request 'searchCLI' (#5) from searchCLI into main
Reviewed-on: #5
This commit is contained in:
commit
964eaa8ae9
12 changed files with 1203 additions and 629 deletions
|
@ -1,614 +1,12 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* fylgja-cli.js
|
* fylgja-cli.js
|
||||||
*
|
*
|
||||||
* Interactive CLI interface
|
* Command-line executable entry point for Fylgja CLI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const readline = require('readline');
|
// Import the CLI starter function
|
||||||
// Import chalk with compatibility for both ESM and CommonJS
|
const { startCLI } = require('./fylgja-cli/cli');
|
||||||
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');
|
// Run the CLI application
|
||||||
const logger = require('./utils/logger');
|
|
||||||
const sigmaSearchHandler = require('./handlers/sigma/sigma_search_handler');
|
|
||||||
const sigmaDetailsHandler = require('./handlers/sigma/sigma_details_handler');
|
|
||||||
const sigmaStatsHandler = require('./handlers/sigma/sigma_stats_handler');
|
|
||||||
const sigmaCreateHandler = require('./handlers/sigma/sigma_create_handler');
|
|
||||||
const { handleCommand: handleAlerts } = require('./handlers/alerts/alerts_handler');
|
|
||||||
const { handleCommand: handleCase } = require('./handlers/case/case_handler');
|
|
||||||
const { handleCommand: handleConfig } = require('./handlers/config/config_handler');
|
|
||||||
const { handleCommand: handleStats } = require('./handlers/stats/stats_handler');
|
|
||||||
|
|
||||||
// Import CLI formatters
|
|
||||||
const {
|
|
||||||
formatSigmaStats,
|
|
||||||
formatSigmaSearchResults,
|
|
||||||
formatSigmaDetails
|
|
||||||
} = require('./utils/cli_formatters');
|
|
||||||
|
|
||||||
// Set logger to CLI mode (prevents console output)
|
|
||||||
logger.setCliMode(true);
|
|
||||||
|
|
||||||
// Try to get version, but provide fallback if package.json can't be found
|
|
||||||
let version = '1.0.0';
|
|
||||||
try {
|
|
||||||
const packageJson = require('../package.json');
|
|
||||||
version = packageJson.version;
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Could not load package.json, using default version');
|
|
||||||
}
|
|
||||||
|
|
||||||
const FILE_NAME = 'fylgja-cli.js';
|
|
||||||
|
|
||||||
// ASCII art logo for the CLI
|
|
||||||
const ASCII_LOGO = `
|
|
||||||
░▒▓████████▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░░▒▓██████▓▒░
|
|
||||||
░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
|
|
||||||
░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
|
|
||||||
░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░ ░▒▓█▓▒▒▓███▓▒░ ░▒▓█▓▒░▒▓████████▓▒░
|
|
||||||
░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
|
|
||||||
░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
|
|
||||||
░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Command history array
|
|
||||||
let commandHistory = [];
|
|
||||||
let historyIndex = -1;
|
|
||||||
|
|
||||||
// Create the readline interface
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
completer: completer,
|
|
||||||
prompt: 'fylgja> '
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Command auto-completion function
|
|
||||||
* @param {string} line Current command line input
|
|
||||||
* @returns {Array} Array with possible completions and the substring being completed
|
|
||||||
*/
|
|
||||||
function completer(line) {
|
|
||||||
const commands = [
|
|
||||||
'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',
|
|
||||||
'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
|
|
||||||
* @param {string} type The type of data (results, details, stats)
|
|
||||||
*/
|
|
||||||
function formatOutput(data, type) {
|
|
||||||
if (!data) {
|
|
||||||
console.log('No data returned from the server.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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':
|
|
||||||
// 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) {
|
|
||||||
// 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(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;
|
|
||||||
|
|
||||||
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 = key.padEnd(sigmaStatsMetricWidth - 2);
|
|
||||||
const formattedValue = String(value || '').padEnd(sigmaStatsValueWidth - 2);
|
|
||||||
|
|
||||||
console.log(`║ ${formattedKey} ║ ${formattedValue} ║`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(sigmaStatsFooterLine);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
console.log(JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse out any basic search keywords from a complexSearch query
|
|
||||||
* This helps with the search commands that don't quite match the expected format
|
|
||||||
* @param {string} input The complex search query
|
|
||||||
* @returns {string} Extracted keywords
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a command from the CLI
|
|
||||||
* @param {string} input User input command
|
|
||||||
*/
|
|
||||||
async function processCommand(input) {
|
|
||||||
try {
|
|
||||||
// Skip empty commands
|
|
||||||
if (!input.trim()) {
|
|
||||||
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,
|
|
||||||
user_id: 'cli_user',
|
|
||||||
user_name: 'cli_user',
|
|
||||||
command: '/fylgja',
|
|
||||||
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) {
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
user_id: 'cli_user',
|
|
||||||
user_name: 'cli_user',
|
|
||||||
command: '/fylgja',
|
|
||||||
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':
|
|
||||||
switch (action) {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error: ${error.message}`);
|
|
||||||
// Log to file but not console
|
|
||||||
logger.error(`${FILE_NAME}: Command execution error: ${error.message}`);
|
|
||||||
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
|
||||||
rl.prompt();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Fatal error: ${error.message}`);
|
|
||||||
// Log to file but not console
|
|
||||||
logger.error(`${FILE_NAME}: Fatal error: ${error.message}`);
|
|
||||||
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
|
||||||
rl.prompt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a 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) {
|
|
||||||
return async (response) => {
|
|
||||||
if (typeof response === 'string') {
|
|
||||||
console.log(response);
|
|
||||||
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');
|
|
||||||
} else if (action === 'details') {
|
|
||||||
formattedData = formatSigmaDetails(response.responseData);
|
|
||||||
formatOutput(formattedData, 'details');
|
|
||||||
} else if (action === 'stats') {
|
|
||||||
formattedData = formatSigmaStats(response.responseData);
|
|
||||||
formatOutput(formattedData, 'stats');
|
|
||||||
} else {
|
|
||||||
console.log(JSON.stringify(response.responseData, null, 2));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For other modules, just display the JSON
|
|
||||||
console.log(JSON.stringify(response.responseData, null, 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback for text-only responses
|
|
||||||
else if (response.text) {
|
|
||||||
console.log(response.text);
|
|
||||||
} else {
|
|
||||||
console.log('Command completed successfully.');
|
|
||||||
}
|
|
||||||
|
|
||||||
rl.prompt();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display help text
|
|
||||||
*/
|
|
||||||
function displayHelp() {
|
|
||||||
const helpText = `
|
|
||||||
Fylgja CLI Help
|
|
||||||
|
|
||||||
Basic Sigma Commands:
|
|
||||||
- search sigma <keyword> - Search for Sigma rules by keyword
|
|
||||||
- details sigma <rule_id> - Get details about a specific Sigma rule
|
|
||||||
- stats sigma - Get statistics about Sigma rules database
|
|
||||||
|
|
||||||
Advanced Sigma Search Commands:
|
|
||||||
- search sigma where title contains "ransomware" - Search by title
|
|
||||||
- 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
|
|
||||||
|
|
||||||
|
|
||||||
- exit or quit - Exit the CLI
|
|
||||||
- clear - Clear the terminal screen
|
|
||||||
- help - Display this help text
|
|
||||||
`;
|
|
||||||
|
|
||||||
console.log(helpText);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the CLI application
|
|
||||||
*/
|
|
||||||
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') {
|
|
||||||
// Don't output control characters for up/down arrows
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rl.output.write(stringToWrite);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up key listeners for history
|
|
||||||
rl.input.on('keypress', (char, key) => {
|
|
||||||
if (key && key.name === 'up') {
|
|
||||||
if (historyIndex > 0) {
|
|
||||||
historyIndex--;
|
|
||||||
rl.line = commandHistory[historyIndex];
|
|
||||||
rl.cursor = rl.line.length;
|
|
||||||
rl._refreshLine();
|
|
||||||
}
|
|
||||||
} else if (key && key.name === 'down') {
|
|
||||||
if (historyIndex < commandHistory.length - 1) {
|
|
||||||
historyIndex++;
|
|
||||||
rl.line = commandHistory[historyIndex];
|
|
||||||
rl.cursor = rl.line.length;
|
|
||||||
rl._refreshLine();
|
|
||||||
} else if (historyIndex === commandHistory.length - 1) {
|
|
||||||
historyIndex = commandHistory.length;
|
|
||||||
rl.line = '';
|
|
||||||
rl.cursor = 0;
|
|
||||||
rl._refreshLine();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
rl.prompt();
|
|
||||||
|
|
||||||
rl.on('line', async (line) => {
|
|
||||||
await processCommand(line.trim());
|
|
||||||
});
|
|
||||||
|
|
||||||
rl.on('close', () => {
|
|
||||||
console.log('Goodbye!');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if running directly
|
|
||||||
if (require.main === module) {
|
|
||||||
startCLI();
|
startCLI();
|
||||||
} else {
|
|
||||||
// Export functions for integration with main app
|
|
||||||
module.exports = {
|
|
||||||
startCLI
|
|
||||||
};
|
|
||||||
}
|
|
438
src/fylgja-cli/cli.js
Normal file
438
src/fylgja-cli/cli.js
Normal file
|
@ -0,0 +1,438 @@
|
||||||
|
/**
|
||||||
|
* cli.js
|
||||||
|
*
|
||||||
|
* Interactive CLI interface
|
||||||
|
*/
|
||||||
|
const readline = require('readline');
|
||||||
|
const { parseCommand } = require('../lang/command_parser');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { generateGradientLogo } = require('./utils/cli_logo');
|
||||||
|
const outputManager = require('./cli_output_manager');
|
||||||
|
|
||||||
|
// Import command handlers
|
||||||
|
const sigmaSearchHandler = require('../handlers/sigma/sigma_search_handler');
|
||||||
|
const sigmaDetailsHandler = require('../handlers/sigma/sigma_details_handler');
|
||||||
|
const sigmaStatsHandler = require('../handlers/sigma/sigma_stats_handler');
|
||||||
|
const sigmaCreateHandler = require('../handlers/sigma/sigma_create_handler');
|
||||||
|
const { handleCommand: handleAlerts } = require('../handlers/alerts/alerts_handler');
|
||||||
|
const { handleCommand: handleCase } = require('../handlers/case/case_handler');
|
||||||
|
const { handleCommand: handleConfig } = require('../handlers/config/config_handler');
|
||||||
|
const { handleCommand: handleStats } = require('../handlers/stats/stats_handler');
|
||||||
|
|
||||||
|
// Set constants
|
||||||
|
const FILE_NAME = 'cli.js';
|
||||||
|
|
||||||
|
// Try to get version, but provide fallback if package.json can't be found
|
||||||
|
let version = '1.0.0';
|
||||||
|
try {
|
||||||
|
const packageJson = require('../package.json');
|
||||||
|
version = packageJson.version;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Could not load package.json, using default version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command history management
|
||||||
|
let commandHistory = [];
|
||||||
|
let historyIndex = -1;
|
||||||
|
|
||||||
|
// Create the readline interface
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
completer: completer,
|
||||||
|
prompt: 'fylgja> '
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command auto-completion function
|
||||||
|
* @param {string} line Current command line input
|
||||||
|
* @returns {Array} Array with possible completions and the substring being completed
|
||||||
|
*/
|
||||||
|
function completer(line) {
|
||||||
|
const commands = [
|
||||||
|
'search sigma',
|
||||||
|
'details sigma',
|
||||||
|
'stats sigma',
|
||||||
|
'search sigma rules where title contains',
|
||||||
|
'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',
|
||||||
|
'clear'
|
||||||
|
];
|
||||||
|
|
||||||
|
const hits = commands.filter((c) => c.startsWith(line));
|
||||||
|
return [hits.length ? hits : commands, line];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse out any basic search keywords from a complexSearch query
|
||||||
|
* @param {string} input The complex search query
|
||||||
|
* @returns {string} Extracted keywords
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a custom respond function for handlers
|
||||||
|
* @param {string} action - The action being performed
|
||||||
|
* @param {string} module - The module handling the action
|
||||||
|
* @param {Array} params - The parameters for the action
|
||||||
|
* @returns {Function} A respond function for handler callbacks
|
||||||
|
*/
|
||||||
|
function createRespondFunction(action, module, params) {
|
||||||
|
// Keep track of whether we're waiting for results
|
||||||
|
let isWaitingForResults = false;
|
||||||
|
|
||||||
|
return async (response) => {
|
||||||
|
const isProgressMessage =
|
||||||
|
typeof response === 'object' &&
|
||||||
|
response.text &&
|
||||||
|
!response.responseData &&
|
||||||
|
(response.text.includes('moment') ||
|
||||||
|
response.text.includes('searching') ||
|
||||||
|
response.text.includes('processing'));
|
||||||
|
|
||||||
|
if (isProgressMessage) {
|
||||||
|
outputManager.displayProgress(response.text);
|
||||||
|
isWaitingForResults = true;
|
||||||
|
return; // Don't show prompt after progress messages
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof response === 'string') {
|
||||||
|
console.log(response);
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for the responseData property (directly from service)
|
||||||
|
if (response.responseData) {
|
||||||
|
if (module === 'sigma') {
|
||||||
|
if (action === 'search' || action === 'complexSearch') {
|
||||||
|
// Convert array response to expected format if needed
|
||||||
|
let dataToFormat = response.responseData;
|
||||||
|
|
||||||
|
// Wrap responseData array in proper structure
|
||||||
|
if (Array.isArray(dataToFormat)) {
|
||||||
|
dataToFormat = {
|
||||||
|
results: dataToFormat,
|
||||||
|
totalCount: dataToFormat.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
outputManager.display(dataToFormat, 'search_results');
|
||||||
|
} else if (action === 'details') {
|
||||||
|
outputManager.display(response.responseData, 'details');
|
||||||
|
} else if (action === 'stats') {
|
||||||
|
outputManager.display(response.responseData, 'stats');
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify(response.responseData, null, 2));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other modules, just display the JSON
|
||||||
|
console.log(JSON.stringify(response.responseData, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback for text-only responses
|
||||||
|
else if (response.text) {
|
||||||
|
console.log(response.text);
|
||||||
|
} else {
|
||||||
|
outputManager.displaySuccess('Command completed successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset waiting state and show prompt after results
|
||||||
|
isWaitingForResults = false;
|
||||||
|
rl.prompt();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a command from the CLI
|
||||||
|
* @param {string} input User input command
|
||||||
|
*/
|
||||||
|
async function processCommand(input) {
|
||||||
|
try {
|
||||||
|
// Skip empty commands
|
||||||
|
if (!input.trim()) {
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special CLI commands
|
||||||
|
if (input.trim().toLowerCase() === 'exit' || input.trim().toLowerCase() === 'quit') {
|
||||||
|
outputManager.displaySuccess('Goodbye!');
|
||||||
|
rl.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.trim().toLowerCase() === 'clear') {
|
||||||
|
console.clear();
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.trim().toLowerCase() === 'help') {
|
||||||
|
outputManager.displayHelp();
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to command history
|
||||||
|
commandHistory.push(input);
|
||||||
|
historyIndex = commandHistory.length;
|
||||||
|
|
||||||
|
// Special case for simple search
|
||||||
|
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];
|
||||||
|
|
||||||
|
// Create fake command object
|
||||||
|
const command = {
|
||||||
|
text: keyword,
|
||||||
|
user_id: 'cli_user',
|
||||||
|
user_name: 'cli_user',
|
||||||
|
command: '/fylgja',
|
||||||
|
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) {
|
||||||
|
outputManager.displayError(error.message);
|
||||||
|
logger.error(`${FILE_NAME}: Command execution error: ${error.message}`);
|
||||||
|
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
||||||
|
rl.prompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
outputManager.displayError(error.message);
|
||||||
|
logger.error(`${FILE_NAME}: Command execution error: ${error.message}`);
|
||||||
|
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
||||||
|
rl.prompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command using existing parser
|
||||||
|
const parsedCommand = await parseCommand(input);
|
||||||
|
|
||||||
|
if (!parsedCommand.success) {
|
||||||
|
outputManager.displayWarning(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,
|
||||||
|
user_id: 'cli_user',
|
||||||
|
user_name: 'cli_user',
|
||||||
|
command: '/fylgja',
|
||||||
|
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':
|
||||||
|
switch (action) {
|
||||||
|
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:
|
||||||
|
outputManager.displayWarning(`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;
|
||||||
|
|
||||||
|
default:
|
||||||
|
outputManager.displayWarning(`Unknown module: ${module}`);
|
||||||
|
rl.prompt();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
outputManager.displayError(error.message);
|
||||||
|
// Log to file but not console
|
||||||
|
logger.error(`${FILE_NAME}: Command execution error: ${error.message}`);
|
||||||
|
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
||||||
|
rl.prompt();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
outputManager.displayError(`Fatal error: ${error.message}`);
|
||||||
|
// Log to file but not console
|
||||||
|
logger.error(`${FILE_NAME}: Fatal error: ${error.message}`);
|
||||||
|
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
||||||
|
rl.prompt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the CLI
|
||||||
|
*/
|
||||||
|
function startCLI() {
|
||||||
|
console.log(generateGradientLogo());
|
||||||
|
console.log(`Fylgja CLI v${version} - Interactive SIEM Management Tool`);
|
||||||
|
console.log(`Type 'help' for usage information or 'exit' to quit\n`);
|
||||||
|
|
||||||
|
// Set logger to CLI mode (prevents console output)
|
||||||
|
logger.setCliMode(true);
|
||||||
|
|
||||||
|
// Set up key bindings for history navigation
|
||||||
|
rl._writeToOutput = function _writeToOutput(stringToWrite) {
|
||||||
|
if (stringToWrite === '\\u001b[A' || stringToWrite === '\\u001b[B') {
|
||||||
|
// Don't output control characters for up/down arrows
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rl.output.write(stringToWrite);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up key listeners for history
|
||||||
|
rl.input.on('keypress', (char, key) => {
|
||||||
|
if (key && key.name === 'up') {
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
historyIndex--;
|
||||||
|
rl.line = commandHistory[historyIndex];
|
||||||
|
rl.cursor = rl.line.length;
|
||||||
|
rl._refreshLine();
|
||||||
|
}
|
||||||
|
} else if (key && key.name === 'down') {
|
||||||
|
if (historyIndex < commandHistory.length - 1) {
|
||||||
|
historyIndex++;
|
||||||
|
rl.line = commandHistory[historyIndex];
|
||||||
|
rl.cursor = rl.line.length;
|
||||||
|
rl._refreshLine();
|
||||||
|
} else if (historyIndex === commandHistory.length - 1) {
|
||||||
|
historyIndex = commandHistory.length;
|
||||||
|
rl.line = '';
|
||||||
|
rl.cursor = 0;
|
||||||
|
rl._refreshLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.prompt();
|
||||||
|
|
||||||
|
rl.on('line', async (line) => {
|
||||||
|
await processCommand(line.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
outputManager.displaySuccess('Goodbye!');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if running directly
|
||||||
|
if (require.main === module) {
|
||||||
|
startCLI();
|
||||||
|
} else {
|
||||||
|
// Export functions for integration with main app
|
||||||
|
module.exports = {
|
||||||
|
startCLI
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
123
src/fylgja-cli/cli_output_manager.js
Normal file
123
src/fylgja-cli/cli_output_manager.js
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
/**
|
||||||
|
* cli_output_manager.js
|
||||||
|
*
|
||||||
|
* Centralized manager for CLI output formatting and display
|
||||||
|
*/
|
||||||
|
const colors = require('./utils/colors');
|
||||||
|
const formatters = require('./formatters');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI Output Manager
|
||||||
|
* Handles all CLI output formatting and display
|
||||||
|
*/
|
||||||
|
class CliOutputManager {
|
||||||
|
/**
|
||||||
|
* Create a new CLI Output Manager
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.colors = colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display formatted output based on data type
|
||||||
|
*
|
||||||
|
* @param {any} data - Data to format and display
|
||||||
|
* @param {string} type - Type of data (search_results, details, stats)
|
||||||
|
*/
|
||||||
|
display(data, type) {
|
||||||
|
if (!data) {
|
||||||
|
console.log('No data returned from the server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(); // Empty line for spacing
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'search_results':
|
||||||
|
const formattedResults = formatters.formatSigmaSearchResults(data);
|
||||||
|
console.log(formatters.renderSigmaSearchResults(formattedResults));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'details':
|
||||||
|
const formattedDetails = formatters.formatSigmaDetails(data);
|
||||||
|
console.log(formatters.renderSigmaDetails(formattedDetails));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stats':
|
||||||
|
const formattedStats = formatters.formatSigmaStats(data);
|
||||||
|
console.log(formatters.renderSigmaStats(formattedStats));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// For unknown types, just display JSON
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a progress message
|
||||||
|
*
|
||||||
|
* @param {string} message - Progress message to display
|
||||||
|
*/
|
||||||
|
displayProgress(message) {
|
||||||
|
console.log(this.colors.dim(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display an error message
|
||||||
|
*
|
||||||
|
* @param {string} message - Error message to display
|
||||||
|
*/
|
||||||
|
displayError(message) {
|
||||||
|
console.error(this.colors.error(`Error: ${message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a success message
|
||||||
|
*
|
||||||
|
* @param {string} message - Success message to display
|
||||||
|
*/
|
||||||
|
displaySuccess(message) {
|
||||||
|
console.log(this.colors.success(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a warning message
|
||||||
|
*
|
||||||
|
* @param {string} message - Warning message to display
|
||||||
|
*/
|
||||||
|
displayWarning(message) {
|
||||||
|
console.log(this.colors.warning(`Warning: ${message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display help text
|
||||||
|
*/
|
||||||
|
displayHelp() {
|
||||||
|
const helpText = `
|
||||||
|
Fylgja CLI Help
|
||||||
|
|
||||||
|
Basic Sigma Commands:
|
||||||
|
- search sigma <keyword> - Search for Sigma rules by keyword
|
||||||
|
- details sigma <rule_id> - Get details about a specific Sigma rule
|
||||||
|
- stats sigma - Get statistics about Sigma rules database
|
||||||
|
|
||||||
|
Advanced Sigma Search Commands:
|
||||||
|
- search sigma where title contains "ransomware" - Search by title
|
||||||
|
- 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
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log(helpText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CliOutputManager();
|
36
src/fylgja-cli/formatters/index.js
Normal file
36
src/fylgja-cli/formatters/index.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* formatters/index.js
|
||||||
|
*
|
||||||
|
* Exports all formatters for easy importing
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Text and table formatters
|
||||||
|
const textFormatter = require('./text_formatter');
|
||||||
|
const tableFormatter = require('./table_formatter');
|
||||||
|
|
||||||
|
// Domain-specific formatters
|
||||||
|
const sigmaFormatter = require('./sigma_formatter');
|
||||||
|
|
||||||
|
// Re-export all formatters
|
||||||
|
module.exports = {
|
||||||
|
// Text utilities
|
||||||
|
wrapText: textFormatter.wrapText,
|
||||||
|
formatDate: textFormatter.formatDate,
|
||||||
|
formatNumber: textFormatter.formatNumber,
|
||||||
|
|
||||||
|
// Table utilities
|
||||||
|
formatTable: tableFormatter.formatTable,
|
||||||
|
formatKeyValueTable: tableFormatter.formatKeyValueTable,
|
||||||
|
formatTableHeader: tableFormatter.formatTableHeader,
|
||||||
|
formatTableRow: tableFormatter.formatTableRow,
|
||||||
|
|
||||||
|
// Sigma formatters
|
||||||
|
formatSigmaDetails: sigmaFormatter.formatSigmaDetails,
|
||||||
|
formatSigmaStats: sigmaFormatter.formatSigmaStats,
|
||||||
|
formatSigmaSearchResults: sigmaFormatter.formatSigmaSearchResults,
|
||||||
|
|
||||||
|
// Rendering functions for CLI output
|
||||||
|
renderSigmaDetails: sigmaFormatter.renderSigmaDetails,
|
||||||
|
renderSigmaStats: sigmaFormatter.renderSigmaStats,
|
||||||
|
renderSigmaSearchResults: sigmaFormatter.renderSigmaSearchResults
|
||||||
|
};
|
196
src/fylgja-cli/formatters/sigma_formatter.js
Normal file
196
src/fylgja-cli/formatters/sigma_formatter.js
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
/**
|
||||||
|
* sigma_formatter.js
|
||||||
|
*
|
||||||
|
* Specialized formatters for Sigma rule data
|
||||||
|
*/
|
||||||
|
const { wrapText, formatDate, formatNumber } = require('./text_formatter');
|
||||||
|
const { formatKeyValueTable, formatTable } = require('./table_formatter');
|
||||||
|
const colors = require('../utils/colors');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Sigma rule details for CLI display
|
||||||
|
*
|
||||||
|
* @param {Object} ruleDetails - The rule details object
|
||||||
|
* @returns {Object} Formatted rule details
|
||||||
|
*/
|
||||||
|
function formatSigmaDetails(ruleDetails) {
|
||||||
|
if (!ruleDetails) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a flattened object for display in CLI table format
|
||||||
|
return {
|
||||||
|
'ID': ruleDetails.id || 'Unknown',
|
||||||
|
'Title': ruleDetails.title || 'Untitled Rule',
|
||||||
|
'Description': ruleDetails.description || 'No description provided',
|
||||||
|
'Author': ruleDetails.author || 'Unknown author',
|
||||||
|
'Severity': ruleDetails.severity || 'Unknown',
|
||||||
|
'Status': ruleDetails.status || 'Unknown',
|
||||||
|
'Created': formatDate(ruleDetails.date),
|
||||||
|
'Modified': formatDate(ruleDetails.modified),
|
||||||
|
'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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Sigma statistics for CLI display
|
||||||
|
*
|
||||||
|
* @param {Object} stats - The statistics object
|
||||||
|
* @returns {Object} Formatted stats
|
||||||
|
*/
|
||||||
|
function formatSigmaStats(stats) {
|
||||||
|
if (!stats) {
|
||||||
|
return { error: 'No statistics data available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a simplified object suitable for table display
|
||||||
|
const formattedStats = {
|
||||||
|
'Last Update': formatDate(stats.lastUpdate),
|
||||||
|
'Total Rules': formatNumber(stats.totalRules),
|
||||||
|
'Database Health': `${stats.databaseHealth.contentPercentage}% Complete`,
|
||||||
|
|
||||||
|
// OS breakdown
|
||||||
|
'Windows Rules': formatNumber(stats.operatingSystems.windows),
|
||||||
|
'Linux Rules': formatNumber(stats.operatingSystems.linux),
|
||||||
|
'macOS Rules': formatNumber(stats.operatingSystems.macos),
|
||||||
|
'Other OS Rules': formatNumber(stats.operatingSystems.other),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add severity levels
|
||||||
|
if (stats.severityLevels && Array.isArray(stats.severityLevels)) {
|
||||||
|
stats.severityLevels.forEach(level => {
|
||||||
|
const levelName = level.level
|
||||||
|
? level.level.charAt(0).toUpperCase() + level.level.slice(1)
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
formattedStats[`${levelName} Severity`] = formatNumber(level.count);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add top MITRE tactics if available
|
||||||
|
if (stats.mitreTactics && Array.isArray(stats.mitreTactics)) {
|
||||||
|
stats.mitreTactics.slice(0, 5).forEach(tactic => { // Only top 5
|
||||||
|
const formattedTactic = tactic.tactic
|
||||||
|
.replace(/-/g, ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
formattedStats[`MITRE: ${formattedTactic}`] = formatNumber(tactic.count);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Sigma search results for CLI display
|
||||||
|
*
|
||||||
|
* @param {Object} searchResults - The search results object
|
||||||
|
* @returns {Object} Formatted search results
|
||||||
|
*/
|
||||||
|
function formatSigmaSearchResults(searchResults) {
|
||||||
|
if (!searchResults || !searchResults.results) {
|
||||||
|
return { error: 'No search results available' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map raw results to formatted results
|
||||||
|
const formattedResults = searchResults.results.map(rule => {
|
||||||
|
// Get logsource.product field
|
||||||
|
let osProduct = 'N/A';
|
||||||
|
|
||||||
|
if (rule.logsource && rule.logsource.product) {
|
||||||
|
osProduct = rule.logsource.product;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rule.id || '',
|
||||||
|
title: rule.title || '',
|
||||||
|
author: rule.author || 'Unknown',
|
||||||
|
level: rule.level || 'medium',
|
||||||
|
osProduct: osProduct,
|
||||||
|
tags: rule.tags || []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: formattedResults,
|
||||||
|
totalCount: searchResults.totalCount || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render formatted Sigma details as a string
|
||||||
|
*
|
||||||
|
* @param {Object} formattedDetails - Formatted details object
|
||||||
|
* @returns {string} CLI-ready output
|
||||||
|
*/
|
||||||
|
function renderSigmaDetails(formattedDetails) {
|
||||||
|
if (!formattedDetails) {
|
||||||
|
return 'No details available';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatKeyValueTable(formattedDetails, 24, 50).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render formatted Sigma statistics as a string
|
||||||
|
*
|
||||||
|
* @param {Object} formattedStats - Formatted statistics object
|
||||||
|
* @returns {string} CLI-ready output
|
||||||
|
*/
|
||||||
|
function renderSigmaStats(formattedStats) {
|
||||||
|
if (!formattedStats) {
|
||||||
|
return 'No statistics available';
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatKeyValueTable(formattedStats, 25, 30, colors.statsKey).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render formatted Sigma search results as a string
|
||||||
|
*
|
||||||
|
* @param {Object} formattedResults - Formatted search results object
|
||||||
|
* @returns {string} CLI-ready output
|
||||||
|
*/
|
||||||
|
function renderSigmaSearchResults(formattedResults) {
|
||||||
|
if (!formattedResults || formattedResults.error) {
|
||||||
|
return formattedResults.error || 'No search results available';
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = ['#', 'Title', 'OS/Product', 'ID'];
|
||||||
|
const widths = [5, 32, 15, 13];
|
||||||
|
|
||||||
|
// Create rows from results
|
||||||
|
const rows = formattedResults.results.map((rule, index) => {
|
||||||
|
return [
|
||||||
|
(index + 1).toString(),
|
||||||
|
rule.title.substring(0, 31), // Trim to fit column width
|
||||||
|
rule.osProduct.substring(0, 14),
|
||||||
|
rule.id.substring(0, 12)
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate table
|
||||||
|
const tableLines = formatTable(headers, rows, widths);
|
||||||
|
|
||||||
|
// Add count footer
|
||||||
|
const countLine = colors.count(`${formattedResults.totalCount} rows in set`);
|
||||||
|
|
||||||
|
return [...tableLines, '', countLine].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
formatSigmaDetails,
|
||||||
|
formatSigmaStats,
|
||||||
|
formatSigmaSearchResults,
|
||||||
|
renderSigmaDetails,
|
||||||
|
renderSigmaStats,
|
||||||
|
renderSigmaSearchResults
|
||||||
|
};
|
97
src/fylgja-cli/formatters/table_formatter.js
Normal file
97
src/fylgja-cli/formatters/table_formatter.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
/**
|
||||||
|
* table_formatter.js
|
||||||
|
*
|
||||||
|
* Table formatting utilities for CLI output
|
||||||
|
*/
|
||||||
|
const colors = require('../utils/colors');
|
||||||
|
const { wrapText } = require('./text_formatter');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a formatted table row with fixed column widths
|
||||||
|
*
|
||||||
|
* @param {string[]} columns - Array of column values
|
||||||
|
* @param {number[]} widths - Array of column widths
|
||||||
|
* @param {Function[]} formatters - Array of formatting functions for each column (optional)
|
||||||
|
* @returns {string} Formatted table row
|
||||||
|
*/
|
||||||
|
function formatTableRow(columns, widths, formatters = []) {
|
||||||
|
return columns.map((value, index) => {
|
||||||
|
const formatter = formatters[index] || (text => text);
|
||||||
|
return formatter(String(value || '').padEnd(widths[index]));
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a header row for a table
|
||||||
|
*
|
||||||
|
* @param {string[]} headers - Array of header texts
|
||||||
|
* @param {number[]} widths - Array of column widths
|
||||||
|
* @returns {string} Formatted header row
|
||||||
|
*/
|
||||||
|
function formatTableHeader(headers, widths) {
|
||||||
|
return formatTableRow(headers, widths, headers.map(() => colors.header));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats data as a key-value table
|
||||||
|
*
|
||||||
|
* @param {Object} data - Object with key-value pairs to display
|
||||||
|
* @param {number} keyWidth - Width of the key column
|
||||||
|
* @param {number} valueWidth - Width of the value column
|
||||||
|
* @param {Function} keyFormatter - Formatting function for keys
|
||||||
|
* @returns {string[]} Array of formatted lines
|
||||||
|
*/
|
||||||
|
function formatKeyValueTable(data, keyWidth = 25, valueWidth = 50, keyFormatter = colors.key) {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
const formattedKey = keyFormatter(key.padEnd(keyWidth));
|
||||||
|
|
||||||
|
// Handle wrapping for multiline values
|
||||||
|
const wrappedLines = wrapText(value, valueWidth);
|
||||||
|
|
||||||
|
// First line includes the key
|
||||||
|
lines.push(`${formattedKey}${wrappedLines[0] || ''}`);
|
||||||
|
|
||||||
|
// Additional lines are indented to align with the value column
|
||||||
|
for (let i = 1; i < wrappedLines.length; i++) {
|
||||||
|
lines.push(`${' '.repeat(keyWidth)}${wrappedLines[i]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a full table with headers and rows
|
||||||
|
*
|
||||||
|
* @param {string[]} headers - Table headers
|
||||||
|
* @param {Array<Array<string>>} rows - Table data rows
|
||||||
|
* @param {number[]} widths - Column widths
|
||||||
|
* @returns {string[]} Array of formatted table lines
|
||||||
|
*/
|
||||||
|
function formatTable(headers, rows, widths) {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
// Add header
|
||||||
|
lines.push(formatTableHeader(headers, widths));
|
||||||
|
|
||||||
|
// Add separator line (optional)
|
||||||
|
// lines.push(widths.map(w => '-'.repeat(w)).join(''));
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
rows.forEach(row => {
|
||||||
|
lines.push(formatTableRow(row, widths));
|
||||||
|
});
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
formatTableRow,
|
||||||
|
formatTableHeader,
|
||||||
|
formatKeyValueTable,
|
||||||
|
formatTable
|
||||||
|
};
|
97
src/fylgja-cli/formatters/text_formatter.js
Normal file
97
src/fylgja-cli/formatters/text_formatter.js
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
/**
|
||||||
|
* text_formatter.js
|
||||||
|
*
|
||||||
|
* Common text formatting utilities for CLI output
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps text at specified length, handling different data types
|
||||||
|
*
|
||||||
|
* @param {any} text - Text to wrap (will be converted to string)
|
||||||
|
* @param {number} maxWidth - Maximum line width
|
||||||
|
* @returns {string[]} Array of wrapped lines
|
||||||
|
*/
|
||||||
|
function wrapText(text, maxWidth = 80) {
|
||||||
|
// Handle non-string or empty inputs
|
||||||
|
if (text === undefined || text === null) return [''];
|
||||||
|
|
||||||
|
// Convert to string and normalize newlines
|
||||||
|
const normalizedText = String(text).replace(/\n/g, ' ');
|
||||||
|
|
||||||
|
// If text fits in one line, return it as a single-element array
|
||||||
|
if (normalizedText.length <= maxWidth) return [normalizedText];
|
||||||
|
|
||||||
|
const words = normalizedText.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date string for display
|
||||||
|
*
|
||||||
|
* @param {string} dateString - Date string to format
|
||||||
|
* @returns {string} Formatted date string
|
||||||
|
*/
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return 'Unknown';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch (error) {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a number with locale-specific thousands separators
|
||||||
|
*
|
||||||
|
* @param {number} num - Number to format
|
||||||
|
* @returns {string} Formatted number string
|
||||||
|
*/
|
||||||
|
function formatNumber(num) {
|
||||||
|
if (typeof num !== 'number') return String(num || '');
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
wrapText,
|
||||||
|
formatDate,
|
||||||
|
formatNumber
|
||||||
|
};
|
105
src/fylgja-cli/utils/cli_logo.js
Normal file
105
src/fylgja-cli/utils/cli_logo.js
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* cli_logo.js
|
||||||
|
*
|
||||||
|
* Gradient-colored ASCII logo for CLI
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Define the ASCII logo
|
||||||
|
const logoLines = [
|
||||||
|
'░▒▓████████▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░░▒▓██████▓▒░ ',
|
||||||
|
'░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ',
|
||||||
|
'░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ',
|
||||||
|
'░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░ ░▒▓█▓▒▒▓███▓▒░ ░▒▓█▓▒░▒▓████████▓▒░ ',
|
||||||
|
'░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ',
|
||||||
|
'░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░ ',
|
||||||
|
'░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░ '
|
||||||
|
];
|
||||||
|
|
||||||
|
// Colors (hex codes without # prefix)
|
||||||
|
const colors = {
|
||||||
|
teal: '7DE2D1',
|
||||||
|
darkTeal: '339989',
|
||||||
|
offWhite: 'FFFAFB',
|
||||||
|
lavender: '8F95D3'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert hex to RGB
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear interpolation between two colors
|
||||||
|
function interpolateColor(color1, color2, factor) {
|
||||||
|
return {
|
||||||
|
r: Math.round(color1.r + factor * (color2.r - color1.r)),
|
||||||
|
g: Math.round(color1.g + factor * (color2.g - color1.g)),
|
||||||
|
b: Math.round(color1.b + factor * (color2.b - color1.b))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ANSI true color escape code
|
||||||
|
function getTrueColorCode(r, g, b) {
|
||||||
|
return `\x1b[38;2;${r};${g};${b}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ANSI 256-color escape code (fallback for terminals without true color support)
|
||||||
|
function get256ColorCode(r, g, b) {
|
||||||
|
// Convert RGB to approximate 256-color code
|
||||||
|
const ansi256 = 16 +
|
||||||
|
36 * Math.round(r * 5 / 255) +
|
||||||
|
6 * Math.round(g * 5 / 255) +
|
||||||
|
Math.round(b * 5 / 255);
|
||||||
|
|
||||||
|
return `\x1b[38;5;${ansi256}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a 2D gradient (both horizontal and vertical gradients combined)
|
||||||
|
* This creates the most dramatic effect but is more processing-intensive
|
||||||
|
* @returns {string} The gradient-colored logo
|
||||||
|
*/
|
||||||
|
function generateGradientLogo() {
|
||||||
|
// Convert hex colors to RGB (corners of the 2D gradient)
|
||||||
|
const topLeft = hexToRgb(colors.teal);
|
||||||
|
const topRight = hexToRgb(colors.darkTeal);
|
||||||
|
const bottomLeft = hexToRgb(colors.offWhite);
|
||||||
|
const bottomRight = hexToRgb(colors.lavender);
|
||||||
|
|
||||||
|
// Initialize gradient logo
|
||||||
|
let gradientLogo = '\n';
|
||||||
|
|
||||||
|
// Process each line
|
||||||
|
for (let y = 0; y < logoLines.length; y++) {
|
||||||
|
const line = logoLines[y];
|
||||||
|
const verticalPosition = y / (logoLines.length - 1);
|
||||||
|
|
||||||
|
// Interpolate top and bottom colors
|
||||||
|
const leftColor = interpolateColor(topLeft, bottomLeft, verticalPosition);
|
||||||
|
const rightColor = interpolateColor(topRight, bottomRight, verticalPosition);
|
||||||
|
|
||||||
|
// Process each character in the line
|
||||||
|
for (let x = 0; x < line.length; x++) {
|
||||||
|
const char = line[x];
|
||||||
|
const horizontalPosition = x / (line.length - 1);
|
||||||
|
|
||||||
|
// Interpolate between left and right colors
|
||||||
|
const color = interpolateColor(leftColor, rightColor, horizontalPosition);
|
||||||
|
|
||||||
|
// Apply the color and add the character
|
||||||
|
gradientLogo += getTrueColorCode(color.r, color.g, color.b) + char;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset color and add newline
|
||||||
|
gradientLogo += '\x1b[0m\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
return gradientLogo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the gradient logo functions
|
||||||
|
module.exports = {
|
||||||
|
generateGradientLogo,
|
||||||
|
};
|
63
src/fylgja-cli/utils/colors.js
Normal file
63
src/fylgja-cli/utils/colors.js
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* colors.js
|
||||||
|
*
|
||||||
|
* Centralized color definitions and formatting functions for CLI output
|
||||||
|
* Using direct ANSI color codes instead of chalk
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Define ANSI color codes
|
||||||
|
const ansiCodes = {
|
||||||
|
// Text formatting
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bold: '\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 color formatting functions
|
||||||
|
const colors = {
|
||||||
|
// Basic formatting functions
|
||||||
|
blue: (text) => `${ansiCodes.blue}${text}${ansiCodes.reset}`,
|
||||||
|
green: (text) => `${ansiCodes.green}${text}${ansiCodes.reset}`,
|
||||||
|
red: (text) => `${ansiCodes.red}${text}${ansiCodes.reset}`,
|
||||||
|
yellow: (text) => `${ansiCodes.yellow}${text}${ansiCodes.reset}`,
|
||||||
|
cyan: (text) => `${ansiCodes.cyan}${text}${ansiCodes.reset}`,
|
||||||
|
white: (text) => `${ansiCodes.white}${text}${ansiCodes.reset}`,
|
||||||
|
dim: (text) => `${ansiCodes.dim}${text}${ansiCodes.reset}`,
|
||||||
|
bold: (text) => `${ansiCodes.bold}${text}${ansiCodes.reset}`,
|
||||||
|
|
||||||
|
// Composite styles for specific UI elements
|
||||||
|
header: (text) => `${ansiCodes.cyan}${ansiCodes.bold}${text}${ansiCodes.reset}`,
|
||||||
|
key: (text) => `${ansiCodes.blue}${text}${ansiCodes.reset}`,
|
||||||
|
statsKey: (text) => `${ansiCodes.yellow}${text}${ansiCodes.reset}`,
|
||||||
|
value: (text) => `${ansiCodes.white}${text}${ansiCodes.reset}`,
|
||||||
|
count: (text) => `${ansiCodes.green}${text}${ansiCodes.reset}`,
|
||||||
|
error: (text) => `${ansiCodes.red}${text}${ansiCodes.reset}`,
|
||||||
|
warning: (text) => `${ansiCodes.yellow}${text}${ansiCodes.reset}`,
|
||||||
|
success: (text) => `${ansiCodes.green}${ansiCodes.bold}${text}${ansiCodes.reset}`
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = colors;
|
|
@ -147,6 +147,7 @@ const handleCommand = async (command, respond) => {
|
||||||
// Respond with the search results
|
// Respond with the search results
|
||||||
await respond({
|
await respond({
|
||||||
blocks: blocks,
|
blocks: blocks,
|
||||||
|
responseData: searchResult.results,
|
||||||
response_type: isEphemeral ? 'ephemeral' : 'in_channel'
|
response_type: isEphemeral ? 'ephemeral' : 'in_channel'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -271,6 +272,7 @@ const handleComplexSearch = async (command, respond) => {
|
||||||
// Respond with the search results
|
// Respond with the search results
|
||||||
await respond({
|
await respond({
|
||||||
blocks: blocks,
|
blocks: blocks,
|
||||||
|
responseData: searchResult.results,
|
||||||
response_type: 'ephemeral' // Complex searches are usually more specific to the user
|
response_type: 'ephemeral' // Complex searches are usually more specific to the user
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
const commandPatterns = [
|
const commandPatterns = [
|
||||||
// Sigma details patterns
|
// Sigma details patterns
|
||||||
{
|
{
|
||||||
name: 'sigma-details',
|
name: 'details-sigma',
|
||||||
regex: /^details\s+sigma\s+(.+)$/i,
|
regex: /^details\s+sigma\s+(.+)$/i,
|
||||||
action: 'details',
|
action: 'details',
|
||||||
module: 'sigma',
|
module: 'sigma',
|
||||||
|
@ -25,13 +25,28 @@ const commandPatterns = [
|
||||||
},
|
},
|
||||||
// Sigma search patterns
|
// Sigma search patterns
|
||||||
{
|
{
|
||||||
name: 'sigma-search',
|
name: 'search-sigma-complex-1',
|
||||||
regex: /^(search|find)\s+(sigma\s+)?(rules|detections)?\s*(where|with)\s+(.+)$/i,
|
regex: /^search\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: 'search-sigma-complex-2',
|
||||||
|
regex: /^search\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: 'search-sigma-simple',
|
||||||
|
regex: /^search\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',
|
||||||
|
@ -41,16 +56,8 @@ const commandPatterns = [
|
||||||
params: [2] // rule ID is in capturing group 2
|
params: [2] // rule ID is in capturing group 2
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sigma stats patterns
|
|
||||||
{
|
{
|
||||||
name: 'sigma-stats-first',
|
name: 'stats-sigma',
|
||||||
regex: /^sigma\s+stats$/i,
|
|
||||||
action: 'stats',
|
|
||||||
module: 'sigma',
|
|
||||||
params: []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'sigma-stats-second',
|
|
||||||
regex: /^stats\s+sigma$/i,
|
regex: /^stats\s+sigma$/i,
|
||||||
action: 'stats',
|
action: 'stats',
|
||||||
module: 'sigma',
|
module: 'sigma',
|
||||||
|
|
|
@ -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 => {
|
||||||
|
// 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 || '',
|
id: rule.id || '',
|
||||||
title: wrapText(rule.title || '', 60), // Use narrower width for table columns
|
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',
|
||||||
})),
|
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,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue