fylgja/src/fylgja-cli/cli.js
Charlotte Croce deedbc7b9f create mkdocs
2025-07-09 22:30:57 -04:00

453 lines
No EOL
13 KiB
JavaScript

/**
* cli.js
*
* Interactive CLI interface
*/
const readline = require('readline');
const { parseCommand } = require('../lang/command_parser');
const logger = require('../utils/logger');
const { generateGradientLogo, generateNormalLogo } = require('./utils/cli_logo');
const outputManager = require('./cli_output_manager');
const handlerRegistry = require('../handlers/handler_registry');
// 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> '
});
// Available commands for auto-completion
const availableCommands = [
'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'
];
/**
* 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 hits = availableCommands.filter((c) => c.startsWith(line));
return [hits.length ? hits : availableCommands, 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 standardized command context object
* @param {string} text - Command text
* @param {string} module - Module name
* @param {string} action - Action name
* @param {Array} params - Command parameters
* @returns {Object} Command context object
*/
function createCommandContext(text, module, action, params) {
return {
command: {
text,
user_id: 'cli_user',
user_name: 'cli_user',
command: '/fylgja',
channel_id: 'cli',
channel_name: 'cli'
},
meta: {
module,
action,
params,
source: 'cli'
}
};
}
/**
* Create a custom respond function for handlers
* @param {Object} context - Command execution context
* @returns {Function} A respond function for handler callbacks
*/
function createRespondFunction(context) {
// 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) {
const { module, action } = context.meta;
// Display data based on module and action type
if (module === 'sigma') {
if (['search', 'complexSearch'].includes(action)) {
// Convert array response to expected format if needed
let dataToFormat = response.responseData;
// Wrap responseData array in proper structure if needed
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();
};
}
/**
* Handle built-in CLI commands
* @param {string} input User input command
* @returns {boolean} True if command was handled, false otherwise
*/
function handleBuiltInCommands(input) {
const trimmedInput = input.trim().toLowerCase();
if (trimmedInput === 'exit' || trimmedInput === 'quit') {
outputManager.displaySuccess('Goodbye!');
rl.close();
process.exit(0);
return true;
}
if (trimmedInput === 'clear') {
console.clear();
rl.prompt();
return true;
}
if (trimmedInput === 'help') {
outputManager.displayHelp();
rl.prompt();
return true;
}
return false;
}
/**
* Handle simple search commands
* @param {string} input User input command
* @returns {boolean} True if command was handled, false otherwise
*/
async function handleSimpleSearch(input) {
const match = input.trim().match(/^search\s+sigma\s+(.+)$/i);
if (match && !input.trim().toLowerCase().includes('where') && !input.trim().toLowerCase().includes('with')) {
const keyword = match[1];
const context = createCommandContext(keyword, 'sigma', 'search', [keyword]);
const respond = createRespondFunction(context);
console.log(`Executing: module=sigma, action=search, params=[${keyword}]`);
try {
// Get handler from registry
const handler = handlerRegistry.getHandler('sigma', 'search');
if (handler) {
await handler.handleCommand(context.command, respond);
} else {
outputManager.displayError('Handler not found for sigma search');
rl.prompt();
}
} 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 true;
}
return false;
}
/**
* Handle complex search commands
* @param {string} input User input command
* @returns {boolean} True if command was handled, false otherwise
*/
async function handleComplexSearch(input) {
const complexSearchMatch = input.trim().match(/^search\s+sigma\s+(rules\s+|detections\s+)?(where|with)\s+(.+)$/i);
if (complexSearchMatch) {
const complexQuery = complexSearchMatch[3];
const searchTerms = extractSearchKeywords(complexQuery);
const context = createCommandContext(
searchTerms || complexQuery,
'sigma',
'complexSearch',
[complexQuery]
);
const respond = createRespondFunction(context);
console.log(`Executing: module=sigma, action=complexSearch, params=[${complexQuery}]`);
try {
// Get handler from registry
const handler = handlerRegistry.getHandler('sigma', 'search');
if (handler) {
await handler.handleCommand(context.command, respond);
} else {
outputManager.displayError('Handler not found for sigma complex search');
rl.prompt();
}
} 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 true;
}
return false;
}
/**
* 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;
}
// Add to command history
commandHistory.push(input);
historyIndex = commandHistory.length;
// Handle built-in commands
if (handleBuiltInCommands(input)) {
return;
}
// Handle simple search
if (await handleSimpleSearch(input)) {
return;
}
// Handle complex search
if (await handleComplexSearch(input)) {
return;
}
// Parse command using existing parser for everything else
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 command context
const context = createCommandContext(
Array.isArray(params) && params.length > 0 ? params[0] : input,
module,
action,
params
);
// Create custom respond function for CLI
const respond = createRespondFunction(context);
try {
// Get handler from registry
const handler = handlerRegistry.getHandler(module, action);
if (handler) {
await handler.handleCommand(context.command, respond);
} else {
outputManager.displayWarning(`Unknown handler for ${module}.${action}`);
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(generateNormalLogo());
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
};
}