refactor cli into multiple files

This commit is contained in:
Charlotte Croce 2025-04-20 18:37:20 -04:00
parent 853b60d762
commit 34143c4241
10 changed files with 1065 additions and 768 deletions

438
src/fylgja-cli/cli.js Normal file
View 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
};
}