create CLI and sigma stats function in CLI
This commit is contained in:
parent
85bb8958b8
commit
519c87fb04
9 changed files with 849 additions and 46 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",
|
||||
"dependencies": {
|
||||
"@slack/bolt": "^4.2.1",
|
||||
"axios": "^1.6.7",
|
||||
"chalk": "^5.4.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"glob": "^8.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"readline": "^1.3.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -659,35 +662,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
|
||||
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
|
@ -797,6 +782,36 @@
|
|||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently/node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
|
@ -2441,6 +2456,12 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readline": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz",
|
||||
"integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==",
|
||||
"license": "BSD"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
"ngrok": "ngrok http 3000 --log=stdout --url=tolerant-bull-ideal.ngrok-free.app",
|
||||
"dev": "concurrently \"npm run start\" \"npm run ngrok\"",
|
||||
"update-db": "node src/sigma_db/sigma_db_initialize.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"cli": "node src/fylgja-cli.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
@ -16,13 +17,15 @@
|
|||
"dependencies": {
|
||||
"@slack/bolt": "^4.2.1",
|
||||
"axios": "^1.6.7",
|
||||
"chalk": "^5.4.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"glob": "^8.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"readline": "^1.3.0",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
509
src/fylgja-cli.js
Normal file
509
src/fylgja-cli.js
Normal file
|
@ -0,0 +1,509 @@
|
|||
/**
|
||||
* fylgja-cli.js
|
||||
*
|
||||
* Interactive CLI interface
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
// Import chalk with compatibility for both ESM and CommonJS
|
||||
let chalk;
|
||||
try {
|
||||
// First try CommonJS import (chalk v4.x)
|
||||
chalk = require('chalk');
|
||||
} catch (e) {
|
||||
// If that fails, provide a fallback implementation
|
||||
chalk = {
|
||||
blue: (text) => text,
|
||||
green: (text) => text,
|
||||
red: (text) => text,
|
||||
yellow: (text) => text,
|
||||
cyan: (text) => text,
|
||||
white: (text) => text,
|
||||
dim: (text) => text,
|
||||
hex: () => (text) => text
|
||||
};
|
||||
}
|
||||
|
||||
const { parseCommand } = require('./lang/command_parser');
|
||||
const logger = require('./utils/logger');
|
||||
const sigmaSearchHandler = require('./handlers/sigma/sigma_search_handler');
|
||||
const sigmaDetailsHandler = require('./handlers/sigma/sigma_details_handler');
|
||||
const sigmaStatsHandler = require('./handlers/sigma/sigma_stats_handler');
|
||||
const sigmaCreateHandler = require('./handlers/sigma/sigma_create_handler');
|
||||
const { handleCommand: handleAlerts } = require('./handlers/alerts/alerts_handler');
|
||||
const { handleCommand: handleCase } = require('./handlers/case/case_handler');
|
||||
const { handleCommand: handleConfig } = require('./handlers/config/config_handler');
|
||||
const { handleCommand: handleStats } = require('./handlers/stats/stats_handler');
|
||||
|
||||
// Import CLI formatters
|
||||
const {
|
||||
formatSigmaStats,
|
||||
formatSigmaSearchResults,
|
||||
formatSigmaDetails
|
||||
} = require('./utils/cli_formatters');
|
||||
|
||||
// Set logger to CLI mode (prevents console output)
|
||||
logger.setCliMode(true);
|
||||
|
||||
// Try to get version, but provide fallback if package.json can't be found
|
||||
let version = '1.0.0';
|
||||
try {
|
||||
const packageJson = require('../package.json');
|
||||
version = packageJson.version;
|
||||
} catch (e) {
|
||||
console.log('Could not load package.json, using default version');
|
||||
}
|
||||
|
||||
const FILE_NAME = 'fylgja-cli.js';
|
||||
|
||||
// ASCII art logo for the CLI
|
||||
const ASCII_LOGO = `
|
||||
░▒▓████████▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓██████▓▒░ ░▒▓█▓▒░░▒▓██████▓▒░
|
||||
░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
|
||||
░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
|
||||
░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░ ░▒▓█▓▒▒▓███▓▒░ ░▒▓█▓▒░▒▓████████▓▒░
|
||||
░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
|
||||
░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░▒▓█▓▒░░▒▓█▓▒░
|
||||
░▒▓█▓▒░ ░▒▓█▓▒░ ░▒▓████████▓▒░▒▓██████▓▒░ ░▒▓██████▓▒░░▒▓█▓▒░░▒▓█▓▒░
|
||||
`;
|
||||
|
||||
// Command history array
|
||||
let commandHistory = [];
|
||||
let historyIndex = -1;
|
||||
|
||||
// Create the readline interface
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
completer: completer,
|
||||
prompt: 'fylgja> '
|
||||
});
|
||||
|
||||
/**
|
||||
* Command auto-completion function
|
||||
* @param {string} line Current command line input
|
||||
* @returns {Array} Array with possible completions and the substring being completed
|
||||
*/
|
||||
function completer(line) {
|
||||
const commands = [
|
||||
'search sigma',
|
||||
'details sigma',
|
||||
'sigma stats',
|
||||
'stats sigma',
|
||||
'search sigma rules where title contains',
|
||||
'search rules where tags include',
|
||||
'search rules where logsource.category ==',
|
||||
'search rules where modified after',
|
||||
'help',
|
||||
'exit',
|
||||
'quit',
|
||||
'clear'
|
||||
];
|
||||
|
||||
const hits = commands.filter((c) => c.startsWith(line));
|
||||
return [hits.length ? hits : commands, line];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CLI output similar to MySQL
|
||||
* @param {Object} data The data to format
|
||||
* @param {string} type The type of data (results, details, stats)
|
||||
*/
|
||||
function formatOutput(data, type) {
|
||||
if (!data) {
|
||||
console.log('No data returned from the server.');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'search_results':
|
||||
console.log('\n+-------+----------------------+------------------+-------------+');
|
||||
console.log('| ID | Title | Author | Level |');
|
||||
console.log('+-------+----------------------+------------------+-------------+');
|
||||
|
||||
if (data.results && data.results.length > 0) {
|
||||
data.results.forEach(rule => {
|
||||
const id = (rule.id || '').padEnd(5).substring(0, 5);
|
||||
const title = (rule.title || '').padEnd(20).substring(0, 20);
|
||||
const author = (rule.author || 'Unknown').padEnd(16).substring(0, 16);
|
||||
const level = (rule.level || 'medium').padEnd(11).substring(0, 11);
|
||||
|
||||
console.log(`| ${id} | ${title} | ${author} | ${level} |`);
|
||||
});
|
||||
} else {
|
||||
console.log('| No results found |');
|
||||
}
|
||||
|
||||
console.log('+-------+----------------------+------------------+-------------+');
|
||||
console.log(`${data.totalCount || 0} rows in set`);
|
||||
break;
|
||||
|
||||
case 'details':
|
||||
console.log('\n+----------------------+--------------------------------------------------+');
|
||||
console.log('| Field | Value |');
|
||||
console.log('+----------------------+--------------------------------------------------+');
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
const formattedKey = key.padEnd(20).substring(0, 20);
|
||||
const formattedValue = String(value || '').padEnd(48).substring(0, 48);
|
||||
|
||||
console.log(`| ${formattedKey} | ${formattedValue} |`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('+----------------------+--------------------------------------------------+');
|
||||
break;
|
||||
|
||||
case 'stats':
|
||||
console.log('\n+--------------------+---------------+');
|
||||
console.log('| Metric | Value |');
|
||||
console.log('+--------------------+---------------+');
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const formattedKey = key.padEnd(18).substring(0, 18);
|
||||
const formattedValue = String(value || '').padEnd(13).substring(0, 13);
|
||||
|
||||
console.log(`| ${formattedKey} | ${formattedValue} |`);
|
||||
}
|
||||
|
||||
console.log('+--------------------+---------------+');
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse out any basic search keywords from a complexSearch query
|
||||
* This helps with the search commands that don't quite match the expected format
|
||||
* @param {string} input The complex search query
|
||||
* @returns {string} Extracted keywords
|
||||
*/
|
||||
function extractSearchKeywords(input) {
|
||||
if (!input) return '';
|
||||
|
||||
// Try to extract keywords from common patterns
|
||||
if (input.includes('title contains')) {
|
||||
const match = input.match(/title\s+contains\s+["']([^"']+)["']/i);
|
||||
if (match) return match[1];
|
||||
}
|
||||
|
||||
if (input.includes('tags include')) {
|
||||
const match = input.match(/tags\s+include\s+(\S+)/i);
|
||||
if (match) return match[1];
|
||||
}
|
||||
|
||||
// Default - just return the input as is
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a command from the CLI
|
||||
* @param {string} input User input command
|
||||
*/
|
||||
async function processCommand(input) {
|
||||
try {
|
||||
// Skip empty commands
|
||||
if (!input.trim()) {
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special CLI commands
|
||||
if (input.trim().toLowerCase() === 'exit' || input.trim().toLowerCase() === 'quit') {
|
||||
console.log('Goodbye!');
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (input.trim().toLowerCase() === 'clear') {
|
||||
console.clear();
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Special case for simple search
|
||||
if (input.trim().match(/^search\s+sigma\s+(.+)$/i)) {
|
||||
const keyword = input.trim().match(/^search\s+sigma\s+(.+)$/i)[1];
|
||||
|
||||
// Add to command history
|
||||
commandHistory.push(input);
|
||||
historyIndex = commandHistory.length;
|
||||
|
||||
// Create fake command object
|
||||
const command = {
|
||||
text: keyword,
|
||||
user_id: 'cli_user',
|
||||
user_name: 'cli_user',
|
||||
command: '/fylgja',
|
||||
channel_id: 'cli',
|
||||
channel_name: 'cli'
|
||||
};
|
||||
|
||||
// Create custom respond function
|
||||
const respond = createRespondFunction('search', 'sigma', [keyword]);
|
||||
|
||||
console.log(`Executing: module=sigma, action=search, params=[${keyword}]`);
|
||||
|
||||
try {
|
||||
await sigmaSearchHandler.handleCommand(command, respond);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
logger.error(`${FILE_NAME}: Command execution error: ${error.message}`);
|
||||
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
||||
rl.prompt();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to command history
|
||||
commandHistory.push(input);
|
||||
historyIndex = commandHistory.length;
|
||||
|
||||
// Parse command using existing parser
|
||||
const parsedCommand = await parseCommand(input);
|
||||
|
||||
if (!parsedCommand.success) {
|
||||
console.log(parsedCommand.message || "Command not recognized. Type 'help' for usage.");
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the command details
|
||||
const { action, module, params } = parsedCommand.command;
|
||||
|
||||
// Only show execution info to the user, not sending to logger
|
||||
console.log(`Executing: module=${module}, action=${action}, params=[${params}]`);
|
||||
|
||||
// Create fake command object similar to Slack's
|
||||
const command = {
|
||||
text: Array.isArray(params) && params.length > 0 ? params[0] : input,
|
||||
user_id: 'cli_user',
|
||||
user_name: 'cli_user',
|
||||
command: '/fylgja',
|
||||
channel_id: 'cli',
|
||||
channel_name: 'cli'
|
||||
};
|
||||
|
||||
// Special handling for complexSearch to extract keywords
|
||||
if (action === 'complexSearch' && module === 'sigma' && params.length > 0) {
|
||||
// Try to extract keywords from complex queries
|
||||
const searchTerms = extractSearchKeywords(params[0]);
|
||||
command.text = searchTerms || params[0];
|
||||
}
|
||||
|
||||
// Create custom respond function for CLI
|
||||
const respond = createRespondFunction(action, module, params);
|
||||
|
||||
try {
|
||||
switch (module) {
|
||||
case 'sigma':
|
||||
switch (action) {
|
||||
case 'search':
|
||||
await sigmaSearchHandler.handleCommand(command, respond);
|
||||
break;
|
||||
|
||||
case 'complexSearch':
|
||||
await sigmaSearchHandler.handleComplexSearch(command, respond);
|
||||
break;
|
||||
|
||||
case 'details':
|
||||
await sigmaDetailsHandler.handleCommand(command, respond);
|
||||
break;
|
||||
|
||||
case 'stats':
|
||||
await sigmaStatsHandler.handleCommand(command, respond);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
await sigmaCreateHandler.handleCommand(command, respond);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unknown Sigma action: ${action}`);
|
||||
rl.prompt();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'alerts':
|
||||
await handleAlerts(command, respond);
|
||||
break;
|
||||
|
||||
case 'case':
|
||||
await handleCase(command, respond);
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
await handleConfig(command, respond);
|
||||
break;
|
||||
|
||||
case 'stats':
|
||||
await handleStats(command, respond);
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
displayHelp();
|
||||
rl.prompt();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unknown module: ${module}`);
|
||||
rl.prompt();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
// Log to file but not console
|
||||
logger.error(`${FILE_NAME}: Command execution error: ${error.message}`);
|
||||
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
||||
rl.prompt();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Fatal error: ${error.message}`);
|
||||
// Log to file but not console
|
||||
logger.error(`${FILE_NAME}: Fatal error: ${error.message}`);
|
||||
logger.debug(`${FILE_NAME}: Error stack: ${error.stack}`);
|
||||
rl.prompt();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom respond function for handling results
|
||||
* @param {string} action The action being performed
|
||||
* @param {string} module The module being used
|
||||
* @param {Array} params The parameters for the action
|
||||
* @returns {Function} A respond function for handling results
|
||||
*/
|
||||
function createRespondFunction(action, module, params) {
|
||||
return async (response) => {
|
||||
if (typeof response === 'string') {
|
||||
console.log(response);
|
||||
rl.prompt();
|
||||
return;
|
||||
}
|
||||
|
||||
// First check for the responseData property (directly from service)
|
||||
if (response.responseData) {
|
||||
// Format the data using the appropriate formatter
|
||||
if (module === 'sigma') {
|
||||
let formattedData;
|
||||
|
||||
if (action === 'search' || action === 'complexSearch') {
|
||||
formattedData = formatSigmaSearchResults(response.responseData);
|
||||
formatOutput(formattedData, 'search_results');
|
||||
} else if (action === 'details') {
|
||||
formattedData = formatSigmaDetails(response.responseData);
|
||||
formatOutput(formattedData, 'details');
|
||||
} else if (action === 'stats') {
|
||||
formattedData = formatSigmaStats(response.responseData);
|
||||
formatOutput(formattedData, 'stats');
|
||||
} else {
|
||||
console.log(JSON.stringify(response.responseData, null, 2));
|
||||
}
|
||||
} else {
|
||||
// For other modules, just display the JSON
|
||||
console.log(JSON.stringify(response.responseData, null, 2));
|
||||
}
|
||||
}
|
||||
// Fallback for text-only responses
|
||||
else if (response.text) {
|
||||
console.log(response.text);
|
||||
} else {
|
||||
console.log('Command completed successfully.');
|
||||
}
|
||||
|
||||
rl.prompt();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display help text
|
||||
*/
|
||||
function displayHelp() {
|
||||
const helpText = `
|
||||
Fylgja CLI Help
|
||||
|
||||
Basic Sigma Commands:
|
||||
- search sigma <keyword> - Search for Sigma rules by keyword
|
||||
- details sigma <rule_id> - Get details about a specific Sigma rule
|
||||
- sigma stats - Get statistics about Sigma rules database
|
||||
|
||||
Advanced Sigma Search Commands:
|
||||
- search sigma rules where title contains "ransomware" - Search by title
|
||||
- search sigma rules where tags include privilege_escalation - Search by tags
|
||||
- search sigma rules where logsource.category == "process_creation" - Search by log source
|
||||
- search sigma rules where modified after 2024-01-01 - Search by modification date
|
||||
|
||||
|
||||
- exit or quit - Exit the CLI
|
||||
- clear - Clear the terminal screen
|
||||
- help - Display this help text
|
||||
`;
|
||||
|
||||
console.log(helpText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the CLI application
|
||||
*/
|
||||
function startCLI() {
|
||||
console.log(ASCII_LOGO);
|
||||
console.log(`Fylgja CLI v${version} - Interactive SIEM Management Tool`);
|
||||
console.log(`Type 'help' for usage information or 'exit' to quit\n`);
|
||||
|
||||
// Set up key bindings for history navigation
|
||||
rl._writeToOutput = function _writeToOutput(stringToWrite) {
|
||||
if (stringToWrite === '\\u001b[A' || stringToWrite === '\\u001b[B') {
|
||||
// Don't output control characters for up/down arrows
|
||||
return;
|
||||
}
|
||||
rl.output.write(stringToWrite);
|
||||
};
|
||||
|
||||
// Set up key listeners for history
|
||||
rl.input.on('keypress', (char, key) => {
|
||||
if (key && key.name === 'up') {
|
||||
if (historyIndex > 0) {
|
||||
historyIndex--;
|
||||
rl.line = commandHistory[historyIndex];
|
||||
rl.cursor = rl.line.length;
|
||||
rl._refreshLine();
|
||||
}
|
||||
} else if (key && key.name === 'down') {
|
||||
if (historyIndex < commandHistory.length - 1) {
|
||||
historyIndex++;
|
||||
rl.line = commandHistory[historyIndex];
|
||||
rl.cursor = rl.line.length;
|
||||
rl._refreshLine();
|
||||
} else if (historyIndex === commandHistory.length - 1) {
|
||||
historyIndex = commandHistory.length;
|
||||
rl.line = '';
|
||||
rl.cursor = 0;
|
||||
rl._refreshLine();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rl.prompt();
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
await processCommand(line.trim());
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
console.log('Goodbye!');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if running directly
|
||||
if (require.main === module) {
|
||||
startCLI();
|
||||
} else {
|
||||
// Export functions for integration with main app
|
||||
module.exports = {
|
||||
startCLI
|
||||
};
|
||||
}
|
|
@ -39,7 +39,7 @@ const handleCommand = async (command, respond) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Generate blocks for displaying statistics
|
||||
// For Slack responses, generate Block Kit blocks
|
||||
let blocks;
|
||||
try {
|
||||
blocks = getStatsBlocks(statsResult.stats);
|
||||
|
@ -51,9 +51,10 @@ const handleCommand = async (command, respond) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Return the response
|
||||
// Return the response with both blocks for Slack and responseData for CLI
|
||||
await respond({
|
||||
blocks: blocks,
|
||||
responseData: statsResult.stats, // Include raw data for CLI
|
||||
response_type: 'in_channel'
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
@ -31,11 +31,25 @@ async function getSigmaStats() {
|
|||
};
|
||||
}
|
||||
|
||||
// Format the data in a consistent structure for both CLI and Slack
|
||||
const formattedStats = {
|
||||
lastUpdate: statsResult.stats.lastUpdate,
|
||||
totalRules: statsResult.stats.totalRules,
|
||||
databaseHealth: statsResult.stats.databaseHealth,
|
||||
operatingSystems: statsResult.stats.operatingSystems,
|
||||
severityLevels: statsResult.stats.severityLevels,
|
||||
mitreTactics: statsResult.stats.mitreTactics,
|
||||
topAuthors: statsResult.stats.topAuthors,
|
||||
// Add any other statistics needed
|
||||
};
|
||||
|
||||
logger.info(`${FILE_NAME}: Successfully collected database statistics`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats: statsResult.stats
|
||||
stats: formattedStats,
|
||||
// Include raw response data for direct use by CLI
|
||||
responseData: formattedStats
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${FILE_NAME}: Error processing statistics: ${error.message}`);
|
||||
|
|
171
src/utils/cli_formatters.js
Normal file
171
src/utils/cli_formatters.js
Normal file
|
@ -0,0 +1,171 @@
|
|||
/**
|
||||
* cli_formatters.js
|
||||
*
|
||||
* Dedicated formatters for CLI output of various data types
|
||||
* Converts raw data into formatted CLI-friendly displays
|
||||
*/
|
||||
const chalk = require('chalk');
|
||||
|
||||
/**
|
||||
* Format Sigma statistics for CLI display
|
||||
*
|
||||
* @param {Object} stats - The statistics object
|
||||
* @returns {Object} Formatted stats ready for CLI display
|
||||
*/
|
||||
function formatSigmaStats(stats) {
|
||||
if (!stats) {
|
||||
return { error: 'No statistics data available' };
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'Unknown';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Create a simplified object suitable for table display
|
||||
const formattedStats = {
|
||||
'Last Update': formatDate(stats.lastUpdate),
|
||||
'Total Rules': stats.totalRules.toLocaleString(),
|
||||
'Database Health': `${stats.databaseHealth.contentPercentage}% Complete`,
|
||||
|
||||
// OS breakdown
|
||||
'Windows Rules': stats.operatingSystems.windows.toLocaleString(),
|
||||
'Linux Rules': stats.operatingSystems.linux.toLocaleString(),
|
||||
'macOS Rules': stats.operatingSystems.macos.toLocaleString(),
|
||||
'Other OS Rules': stats.operatingSystems.other.toLocaleString(),
|
||||
|
||||
// Add severity levels
|
||||
...(stats.severityLevels || []).reduce((acc, level) => {
|
||||
const levelName = level.level
|
||||
? level.level.charAt(0).toUpperCase() + level.level.slice(1)
|
||||
: 'Unknown';
|
||||
|
||||
acc[`${levelName} Severity`] = level.count.toLocaleString();
|
||||
return acc;
|
||||
}, {})
|
||||
};
|
||||
|
||||
// Add top MITRE tactics if available
|
||||
if (stats.mitreTactics && stats.mitreTactics.length > 0) {
|
||||
stats.mitreTactics.forEach((tactic, index) => {
|
||||
if (index < 5) { // Only include top 5 for brevity
|
||||
const formattedTactic = tactic.tactic
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
formattedStats[`MITRE: ${formattedTactic}`] = tactic.count.toLocaleString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return formattedStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Sigma search results for CLI display
|
||||
*
|
||||
* @param {Object} searchResults - The search results object
|
||||
* @returns {Object} Formatted results ready for CLI display
|
||||
*/
|
||||
function formatSigmaSearchResults(searchResults) {
|
||||
if (!searchResults || !searchResults.results) {
|
||||
return { error: 'No search results available' };
|
||||
}
|
||||
|
||||
// Return a structure with results and meta info
|
||||
return {
|
||||
results: searchResults.results.map(rule => ({
|
||||
id: rule.id || '',
|
||||
title: rule.title || '',
|
||||
author: rule.author || 'Unknown',
|
||||
level: rule.level || 'medium'
|
||||
})),
|
||||
totalCount: searchResults.totalCount || 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Sigma rule details for CLI display
|
||||
*
|
||||
* @param {Object} ruleDetails - The rule details object
|
||||
* @returns {Object} Formatted details ready for CLI display
|
||||
*/
|
||||
function formatSigmaDetails(ruleDetails) {
|
||||
if (!ruleDetails) {
|
||||
return { error: 'No rule details available' };
|
||||
}
|
||||
|
||||
// Filter and format the rule details for CLI display
|
||||
const formattedDetails = {};
|
||||
|
||||
// Include only the most important fields for display
|
||||
const fieldsToInclude = [
|
||||
'id', 'title', 'description', 'status', 'author',
|
||||
'level', 'falsepositives', 'references',
|
||||
'created', 'modified'
|
||||
];
|
||||
|
||||
// Add detection information if available
|
||||
if (ruleDetails.detection && ruleDetails.detection.condition) {
|
||||
fieldsToInclude.push('detection_condition');
|
||||
formattedDetails['detection_condition'] = ruleDetails.detection.condition;
|
||||
}
|
||||
|
||||
// Add logsource information if available
|
||||
if (ruleDetails.logsource) {
|
||||
if (ruleDetails.logsource.product) {
|
||||
fieldsToInclude.push('logsource_product');
|
||||
formattedDetails['logsource_product'] = ruleDetails.logsource.product;
|
||||
}
|
||||
|
||||
if (ruleDetails.logsource.category) {
|
||||
fieldsToInclude.push('logsource_category');
|
||||
formattedDetails['logsource_category'] = ruleDetails.logsource.category;
|
||||
}
|
||||
|
||||
if (ruleDetails.logsource.service) {
|
||||
fieldsToInclude.push('logsource_service');
|
||||
formattedDetails['logsource_service'] = ruleDetails.logsource.service;
|
||||
}
|
||||
}
|
||||
|
||||
// Format date fields
|
||||
const dateFields = ['created', 'modified'];
|
||||
|
||||
for (const [key, value] of Object.entries(ruleDetails)) {
|
||||
if (fieldsToInclude.includes(key)) {
|
||||
// Format dates
|
||||
if (dateFields.includes(key) && value) {
|
||||
try {
|
||||
formattedDetails[key] = new Date(value).toLocaleString();
|
||||
} catch (e) {
|
||||
formattedDetails[key] = value;
|
||||
}
|
||||
}
|
||||
// Format arrays
|
||||
else if (Array.isArray(value)) {
|
||||
formattedDetails[key] = value.join(', ');
|
||||
}
|
||||
// Default handling
|
||||
else {
|
||||
formattedDetails[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formattedDetails;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatSigmaStats,
|
||||
formatSigmaSearchResults,
|
||||
formatSigmaDetails
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* logger.js
|
||||
* Handles logging functionality
|
||||
*
|
||||
* Handles logging functionality with CLI mode support
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
@ -25,12 +26,29 @@ if (!fs.existsSync(LOGS_DIR)) {
|
|||
}
|
||||
|
||||
// Use log file from config if available, otherwise use default
|
||||
const LOG_FILE = LOGGING_CONFIG?.file
|
||||
? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file)
|
||||
const LOG_FILE = LOGGING_CONFIG?.file
|
||||
? path.resolve(path.join(__dirname, '..', '..'), LOGGING_CONFIG.file)
|
||||
: path.join(LOGS_DIR, 'fylgja.log');
|
||||
|
||||
// Flag to determine if we're running in CLI mode
|
||||
let isCliMode = false;
|
||||
|
||||
// Create logger object
|
||||
const logger = {
|
||||
/**
|
||||
* Set the CLI mode flag
|
||||
* @param {boolean} mode - True to enable CLI mode (no console output)
|
||||
*/
|
||||
setCliMode: (mode) => {
|
||||
isCliMode = !!mode;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if running in CLI mode
|
||||
* @returns {boolean} CLI mode status
|
||||
*/
|
||||
isCliMode: () => isCliMode,
|
||||
|
||||
/**
|
||||
* Internal method to write log entry to file and console if level meets threshold
|
||||
* @param {string} level - Log level (DEBUG, INFO, WARN, ERROR)
|
||||
|
@ -39,7 +57,7 @@ const logger = {
|
|||
_writeToFile: (level, message) => {
|
||||
// Check if this log level should be displayed based on configured level
|
||||
const levelValue = LOG_LEVELS[level] || 0;
|
||||
|
||||
|
||||
if (levelValue >= configuredLevelValue) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logEntry = `${timestamp} ${level}: ${message}\n`;
|
||||
|
@ -48,23 +66,28 @@ const logger = {
|
|||
try {
|
||||
fs.appendFileSync(LOG_FILE, logEntry);
|
||||
} catch (err) {
|
||||
console.error(`Failed to write to log file: ${err.message}`);
|
||||
// If in CLI mode, don't output to console
|
||||
if (!isCliMode) {
|
||||
console.error(`Failed to write to log file: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also log to console with appropriate method
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
console.error(logEntry.trim());
|
||||
break;
|
||||
case 'WARN':
|
||||
console.warn(logEntry.trim());
|
||||
break;
|
||||
case 'DEBUG':
|
||||
console.debug(logEntry.trim());
|
||||
break;
|
||||
case 'INFO':
|
||||
default:
|
||||
console.info(logEntry.trim());
|
||||
// Only log to console if not in CLI mode
|
||||
if (!isCliMode) {
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
console.error(logEntry.trim());
|
||||
break;
|
||||
case 'WARN':
|
||||
console.warn(logEntry.trim());
|
||||
break;
|
||||
case 'DEBUG':
|
||||
console.debug(logEntry.trim());
|
||||
break;
|
||||
case 'INFO':
|
||||
default:
|
||||
console.info(logEntry.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue