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:
commit
b329988c38
15 changed files with 1014 additions and 80 deletions
2
fylgja-cli
Executable file
2
fylgja-cli
Executable file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/bash
|
||||||
|
node "$(dirname "$0")/src/fylgja-cli.js" "$@"
|
59
fylgja-cli.md
Normal file
59
fylgja-cli.md
Normal 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
65
package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
614
src/fylgja-cli.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
|
@ -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) {
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
|
@ -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
164
src/utils/cli_formatters.js
Normal 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
|
||||||
|
};
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue