diff --git a/fylgja-cli b/fylgja-cli new file mode 100755 index 0000000..0ac5b97 --- /dev/null +++ b/fylgja-cli @@ -0,0 +1,2 @@ +#!/bin/bash +node "$(dirname "$0")/src/fylgja-cli.js" "$@" diff --git a/fylgja-cli.md b/fylgja-cli.md new file mode 100644 index 0000000..2b5a7f5 --- /dev/null +++ b/fylgja-cli.md @@ -0,0 +1,59 @@ +# Fylgja CLI Interface + +The Fylgja CLI provides an interactive command-line interface for managing SIEM rules, similar to MySQL's CLI. + +## Usage + +Start the CLI interface: + +```bash +npm run cli +``` + +Or use the direct launcher: + +```bash +./fylgja-cli +``` + +## Features + +- **Interactive Prompt**: MySQL-style prompt with command history +- **Tab Completion**: Press Tab to auto-complete commands +- **Command History**: Use Up/Down arrows to navigate previous commands +- **Formatted Output**: Table-based output formats for different commands +- **Color Coding**: Visual indicators for severity levels and result types + +## Available Commands + +### Basic Commands + +``` +search Search for Sigma rules by keyword +details Get details about a specific Sigma rule +stats Get statistics about Sigma rules database +help Display help information +exit/quit Exit the CLI +clear Clear the terminal screen +``` + +### Advanced Search Commands + +``` +search sigma rules where title contains "ransomware" +find rules where tags include privilege_escalation +search rules where logsource.category == "process_creation" +find rules where modified after 2024-01-01 +``` + +## Examples + +``` +fylgja> search rules where level is "high" +fylgja> details 5f35f6c7-80a7-4ca0-a41f-31e8ac557233 +fylgja> stats +``` + +## Integration with Slack Bot + +The CLI interface uses the same command parsing and execution logic as the Slack bot, ensuring consistency across interfaces. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4cb0601..b1c7c87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,13 @@ "license": "ISC", "dependencies": { "@slack/bolt": "^4.2.1", + "axios": "^1.6.7", + "chalk": "^5.4.1", "dotenv": "^16.4.7", "express": "^5.1.0", "glob": "^8.1.0", "js-yaml": "^4.1.0", + "readline": "^1.3.0", "sqlite3": "^5.1.7" }, "devDependencies": { @@ -659,35 +662,17 @@ } }, "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, "engines": { - "node": ">=10" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -797,6 +782,36 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -2441,6 +2456,12 @@ "node": ">= 6" } }, + "node_modules/readline": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", + "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==", + "license": "BSD" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 1cdbd06..283c675 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "ngrok": "ngrok http 3000 --log=stdout --url=tolerant-bull-ideal.ngrok-free.app", "dev": "concurrently \"npm run start\" \"npm run ngrok\"", "update-db": "node src/sigma_db/sigma_db_initialize.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "cli": "node src/fylgja-cli.js" }, "keywords": [], "author": "", @@ -16,13 +17,15 @@ "dependencies": { "@slack/bolt": "^4.2.1", "axios": "^1.6.7", + "chalk": "^5.4.1", "dotenv": "^16.4.7", "express": "^5.1.0", "glob": "^8.1.0", "js-yaml": "^4.1.0", + "readline": "^1.3.0", "sqlite3": "^5.1.7" }, "devDependencies": { "concurrently": "^9.1.2" } -} \ No newline at end of file +} diff --git a/src/fylgja-cli.js b/src/fylgja-cli.js new file mode 100644 index 0000000..612665f --- /dev/null +++ b/src/fylgja-cli.js @@ -0,0 +1,509 @@ +/** + * fylgja-cli.js + * + * Interactive CLI interface + */ + +const readline = require('readline'); +// Import chalk with compatibility for both ESM and CommonJS +let chalk; +try { + // First try CommonJS import (chalk v4.x) + chalk = require('chalk'); +} catch (e) { + // If that fails, provide a fallback implementation + chalk = { + blue: (text) => text, + green: (text) => text, + red: (text) => text, + yellow: (text) => text, + cyan: (text) => text, + white: (text) => text, + dim: (text) => text, + hex: () => (text) => text + }; +} + +const { parseCommand } = require('./lang/command_parser'); +const logger = require('./utils/logger'); +const sigmaSearchHandler = require('./handlers/sigma/sigma_search_handler'); +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]; +} + +/** + * 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': + 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': + console.log('\n+----------------------+--------------------------------------------------+'); + console.log('| Field | Value |'); + console.log('+----------------------+--------------------------------------------------+'); + + for (const [key, value] of Object.entries(data)) { + if (typeof value !== 'object' || value === null) { + const formattedKey = key.padEnd(20).substring(0, 20); + const formattedValue = String(value || '').padEnd(48).substring(0, 48); + + console.log(`| ${formattedKey} | ${formattedValue} |`); + } + } + + console.log('+----------------------+--------------------------------------------------+'); + break; + + case 'stats': + console.log('\n+--------------------+---------------+'); + console.log('| Metric | Value |'); + console.log('+--------------------+---------------+'); + + for (const [key, value] of Object.entries(data)) { + const formattedKey = key.padEnd(18).substring(0, 18); + const formattedValue = String(value || '').padEnd(13).substring(0, 13); + + console.log(`| ${formattedKey} | ${formattedValue} |`); + } + + console.log('+--------------------+---------------+'); + 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 - Search for Sigma rules by keyword +- details sigma - Get details about a specific Sigma rule +- sigma stats - Get statistics about Sigma rules database + +Advanced Sigma Search Commands: +- search sigma rules where title contains "ransomware" - Search by title +- search sigma rules where tags include privilege_escalation - Search by tags +- search sigma rules where logsource.category == "process_creation" - Search by log source +- search sigma rules 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(); +} else { + // Export functions for integration with main app + module.exports = { + startCLI + }; +} \ No newline at end of file diff --git a/src/handlers/sigma/sigma_stats_handler.js b/src/handlers/sigma/sigma_stats_handler.js index 5b49a63..d92e126 100644 --- a/src/handlers/sigma/sigma_stats_handler.js +++ b/src/handlers/sigma/sigma_stats_handler.js @@ -39,7 +39,7 @@ const handleCommand = async (command, respond) => { return; } - // Generate blocks for displaying statistics + // For Slack responses, generate Block Kit blocks let blocks; try { blocks = getStatsBlocks(statsResult.stats); @@ -51,9 +51,10 @@ const handleCommand = async (command, respond) => { return; } - // Return the response + // Return the response with both blocks for Slack and responseData for CLI await respond({ blocks: blocks, + responseData: statsResult.stats, // Include raw data for CLI response_type: 'in_channel' }); } catch (error) { diff --git a/src/services/sigma/sigma_stats_service.js b/src/services/sigma/sigma_stats_service.js index 0bee7a0..20cac15 100644 --- a/src/services/sigma/sigma_stats_service.js +++ b/src/services/sigma/sigma_stats_service.js @@ -31,11 +31,25 @@ async function getSigmaStats() { }; } + // Format the data in a consistent structure for both CLI and Slack + const formattedStats = { + lastUpdate: statsResult.stats.lastUpdate, + totalRules: statsResult.stats.totalRules, + databaseHealth: statsResult.stats.databaseHealth, + operatingSystems: statsResult.stats.operatingSystems, + severityLevels: statsResult.stats.severityLevels, + mitreTactics: statsResult.stats.mitreTactics, + topAuthors: statsResult.stats.topAuthors, + // Add any other statistics needed + }; + logger.info(`${FILE_NAME}: Successfully collected database statistics`); return { success: true, - stats: statsResult.stats + stats: formattedStats, + // Include raw response data for direct use by CLI + responseData: formattedStats }; } catch (error) { logger.error(`${FILE_NAME}: Error processing statistics: ${error.message}`); diff --git a/src/utils/cli_formatters.js b/src/utils/cli_formatters.js new file mode 100644 index 0000000..49636cd --- /dev/null +++ b/src/utils/cli_formatters.js @@ -0,0 +1,171 @@ +/** + * cli_formatters.js + * + * Dedicated formatters for CLI output of various data types + * Converts raw data into formatted CLI-friendly displays + */ +const chalk = require('chalk'); + +/** + * Format Sigma statistics for CLI display + * + * @param {Object} stats - The statistics object + * @returns {Object} Formatted stats ready for CLI display + */ +function formatSigmaStats(stats) { + if (!stats) { + return { error: 'No statistics data available' }; + } + + // Format date + const formatDate = (dateString) => { + if (!dateString) return 'Unknown'; + try { + const date = new Date(dateString); + return date.toLocaleString(); + } catch (error) { + return dateString; + } + }; + + // Create a simplified object suitable for table display + const formattedStats = { + 'Last Update': formatDate(stats.lastUpdate), + 'Total Rules': stats.totalRules.toLocaleString(), + 'Database Health': `${stats.databaseHealth.contentPercentage}% Complete`, + + // OS breakdown + 'Windows Rules': stats.operatingSystems.windows.toLocaleString(), + 'Linux Rules': stats.operatingSystems.linux.toLocaleString(), + 'macOS Rules': stats.operatingSystems.macos.toLocaleString(), + 'Other OS Rules': stats.operatingSystems.other.toLocaleString(), + + // Add severity levels + ...(stats.severityLevels || []).reduce((acc, level) => { + const levelName = level.level + ? level.level.charAt(0).toUpperCase() + level.level.slice(1) + : 'Unknown'; + + acc[`${levelName} Severity`] = level.count.toLocaleString(); + return acc; + }, {}) + }; + + // Add top MITRE tactics if available + if (stats.mitreTactics && stats.mitreTactics.length > 0) { + stats.mitreTactics.forEach((tactic, index) => { + if (index < 5) { // Only include top 5 for brevity + const formattedTactic = tactic.tactic + .replace(/-/g, ' ') + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + formattedStats[`MITRE: ${formattedTactic}`] = tactic.count.toLocaleString(); + } + }); + } + + return formattedStats; +} + +/** + * Format Sigma search results for CLI display + * + * @param {Object} searchResults - The search results object + * @returns {Object} Formatted results ready for CLI display + */ +function formatSigmaSearchResults(searchResults) { + if (!searchResults || !searchResults.results) { + return { error: 'No search results available' }; + } + + // Return a structure with results and meta info + return { + results: searchResults.results.map(rule => ({ + id: rule.id || '', + title: rule.title || '', + author: rule.author || 'Unknown', + level: rule.level || 'medium' + })), + totalCount: searchResults.totalCount || 0 + }; +} + +/** + * Format Sigma rule details for CLI display + * + * @param {Object} ruleDetails - The rule details object + * @returns {Object} Formatted details ready for CLI display + */ +function formatSigmaDetails(ruleDetails) { + if (!ruleDetails) { + return { error: 'No rule details available' }; + } + + // Filter and format the rule details for CLI display + const formattedDetails = {}; + + // Include only the most important fields for display + const fieldsToInclude = [ + 'id', 'title', 'description', 'status', 'author', + 'level', 'falsepositives', 'references', + 'created', 'modified' + ]; + + // Add detection information if available + if (ruleDetails.detection && ruleDetails.detection.condition) { + fieldsToInclude.push('detection_condition'); + formattedDetails['detection_condition'] = ruleDetails.detection.condition; + } + + // Add logsource information if available + if (ruleDetails.logsource) { + if (ruleDetails.logsource.product) { + fieldsToInclude.push('logsource_product'); + formattedDetails['logsource_product'] = ruleDetails.logsource.product; + } + + if (ruleDetails.logsource.category) { + fieldsToInclude.push('logsource_category'); + formattedDetails['logsource_category'] = ruleDetails.logsource.category; + } + + if (ruleDetails.logsource.service) { + fieldsToInclude.push('logsource_service'); + formattedDetails['logsource_service'] = ruleDetails.logsource.service; + } + } + + // Format date fields + const dateFields = ['created', 'modified']; + + for (const [key, value] of Object.entries(ruleDetails)) { + if (fieldsToInclude.includes(key)) { + // Format dates + if (dateFields.includes(key) && value) { + try { + formattedDetails[key] = new Date(value).toLocaleString(); + } catch (e) { + formattedDetails[key] = value; + } + } + // Format arrays + else if (Array.isArray(value)) { + formattedDetails[key] = value.join(', '); + } + // Default handling + else { + formattedDetails[key] = value; + } + } + } + + return formattedDetails; +} + +module.exports = { + formatSigmaStats, + formatSigmaSearchResults, + formatSigmaDetails +}; \ No newline at end of file diff --git a/src/utils/logger.js b/src/utils/logger.js index 3225e2a..29c81a8 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -1,6 +1,7 @@ /** * logger.js - * Handles logging functionality + * + * Handles logging functionality with CLI mode support */ const fs = require('fs'); const path = require('path'); @@ -25,12 +26,29 @@ if (!fs.existsSync(LOGS_DIR)) { } // Use log file from config if available, otherwise use default -const LOG_FILE = LOGGING_CONFIG?.file - ? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file) +const LOG_FILE = LOGGING_CONFIG?.file + ? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file) : path.join(LOGS_DIR, 'fylgja.log'); +// Flag to determine if we're running in CLI mode +let isCliMode = false; + // Create logger object const logger = { + /** + * Set the CLI mode flag + * @param {boolean} mode - True to enable CLI mode (no console output) + */ + setCliMode: (mode) => { + isCliMode = !!mode; + }, + + /** + * Check if running in CLI mode + * @returns {boolean} CLI mode status + */ + isCliMode: () => isCliMode, + /** * Internal method to write log entry to file and console if level meets threshold * @param {string} level - Log level (DEBUG, INFO, WARN, ERROR) @@ -39,7 +57,7 @@ const logger = { _writeToFile: (level, message) => { // Check if this log level should be displayed based on configured level const levelValue = LOG_LEVELS[level] || 0; - + if (levelValue >= configuredLevelValue) { const timestamp = new Date().toISOString(); const logEntry = `${timestamp} ${level}: ${message}\n`; @@ -48,23 +66,28 @@ const logger = { try { fs.appendFileSync(LOG_FILE, logEntry); } catch (err) { - console.error(`Failed to write to log file: ${err.message}`); + // If in CLI mode, don't output to console + if (!isCliMode) { + console.error(`Failed to write to log file: ${err.message}`); + } } - // Also log to console with appropriate method - switch (level) { - case 'ERROR': - console.error(logEntry.trim()); - break; - case 'WARN': - console.warn(logEntry.trim()); - break; - case 'DEBUG': - console.debug(logEntry.trim()); - break; - case 'INFO': - default: - console.info(logEntry.trim()); + // Only log to console if not in CLI mode + if (!isCliMode) { + switch (level) { + case 'ERROR': + console.error(logEntry.trim()); + break; + case 'WARN': + console.warn(logEntry.trim()); + break; + case 'DEBUG': + console.debug(logEntry.trim()); + break; + case 'INFO': + default: + console.info(logEntry.trim()); + } } } },