Merge pull request 'create-cli' (#3) from create-cli into main

Reviewed-on: https://codeberg.org/charlottecroce/fylgja/pulls/3
This commit is contained in:
charlottecroce 2025-04-19 17:27:54 +00:00
commit b329988c38
15 changed files with 1014 additions and 80 deletions

2
fylgja-cli Executable file
View file

@ -0,0 +1,2 @@
#!/bin/bash
node "$(dirname "$0")/src/fylgja-cli.js" "$@"

59
fylgja-cli.md Normal file
View file

@ -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 <keyword> Search for Sigma rules by keyword
details <rule_id> 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.

65
package-lock.json generated
View file

@ -10,10 +10,13 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@slack/bolt": "^4.2.1", "@slack/bolt": "^4.2.1",
"axios": "^1.6.7",
"chalk": "^5.4.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.1.0", "express": "^5.1.0",
"glob": "^8.1.0", "glob": "^8.1.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"readline": "^1.3.0",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
@ -659,35 +662,17 @@
} }
}, },
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "5.4.1",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": { "engines": {
"node": ">=10" "node": "^12.17.0 || ^14.13 || >=16.0.0"
}, },
"funding": { "funding": {
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "node_modules/chownr": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "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" "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": { "node_modules/console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@ -2441,6 +2456,12 @@
"node": ">= 6" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",

View file

@ -7,7 +7,8 @@
"ngrok": "ngrok http 3000 --log=stdout --url=tolerant-bull-ideal.ngrok-free.app", "ngrok": "ngrok http 3000 --log=stdout --url=tolerant-bull-ideal.ngrok-free.app",
"dev": "concurrently \"npm run start\" \"npm run ngrok\"", "dev": "concurrently \"npm run start\" \"npm run ngrok\"",
"update-db": "node src/sigma_db/sigma_db_initialize.js", "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": [], "keywords": [],
"author": "", "author": "",
@ -16,13 +17,15 @@
"dependencies": { "dependencies": {
"@slack/bolt": "^4.2.1", "@slack/bolt": "^4.2.1",
"axios": "^1.6.7", "axios": "^1.6.7",
"chalk": "^5.4.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^5.1.0", "express": "^5.1.0",
"glob": "^8.1.0", "glob": "^8.1.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"readline": "^1.3.0",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
} }

View file

@ -15,7 +15,7 @@ const FILE_NAME = getFileName(__filename);
* @param {Object} details - The rule details object containing all rule metadata * @param {Object} details - The rule details object containing all rule metadata
* @returns {Array} Formatted Slack blocks ready for display * @returns {Array} Formatted Slack blocks ready for display
*/ */
function getRuleExplanationBlocks(details) { function getSigmaRuleDetailsBlocks(details) {
logger.debug(`${FILE_NAME}: Creating rule explanation blocks for rule: ${details?.id || 'unknown'}`); logger.debug(`${FILE_NAME}: Creating rule explanation blocks for rule: ${details?.id || 'unknown'}`);
if (!details) { if (!details) {
@ -294,5 +294,5 @@ function getRuleExplanationBlocks(details) {
} }
module.exports = { module.exports = {
getRuleExplanationBlocks getSigmaRuleDetailsBlocks
}; };

614
src/fylgja-cli.js Normal file
View file

@ -0,0 +1,614 @@
/**
* 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];
}
/**
* 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();
} else {
// Export functions for integration with main app
module.exports = {
startCLI
};
}

View file

@ -5,9 +5,9 @@
*/ */
const logger = require('../../../utils/logger'); const logger = require('../../../utils/logger');
const { handleError } = require('../../../utils/error_handler'); const { handleError } = require('../../../utils/error_handler');
const { explainSigmaRule } = require('../../../services/sigma/sigma_details_service'); const { getSigmaRuleDetails } = require('../../../services/sigma/sigma_details_service');
const { convertRuleToBackend } = require('../../../services/sigma/sigma_backend_converter'); const { convertRuleToBackend } = require('../../../services/sigma/sigma_backend_converter');
const { getRuleExplanationBlocks } = require('../../../blocks/sigma/sigma_details_block'); const { getSigmaRuleDetailsBlocks } = require('../../../blocks/sigma/sigma_details_block');
const { getConversionResultBlocks } = require('../../../blocks/sigma/sigma_conversion_block'); const { getConversionResultBlocks } = require('../../../blocks/sigma/sigma_conversion_block');
const { SIGMA_CLI_CONFIG } = require('../../../config/appConfig'); const { SIGMA_CLI_CONFIG } = require('../../../config/appConfig');
@ -38,8 +38,8 @@ const processRuleDetails = async (ruleId, respond, replaceOriginal = false, resp
logger.info(`${FILE_NAME}: Processing details for sigma rule: ${ruleId}`); logger.info(`${FILE_NAME}: Processing details for sigma rule: ${ruleId}`);
// Get Sigma rule details // Get Sigma rule details
logger.info(`${FILE_NAME}: Calling explainSigmaRule with ID: '${ruleId}'`); logger.info(`${FILE_NAME}: Calling getSigmaRuleDetails with ID: '${ruleId}'`);
const result = await explainSigmaRule(ruleId); const result = await getSigmaRuleDetails(ruleId);
if (!result.success) { if (!result.success) {
logger.error(`${FILE_NAME}: Rule details retrieval failed: ${result.message}`); logger.error(`${FILE_NAME}: Rule details retrieval failed: ${result.message}`);
@ -66,7 +66,7 @@ const processRuleDetails = async (ruleId, respond, replaceOriginal = false, resp
// Generate blocks // Generate blocks
let blocks; let blocks;
try { try {
blocks = getRuleExplanationBlocks(result.explanation); blocks = getSigmaRuleDetailsBlocks(result.explanation);
} catch (blockError) { } catch (blockError) {
await handleError(blockError, `${FILE_NAME}: Block generation`, respond, { await handleError(blockError, `${FILE_NAME}: Block generation`, respond, {
replaceOriginal: replaceOriginal, replaceOriginal: replaceOriginal,

View file

@ -5,7 +5,7 @@
*/ */
const logger = require('../../../utils/logger'); const logger = require('../../../utils/logger');
const { handleError } = require('../../../utils/error_handler'); const { handleError } = require('../../../utils/error_handler');
const { explainSigmaRule } = require('../../../services/sigma/sigma_details_service'); const { getSigmaRuleDetails } = require('../../../services/sigma/sigma_details_service');
const { convertRuleToBackend } = require('../../../services/sigma/sigma_backend_converter'); const { convertRuleToBackend } = require('../../../services/sigma/sigma_backend_converter');
const { sendRuleToSiem } = require('../../../services/elastic/elastic_send_rule_to_siem_service'); const { sendRuleToSiem } = require('../../../services/elastic/elastic_send_rule_to_siem_service');
const { getAllSpaces } = require('../../../services/elastic/elastic_api_service'); const { getAllSpaces } = require('../../../services/elastic/elastic_api_service');
@ -210,8 +210,8 @@ const registerSiemActions = (app) => {
const ruleId = actionValue.replace('select_space_for_rule_', ''); const ruleId = actionValue.replace('select_space_for_rule_', '');
// Get rule information to display in the space selection message // Get rule information to display in the space selection message
const explainResult = await explainSigmaRule(ruleId); const sigmaRuleDetailsResult = await getSigmaRuleDetails(ruleId);
const ruleInfo = explainResult.success ? explainResult.explanation : { title: ruleId }; const ruleInfo = sigmaRuleDetailsResult.success ? sigmaRuleDetailsResult.explanation : { title: ruleId };
// Generate blocks for space selection // Generate blocks for space selection
const blocks = getSpaceSelectionBlocks(ruleId, ruleInfo); const blocks = getSpaceSelectionBlocks(ruleId, ruleInfo);

View file

@ -1,27 +1,33 @@
/** /**
* sigma_details_handler.js * sigma_details_handler.js
* *
* Handles Sigma rule details requests from Slack commands * Handles Sigma rule details requests from both Slack commands and CLI
* Processes requests for rule explanations * Processes requests for rule explanations
*/ */
const logger = require('../../utils/logger'); const logger = require('../../utils/logger');
const { handleError } = require('../../utils/error_handler'); const { handleError } = require('../../utils/error_handler');
const { explainSigmaRule } = require('../../services/sigma/sigma_details_service'); const { getSigmaRuleDetails, getSigmaRuleYaml } = require('../../services/sigma/sigma_details_service');
const { processRuleDetails } = require('./actions/sigma_action_core'); const { getSigmaRuleDetailsBlocks } = require('../../blocks/sigma/sigma_details_block');
const FILE_NAME = 'sigma_details_handler.js';
const { getFileName } = require('../../utils/file_utils');
const FILE_NAME = getFileName(__filename);
/** /**
* Handle the sigma-details command for Sigma rules * Handle the sigma-details command for Sigma rules
* *
* @param {Object} command - The Slack command object * @param {Object} command - The Slack command or CLI command object
* @param {Function} respond - Function to send response back to Slack * @param {Function} respond - Function to send response back to Slack or CLI
*/ */
const handleCommand = async (command, respond) => { const handleCommand = async (command, respond) => {
try { try {
logger.debug(`${FILE_NAME}: Processing sigma-details command: ${JSON.stringify(command.text)}`); logger.debug(`${FILE_NAME}: Processing sigma-details command: ${command.text}`);
if (!command || !command.text) { if (!command || !command.text) {
logger.warn(`${FILE_NAME}: Empty command received for sigma-details`); logger.warn(`${FILE_NAME}: Empty command received for sigma-details`);
await respond('Invalid command. Usage: /sigma-details [id]'); await respond({
text: 'Invalid command. Usage: /sigma-details [id] or "details sigma [id]"',
response_type: 'ephemeral'
});
return; return;
} }
@ -30,7 +36,10 @@ const handleCommand = async (command, respond) => {
if (!ruleId) { if (!ruleId) {
logger.warn(`${FILE_NAME}: Missing rule ID in sigma-details command`); logger.warn(`${FILE_NAME}: Missing rule ID in sigma-details command`);
await respond('Invalid command: missing rule ID. Usage: /sigma-details [id]'); await respond({
text: 'Invalid command: missing rule ID. Usage: /sigma-details [id] or "details sigma [id]"',
response_type: 'ephemeral'
});
return; return;
} }
@ -40,14 +49,44 @@ const handleCommand = async (command, respond) => {
response_type: 'ephemeral' response_type: 'ephemeral'
}); });
// Use the shared processRuleDetails function from action handlers // Get the rule explanation
await processRuleDetails(ruleId, respond, false, 'in_channel'); const sigmaRuleDetailsResult = await getSigmaRuleDetails(ruleId);
if (!sigmaRuleDetailsResult.success) {
logger.warn(`${FILE_NAME}: Failed to explain rule ${ruleId}: ${sigmaRuleDetailsResult.message}`);
await respond({
text: `Error: ${sigmaRuleDetailsResult.message}`,
response_type: 'ephemeral'
});
return;
}
// For Slack responses, generate Block Kit blocks
let blocks;
try {
// This is for Slack - get the Block Kit UI components
blocks = getSigmaRuleDetailsBlocks(sigmaRuleDetailsResult.explanation);
} catch (blockError) {
await handleError(blockError, `${FILE_NAME}: Block generation`, respond, {
responseType: 'ephemeral',
customMessage: 'Error generating rule details view'
});
return;
}
// Return the response with both blocks for Slack and responseData for CLI
await respond({
blocks: blocks, // For Slack interface
responseData: sigmaRuleDetailsResult.explanation, // For CLI interface
response_type: 'in_channel'
});
} catch (error) { } catch (error) {
await handleError(error, `${FILE_NAME}: Details command handler`, respond, { await handleError(error, `${FILE_NAME}: Details command handler`, respond, {
responseType: 'ephemeral' responseType: 'ephemeral'
}); });
} }
}; };
module.exports = { module.exports = {
handleCommand handleCommand
}; };

View file

@ -39,7 +39,7 @@ const handleCommand = async (command, respond) => {
return; return;
} }
// Generate blocks for displaying statistics // For Slack responses, generate Block Kit blocks
let blocks; let blocks;
try { try {
blocks = getStatsBlocks(statsResult.stats); blocks = getStatsBlocks(statsResult.stats);
@ -51,9 +51,10 @@ const handleCommand = async (command, respond) => {
return; return;
} }
// Return the response // Return the response with both blocks for Slack and responseData for CLI
await respond({ await respond({
blocks: blocks, blocks: blocks,
responseData: statsResult.stats, // Include raw data for CLI
response_type: 'in_channel' response_type: 'in_channel'
}); });
} catch (error) { } catch (error) {

View file

@ -17,20 +17,12 @@
const commandPatterns = [ const commandPatterns = [
// Sigma details patterns // Sigma details patterns
{ {
name: 'sigma-details-direct', name: 'sigma-details',
regex: /^(explain|get|show|display|details|info|about)\s+(rule|detection)\s+(from\s+)?sigma\s+(where\s+)?(id=|id\s+is\s+|with\s+id\s+)(.+)$/i, regex: /^details\s+sigma\s+(.+)$/i,
action: 'details', action: 'details',
module: 'sigma', module: 'sigma',
params: [6] // rule ID is in capturing group 6 params: [1] // rule ID is in capturing group 1
}, },
{
name: 'sigma-details-simple',
regex: /^(details|explain)\s+(.+)$/i,
action: 'details',
module: 'sigma',
params: [2] // rule ID is in capturing group 2
},
// Sigma search patterns // Sigma search patterns
{ {
name: 'sigma-search', name: 'sigma-search',

View file

@ -17,7 +17,7 @@ const FILE_NAME = getFileName(__filename);
* @param {string} ruleId - The ID of the rule to explain * @param {string} ruleId - The ID of the rule to explain
* @returns {Promise<Object>} Result object with success flag and explanation or error message * @returns {Promise<Object>} Result object with success flag and explanation or error message
*/ */
async function explainSigmaRule(ruleId) { async function getSigmaRuleDetails(ruleId) {
if (!ruleId) { if (!ruleId) {
logger.warn(`${FILE_NAME}: Cannot explain rule: Missing rule ID`); logger.warn(`${FILE_NAME}: Cannot explain rule: Missing rule ID`);
return { return {
@ -145,6 +145,6 @@ async function getSigmaRuleYaml(ruleId) {
} }
module.exports = { module.exports = {
explainSigmaRule, getSigmaRuleDetails,
getSigmaRuleYaml getSigmaRuleYaml
}; };

View file

@ -31,11 +31,27 @@ 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`); logger.info(`${FILE_NAME}: Successfully collected database statistics`);
return { return {
success: true, success: true,
stats: statsResult.stats stats: formattedStats,
// Include raw response data for direct use by CLI.
// We have one universal function in the CLI to receive responses,
// and the CLI will then format each result differently
responseData: formattedStats
}; };
} catch (error) { } catch (error) {
logger.error(`${FILE_NAME}: Error processing statistics: ${error.message}`); logger.error(`${FILE_NAME}: Error processing statistics: ${error.message}`);

164
src/utils/cli_formatters.js Normal file
View file

@ -0,0 +1,164 @@
/**
* cli_formatters.js
*
* Dedicated formatters for CLI output of various data types
* Converts raw data into formatted CLI-friendly displays
*/
const chalk = require('chalk');
/**
* Wraps text at specified length
* @param {string} text - Text to wrap
* @param {number} maxLength - Maximum line length
* @returns {string} Wrapped text
*/
function wrapText(text, maxLength = 80) {
if (!text || typeof text !== 'string') {
return text;
}
if (text.length <= maxLength) {
return text;
}
const words = text.split(' ');
let wrappedText = '';
let currentLine = '';
words.forEach(word => {
// If adding this word would exceed max length, start a new line
if ((currentLine + word).length + 1 > maxLength) {
wrappedText += currentLine.trim() + '\n';
currentLine = word + ' ';
} else {
currentLine += word + ' ';
}
});
// Add the last line
wrappedText += currentLine.trim();
return wrappedText;
}
/**
* Format Sigma rule details for CLI display
* @param {Object} ruleDetails - The rule details to format
* @returns {Object} Formatted rule details for CLI display
*/
function formatSigmaDetails(ruleDetails) {
if (!ruleDetails) {
return null;
}
// Create a flattened object for display in CLI table format
const formattedDetails = {
'ID': ruleDetails.id || 'Unknown',
'Title': wrapText(ruleDetails.title || 'Untitled Rule', 80),
'Description': wrapText(ruleDetails.description || 'No description provided', 80),
'Author': ruleDetails.author || 'Unknown author',
'Severity': ruleDetails.severity || 'Unknown',
'Detection': wrapText(ruleDetails.detectionExplanation || 'No detection specified', 80),
'False Positives': wrapText(Array.isArray(ruleDetails.falsePositives) ?
ruleDetails.falsePositives.join(', ') : 'None specified', 80),
'Tags': wrapText(Array.isArray(ruleDetails.tags) ?
ruleDetails.tags.join(', ') : 'None', 80),
'References': wrapText(Array.isArray(ruleDetails.references) ?
ruleDetails.references.join(', ') : 'None', 80)
};
return formattedDetails;
}
/**
* 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: wrapText(rule.title || '', 60), // Use narrower width for table columns
author: rule.author || 'Unknown',
level: rule.level || 'medium'
})),
totalCount: searchResults.totalCount || 0
};
}
module.exports = {
formatSigmaStats,
formatSigmaSearchResults,
formatSigmaDetails,
wrapText
};

View file

@ -1,6 +1,7 @@
/** /**
* logger.js * logger.js
* Handles logging functionality *
* Handles logging functionality with CLI mode support
*/ */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
@ -25,12 +26,29 @@ if (!fs.existsSync(LOGS_DIR)) {
} }
// Use log file from config if available, otherwise use default // Use log file from config if available, otherwise use default
const LOG_FILE = LOGGING_CONFIG?.file const LOG_FILE = LOGGING_CONFIG?.file
? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file) ? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file)
: path.join(LOGS_DIR, 'fylgja.log'); : path.join(LOGS_DIR, 'fylgja.log');
// Flag to determine if we're running in CLI mode
let isCliMode = false;
// Create logger object // Create logger object
const logger = { 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 * Internal method to write log entry to file and console if level meets threshold
* @param {string} level - Log level (DEBUG, INFO, WARN, ERROR) * @param {string} level - Log level (DEBUG, INFO, WARN, ERROR)
@ -39,7 +57,7 @@ const logger = {
_writeToFile: (level, message) => { _writeToFile: (level, message) => {
// Check if this log level should be displayed based on configured level // Check if this log level should be displayed based on configured level
const levelValue = LOG_LEVELS[level] || 0; const levelValue = LOG_LEVELS[level] || 0;
if (levelValue >= configuredLevelValue) { if (levelValue >= configuredLevelValue) {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const logEntry = `${timestamp} ${level}: ${message}\n`; const logEntry = `${timestamp} ${level}: ${message}\n`;
@ -48,23 +66,28 @@ const logger = {
try { try {
fs.appendFileSync(LOG_FILE, logEntry); fs.appendFileSync(LOG_FILE, logEntry);
} catch (err) { } 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 // Only log to console if not in CLI mode
switch (level) { if (!isCliMode) {
case 'ERROR': switch (level) {
console.error(logEntry.trim()); case 'ERROR':
break; console.error(logEntry.trim());
case 'WARN': break;
console.warn(logEntry.trim()); case 'WARN':
break; console.warn(logEntry.trim());
case 'DEBUG': break;
console.debug(logEntry.trim()); case 'DEBUG':
break; console.debug(logEntry.trim());
case 'INFO': break;
default: case 'INFO':
console.info(logEntry.trim()); default:
console.info(logEntry.trim());
}
} }
} }
}, },