@profullstack/threatcrush 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +3926 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
- package/src/commands/init.ts +211 -0
- package/src/commands/modules.ts +67 -0
- package/src/commands/monitor.ts +208 -0
- package/src/commands/scan.ts +212 -0
- package/src/commands/status.ts +86 -0
- package/src/core/config.ts +96 -0
- package/src/core/log-parser.ts +149 -0
- package/src/core/logger.ts +62 -0
- package/src/core/module-loader.ts +73 -0
- package/src/core/state.ts +146 -0
- package/src/index.ts +114 -0
- package/src/types/config.ts +63 -0
- package/src/types/events.ts +54 -0
- package/src/types/module.ts +42 -0
- package/tsconfig.json +19 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, extname } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { banner, logger } from '../core/logger.js';
|
|
6
|
+
|
|
7
|
+
interface ScanFinding {
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
type: string;
|
|
11
|
+
severity: 'low' | 'medium' | 'high' | 'critical';
|
|
12
|
+
message: string;
|
|
13
|
+
snippet: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Secret patterns
|
|
17
|
+
const SECRET_PATTERNS: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
pattern: RegExp;
|
|
20
|
+
severity: 'medium' | 'high' | 'critical';
|
|
21
|
+
}> = [
|
|
22
|
+
{ name: 'AWS Access Key', pattern: /(?:AKIA[0-9A-Z]{16})/g, severity: 'critical' },
|
|
23
|
+
{ name: 'AWS Secret Key', pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi, severity: 'critical' },
|
|
24
|
+
{ name: 'GitHub Token', pattern: /(?:ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82})/g, severity: 'critical' },
|
|
25
|
+
{ name: 'Generic API Key', pattern: /(?:api[_-]?key|apikey)\s*[=:]\s*['"]([A-Za-z0-9\-_]{20,})['"]?/gi, severity: 'high' },
|
|
26
|
+
{ name: 'Generic Secret', pattern: /(?:secret|password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"]?/gi, severity: 'high' },
|
|
27
|
+
{ name: 'Private Key', pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g, severity: 'critical' },
|
|
28
|
+
{ name: 'JWT Token', pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: 'high' },
|
|
29
|
+
{ name: 'Slack Token', pattern: /xox[bpors]-[A-Za-z0-9-]{10,}/g, severity: 'critical' },
|
|
30
|
+
{ name: 'Stripe Key', pattern: /(?:sk_live_|pk_live_|sk_test_|pk_test_)[A-Za-z0-9]{20,}/g, severity: 'critical' },
|
|
31
|
+
{ name: 'Database URL', pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s'"]+/gi, severity: 'high' },
|
|
32
|
+
{ name: 'Bearer Token', pattern: /Bearer\s+[A-Za-z0-9\-_\.]{20,}/g, severity: 'medium' },
|
|
33
|
+
{ name: 'Hex Token (32+)', pattern: /(?:token|key|secret|auth)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/gi, severity: 'medium' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// File permission / misconfig checks
|
|
37
|
+
const MISCONFIG_FILES = [
|
|
38
|
+
{ pattern: '.env', message: '.env file found — may contain secrets' },
|
|
39
|
+
{ pattern: '.env.local', message: '.env.local file found — may contain secrets' },
|
|
40
|
+
{ pattern: '.env.production', message: '.env.production file found — may contain secrets' },
|
|
41
|
+
{ pattern: 'id_rsa', message: 'Private SSH key found' },
|
|
42
|
+
{ pattern: 'id_ed25519', message: 'Private SSH key found' },
|
|
43
|
+
{ pattern: '.pem', message: 'PEM certificate/key file found' },
|
|
44
|
+
{ pattern: '.p12', message: 'PKCS#12 keystore found' },
|
|
45
|
+
{ pattern: '.keystore', message: 'Keystore file found' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const SKIP_DIRS = new Set([
|
|
49
|
+
'node_modules', '.git', '.next', 'dist', 'build', '__pycache__',
|
|
50
|
+
'.venv', 'vendor', '.terraform', 'coverage', '.cache',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const SCAN_EXTENSIONS = new Set([
|
|
54
|
+
'.ts', '.js', '.tsx', '.jsx', '.py', '.rb', '.go', '.java',
|
|
55
|
+
'.php', '.rs', '.c', '.cpp', '.h', '.yml', '.yaml', '.json',
|
|
56
|
+
'.toml', '.ini', '.cfg', '.conf', '.env', '.sh', '.bash',
|
|
57
|
+
'.tf', '.hcl', '.xml', '.properties', '.gradle',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
export async function scanCommand(targetPath: string): Promise<void> {
|
|
61
|
+
banner();
|
|
62
|
+
logger.info(`Scanning ${chalk.white(targetPath)} for security issues...\n`);
|
|
63
|
+
|
|
64
|
+
const spinner = ora({ text: 'Scanning files...', color: 'green' }).start();
|
|
65
|
+
const findings: ScanFinding[] = [];
|
|
66
|
+
let filesScanned = 0;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
scanDirectory(targetPath, targetPath, findings, () => {
|
|
70
|
+
filesScanned++;
|
|
71
|
+
spinner.text = `Scanning files... (${filesScanned} files)`;
|
|
72
|
+
});
|
|
73
|
+
} catch (err: any) {
|
|
74
|
+
spinner.fail(`Scan failed: ${err.message}`);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
spinner.succeed(`Scanned ${filesScanned} files\n`);
|
|
79
|
+
|
|
80
|
+
// Print results
|
|
81
|
+
if (findings.length === 0) {
|
|
82
|
+
console.log(chalk.green.bold(' ✓ No security issues found!'));
|
|
83
|
+
console.log();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Group by severity
|
|
88
|
+
const critical = findings.filter((f) => f.severity === 'critical');
|
|
89
|
+
const high = findings.filter((f) => f.severity === 'high');
|
|
90
|
+
const medium = findings.filter((f) => f.severity === 'medium');
|
|
91
|
+
const low = findings.filter((f) => f.severity === 'low');
|
|
92
|
+
|
|
93
|
+
console.log(chalk.white.bold(' Scan Results'));
|
|
94
|
+
console.log(chalk.gray(' ' + '─'.repeat(70)));
|
|
95
|
+
console.log(
|
|
96
|
+
` ${chalk.red.bold(critical.length + ' critical')} ` +
|
|
97
|
+
`${chalk.red(high.length + ' high')} ` +
|
|
98
|
+
`${chalk.yellow(medium.length + ' medium')} ` +
|
|
99
|
+
`${chalk.gray(low.length + ' low')}`,
|
|
100
|
+
);
|
|
101
|
+
console.log(chalk.gray(' ' + '─'.repeat(70)));
|
|
102
|
+
console.log();
|
|
103
|
+
|
|
104
|
+
const allFindings = [...critical, ...high, ...medium, ...low];
|
|
105
|
+
for (const finding of allFindings) {
|
|
106
|
+
const sev =
|
|
107
|
+
finding.severity === 'critical' ? chalk.bgRed.white.bold(` ${finding.severity.toUpperCase()} `) :
|
|
108
|
+
finding.severity === 'high' ? chalk.red(`[${finding.severity.toUpperCase()}]`) :
|
|
109
|
+
finding.severity === 'medium' ? chalk.yellow(`[${finding.severity.toUpperCase()}]`) :
|
|
110
|
+
chalk.gray(`[${finding.severity.toUpperCase()}]`);
|
|
111
|
+
|
|
112
|
+
console.log(` ${sev} ${chalk.white.bold(finding.type)}`);
|
|
113
|
+
console.log(` ${chalk.gray('File:')} ${chalk.cyan(finding.file)}:${chalk.yellow(String(finding.line))}`);
|
|
114
|
+
console.log(` ${chalk.gray('Info:')} ${finding.message}`);
|
|
115
|
+
if (finding.snippet) {
|
|
116
|
+
// Redact the actual secret value
|
|
117
|
+
const redacted = finding.snippet.replace(
|
|
118
|
+
/(['"]?)([A-Za-z0-9+/=\-_]{16,})(['"]?)/g,
|
|
119
|
+
'$1' + chalk.red('*'.repeat(16)) + '$3',
|
|
120
|
+
);
|
|
121
|
+
console.log(` ${chalk.gray('Code:')} ${redacted.trim()}`);
|
|
122
|
+
}
|
|
123
|
+
console.log();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(chalk.gray(' ' + '─'.repeat(70)));
|
|
127
|
+
console.log(` ${chalk.white.bold(`${findings.length} issue(s) found`)} across ${filesScanned} files`);
|
|
128
|
+
console.log();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scanDirectory(
|
|
132
|
+
basePath: string,
|
|
133
|
+
currentPath: string,
|
|
134
|
+
findings: ScanFinding[],
|
|
135
|
+
onFile: () => void,
|
|
136
|
+
): void {
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
entries = readdirSync(currentPath, { withFileTypes: true });
|
|
140
|
+
} catch {
|
|
141
|
+
return; // Permission denied or similar
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const fullPath = join(currentPath, entry.name);
|
|
146
|
+
|
|
147
|
+
if (entry.isDirectory()) {
|
|
148
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
149
|
+
scanDirectory(basePath, fullPath, findings, onFile);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!entry.isFile()) continue;
|
|
154
|
+
|
|
155
|
+
// Check for misconfig files
|
|
156
|
+
for (const mc of MISCONFIG_FILES) {
|
|
157
|
+
if (entry.name === mc.pattern || entry.name.endsWith(mc.pattern)) {
|
|
158
|
+
findings.push({
|
|
159
|
+
file: relative(basePath, fullPath),
|
|
160
|
+
line: 0,
|
|
161
|
+
type: 'Sensitive File',
|
|
162
|
+
severity: 'high',
|
|
163
|
+
message: mc.message,
|
|
164
|
+
snippet: '',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Only scan text files with known extensions
|
|
170
|
+
const ext = extname(entry.name).toLowerCase();
|
|
171
|
+
if (!SCAN_EXTENSIONS.has(ext) && !entry.name.startsWith('.env')) continue;
|
|
172
|
+
|
|
173
|
+
// Skip large files
|
|
174
|
+
try {
|
|
175
|
+
const stat = statSync(fullPath);
|
|
176
|
+
if (stat.size > 1024 * 1024) continue; // Skip >1MB
|
|
177
|
+
} catch {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
onFile();
|
|
182
|
+
|
|
183
|
+
// Scan file content
|
|
184
|
+
let content: string;
|
|
185
|
+
try {
|
|
186
|
+
content = readFileSync(fullPath, 'utf-8');
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const lines = content.split('\n');
|
|
192
|
+
for (let i = 0; i < lines.length; i++) {
|
|
193
|
+
const line = lines[i];
|
|
194
|
+
// Skip comments
|
|
195
|
+
if (line.trim().startsWith('//') && !line.includes('password') && !line.includes('secret')) continue;
|
|
196
|
+
|
|
197
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
198
|
+
pattern.pattern.lastIndex = 0;
|
|
199
|
+
if (pattern.pattern.test(line)) {
|
|
200
|
+
findings.push({
|
|
201
|
+
file: relative(basePath, fullPath),
|
|
202
|
+
line: i + 1,
|
|
203
|
+
type: pattern.name,
|
|
204
|
+
severity: pattern.severity,
|
|
205
|
+
message: `Possible ${pattern.name} detected`,
|
|
206
|
+
snippet: line.length > 120 ? line.slice(0, 120) + '...' : line,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { banner, logger } from '../core/logger.js';
|
|
3
|
+
import { discoverModules } from '../core/module-loader.js';
|
|
4
|
+
import { initStateDB, getEventCount, getThreatCount, getRecentEvents } from '../core/state.js';
|
|
5
|
+
|
|
6
|
+
export async function statusCommand(): Promise<void> {
|
|
7
|
+
banner();
|
|
8
|
+
|
|
9
|
+
// Check if daemon is running (look for PID file or process)
|
|
10
|
+
const daemonRunning = checkDaemon();
|
|
11
|
+
|
|
12
|
+
console.log(chalk.green.bold(' Daemon Status'));
|
|
13
|
+
console.log(chalk.gray(' ' + '─'.repeat(50)));
|
|
14
|
+
console.log(` Status: ${daemonRunning ? chalk.green.bold('● RUNNING') : chalk.yellow('○ NOT RUNNING')}`);
|
|
15
|
+
console.log(` PID: ${daemonRunning ? chalk.white('—') : chalk.gray('N/A')}`);
|
|
16
|
+
console.log(` Uptime: ${daemonRunning ? chalk.white('—') : chalk.gray('N/A')}`);
|
|
17
|
+
console.log();
|
|
18
|
+
|
|
19
|
+
// Show modules
|
|
20
|
+
const modules = discoverModules();
|
|
21
|
+
console.log(chalk.green.bold(' Loaded Modules'));
|
|
22
|
+
console.log(chalk.gray(' ' + '─'.repeat(50)));
|
|
23
|
+
|
|
24
|
+
if (modules.length === 0) {
|
|
25
|
+
console.log(chalk.gray(' No modules found. Run `threatcrush init` to set up.'));
|
|
26
|
+
} else {
|
|
27
|
+
for (const mod of modules) {
|
|
28
|
+
const enabled = mod.config.enabled !== false;
|
|
29
|
+
const status = enabled ? chalk.green('● enabled ') : chalk.gray('○ disabled');
|
|
30
|
+
console.log(` ${status} ${chalk.white.bold(mod.manifest.name.padEnd(18))} ${chalk.gray('v' + mod.manifest.version)}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
console.log();
|
|
34
|
+
|
|
35
|
+
// Show event stats
|
|
36
|
+
try {
|
|
37
|
+
initStateDB();
|
|
38
|
+
const totalEvents = getEventCount();
|
|
39
|
+
const totalThreats = getThreatCount();
|
|
40
|
+
const last24h = getEventCount(new Date(Date.now() - 86400000));
|
|
41
|
+
const threats24h = getThreatCount(new Date(Date.now() - 86400000));
|
|
42
|
+
|
|
43
|
+
console.log(chalk.green.bold(' Event Statistics'));
|
|
44
|
+
console.log(chalk.gray(' ' + '─'.repeat(50)));
|
|
45
|
+
console.log(` Total events: ${chalk.white(totalEvents.toString())}`);
|
|
46
|
+
console.log(` Total threats: ${chalk.red(totalThreats.toString())}`);
|
|
47
|
+
console.log(` Events (24h): ${chalk.white(last24h.toString())}`);
|
|
48
|
+
console.log(` Threats (24h): ${chalk.red(threats24h.toString())}`);
|
|
49
|
+
console.log();
|
|
50
|
+
|
|
51
|
+
// Show recent events
|
|
52
|
+
const recent = getRecentEvents(5);
|
|
53
|
+
if (recent.length > 0) {
|
|
54
|
+
console.log(chalk.green.bold(' Recent Events'));
|
|
55
|
+
console.log(chalk.gray(' ' + '─'.repeat(50)));
|
|
56
|
+
for (const evt of recent) {
|
|
57
|
+
const sev = evt.severity === 'critical' || evt.severity === 'high'
|
|
58
|
+
? chalk.red(`[${evt.severity.toUpperCase()}]`)
|
|
59
|
+
: evt.severity === 'medium'
|
|
60
|
+
? chalk.yellow(`[${evt.severity.toUpperCase()}]`)
|
|
61
|
+
: chalk.green(`[${evt.severity.toUpperCase()}]`);
|
|
62
|
+
console.log(` ${chalk.gray(evt.timestamp.toISOString().slice(0, 19))} ${sev.padEnd(20)} ${evt.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
console.log(chalk.gray(' No event data available. Start monitoring to collect events.'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkDaemon(): boolean {
|
|
73
|
+
try {
|
|
74
|
+
const { execSync } = await_import_child_process();
|
|
75
|
+
execSync('pgrep -f threatcrushd', { stdio: 'pipe' });
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Avoid top-level await for dynamic import
|
|
83
|
+
function await_import_child_process() {
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
85
|
+
return require('node:child_process');
|
|
86
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import TOML from '@iarna/toml';
|
|
4
|
+
import type { ThreatCrushConfig, ModuleConfig } from '../types/config.js';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_CONFIG_PATH = '/etc/threatcrush/threatcrushd.conf';
|
|
7
|
+
const DEFAULT_CONFDIR = '/etc/threatcrush/threatcrushd.conf.d';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG: ThreatCrushConfig = {
|
|
10
|
+
daemon: {
|
|
11
|
+
pid_file: '/var/run/threatcrush/threatcrushd.pid',
|
|
12
|
+
log_level: 'info',
|
|
13
|
+
log_file: '/var/log/threatcrush/threatcrushd.log',
|
|
14
|
+
state_db: '/var/lib/threatcrush/state.db',
|
|
15
|
+
},
|
|
16
|
+
api: {
|
|
17
|
+
enabled: true,
|
|
18
|
+
bind: '127.0.0.1:9393',
|
|
19
|
+
tls: false,
|
|
20
|
+
},
|
|
21
|
+
alerts: {},
|
|
22
|
+
modules: {
|
|
23
|
+
auto_update: true,
|
|
24
|
+
update_interval: '24h',
|
|
25
|
+
module_dir: '/etc/threatcrush/modules',
|
|
26
|
+
config_dir: DEFAULT_CONFDIR,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function loadConfig(configPath?: string): ThreatCrushConfig {
|
|
31
|
+
const path = configPath || DEFAULT_CONFIG_PATH;
|
|
32
|
+
if (!existsSync(path)) {
|
|
33
|
+
return { ...DEFAULT_CONFIG };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const raw = readFileSync(path, 'utf-8');
|
|
38
|
+
const parsed = TOML.parse(raw) as unknown as Partial<ThreatCrushConfig>;
|
|
39
|
+
return {
|
|
40
|
+
daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
|
|
41
|
+
api: { ...DEFAULT_CONFIG.api, ...parsed.api },
|
|
42
|
+
alerts: parsed.alerts || {},
|
|
43
|
+
modules: { ...DEFAULT_CONFIG.modules, ...parsed.modules },
|
|
44
|
+
license: parsed.license,
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return { ...DEFAULT_CONFIG };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function loadModuleConfigs(confDir?: string): Map<string, ModuleConfig> {
|
|
52
|
+
const dir = confDir || DEFAULT_CONFDIR;
|
|
53
|
+
const configs = new Map<string, ModuleConfig>();
|
|
54
|
+
|
|
55
|
+
if (!existsSync(dir)) {
|
|
56
|
+
return configs;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.conf'));
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
try {
|
|
62
|
+
const raw = readFileSync(join(dir, file), 'utf-8');
|
|
63
|
+
const parsed = TOML.parse(raw) as Record<string, unknown>;
|
|
64
|
+
for (const [name, config] of Object.entries(parsed)) {
|
|
65
|
+
configs.set(name, config as ModuleConfig);
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// skip bad configs
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return configs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function generateDefaultConfig(detectedServices: string[]): string {
|
|
76
|
+
const config: Record<string, unknown> = {
|
|
77
|
+
daemon: DEFAULT_CONFIG.daemon,
|
|
78
|
+
api: DEFAULT_CONFIG.api,
|
|
79
|
+
modules: DEFAULT_CONFIG.modules,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return TOML.stringify(config as any);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function generateModuleConfig(
|
|
86
|
+
moduleName: string,
|
|
87
|
+
defaults: Record<string, unknown> = {},
|
|
88
|
+
): string {
|
|
89
|
+
const config: Record<string, unknown> = {
|
|
90
|
+
[moduleName]: {
|
|
91
|
+
enabled: true,
|
|
92
|
+
...defaults,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
return TOML.stringify(config as any);
|
|
96
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { ParsedLogLine, NginxLogEntry, AuthLogEntry, SyslogEntry } from '../types/events.js';
|
|
2
|
+
|
|
3
|
+
// Nginx combined log format:
|
|
4
|
+
// 127.0.0.1 - - [04/Apr/2026:12:00:00 +0000] "GET /path HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
|
|
5
|
+
const NGINX_REGEX = /^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d{3}) (\d+) "[^"]*" "([^"]*)"/;
|
|
6
|
+
|
|
7
|
+
// Auth.log format:
|
|
8
|
+
// Apr 4 12:00:00 hostname sshd[1234]: Failed password for user from 1.2.3.4 port 22 ssh2
|
|
9
|
+
const AUTH_REGEX = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)/;
|
|
10
|
+
|
|
11
|
+
// Syslog format:
|
|
12
|
+
// Apr 4 12:00:00 hostname process[pid]: message
|
|
13
|
+
const SYSLOG_REGEX = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)/;
|
|
14
|
+
|
|
15
|
+
// Extract IP from auth messages
|
|
16
|
+
const IP_REGEX = /(?:from|FROM)\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
|
|
17
|
+
const USER_REGEX = /(?:for|user)\s+(\S+?)(?:\s+from|\s*$)/;
|
|
18
|
+
|
|
19
|
+
// Attack pattern signatures
|
|
20
|
+
export const ATTACK_PATTERNS = {
|
|
21
|
+
sqli: [
|
|
22
|
+
/(?:union\s+(?:all\s+)?select)/i,
|
|
23
|
+
/(?:select\s+.*\s+from\s+)/i,
|
|
24
|
+
/(?:insert\s+into\s+)/i,
|
|
25
|
+
/(?:drop\s+(?:table|database))/i,
|
|
26
|
+
/(?:or\s+1\s*=\s*1)/i,
|
|
27
|
+
/(?:'\s*(?:or|and)\s+')/i,
|
|
28
|
+
/(?:--\s*$|;\s*--)/,
|
|
29
|
+
/(?:\/\*.*\*\/)/,
|
|
30
|
+
],
|
|
31
|
+
xss: [
|
|
32
|
+
/<script[^>]*>/i,
|
|
33
|
+
/javascript\s*:/i,
|
|
34
|
+
/on(?:load|error|click|mouseover)\s*=/i,
|
|
35
|
+
/eval\s*\(/i,
|
|
36
|
+
/document\.(?:cookie|write|location)/i,
|
|
37
|
+
],
|
|
38
|
+
path_traversal: [
|
|
39
|
+
/\.\.\//,
|
|
40
|
+
/\.\.\\/,
|
|
41
|
+
/etc\/(?:passwd|shadow|hosts)/,
|
|
42
|
+
/proc\/self/,
|
|
43
|
+
/windows\/system32/i,
|
|
44
|
+
],
|
|
45
|
+
rfi: [
|
|
46
|
+
/(?:https?|ftp):\/\/.*\?/i,
|
|
47
|
+
/php:\/\/(?:input|filter)/i,
|
|
48
|
+
/data:\/\//i,
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function parseNginxLog(line: string): NginxLogEntry | null {
|
|
53
|
+
const match = line.match(NGINX_REGEX);
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
timestamp: parseNginxTimestamp(match[2]),
|
|
58
|
+
raw: line,
|
|
59
|
+
source: 'nginx',
|
|
60
|
+
fields: {
|
|
61
|
+
ip: match[1],
|
|
62
|
+
method: match[3],
|
|
63
|
+
path: match[4],
|
|
64
|
+
status: match[5],
|
|
65
|
+
size: match[6],
|
|
66
|
+
user_agent: match[7],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseAuthLog(line: string): AuthLogEntry | null {
|
|
72
|
+
const match = line.match(AUTH_REGEX);
|
|
73
|
+
if (!match) return null;
|
|
74
|
+
|
|
75
|
+
const ipMatch = match[3].match(IP_REGEX);
|
|
76
|
+
const userMatch = match[3].match(USER_REGEX);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
timestamp: parseSyslogTimestamp(match[1]),
|
|
80
|
+
raw: line,
|
|
81
|
+
source: 'auth',
|
|
82
|
+
fields: {
|
|
83
|
+
process: match[2],
|
|
84
|
+
message: match[3],
|
|
85
|
+
ip: ipMatch?.[1],
|
|
86
|
+
user: userMatch?.[1],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseSyslog(line: string): SyslogEntry | null {
|
|
92
|
+
const match = line.match(SYSLOG_REGEX);
|
|
93
|
+
if (!match) return null;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
timestamp: parseSyslogTimestamp(match[1]),
|
|
97
|
+
raw: line,
|
|
98
|
+
source: 'syslog',
|
|
99
|
+
fields: {
|
|
100
|
+
facility: 'syslog',
|
|
101
|
+
process: match[2],
|
|
102
|
+
message: match[3],
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function detectAttackPattern(path: string): string | null {
|
|
108
|
+
for (const [type, patterns] of Object.entries(ATTACK_PATTERNS)) {
|
|
109
|
+
for (const pattern of patterns) {
|
|
110
|
+
if (pattern.test(path)) {
|
|
111
|
+
return type;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function autoDetectParser(line: string): ParsedLogLine | null {
|
|
119
|
+
// Try nginx first (most specific format)
|
|
120
|
+
const nginx = parseNginxLog(line);
|
|
121
|
+
if (nginx) return nginx;
|
|
122
|
+
|
|
123
|
+
// Try auth log
|
|
124
|
+
const auth = parseAuthLog(line);
|
|
125
|
+
if (auth) return auth;
|
|
126
|
+
|
|
127
|
+
// Fall back to generic syslog
|
|
128
|
+
return parseSyslog(line);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseNginxTimestamp(s: string): Date {
|
|
132
|
+
// "04/Apr/2026:12:00:00 +0000"
|
|
133
|
+
try {
|
|
134
|
+
const cleaned = s.replace(/(\d{2})\/(\w{3})\/(\d{4}):/, '$2 $1, $3 ');
|
|
135
|
+
return new Date(cleaned);
|
|
136
|
+
} catch {
|
|
137
|
+
return new Date();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseSyslogTimestamp(s: string): Date {
|
|
142
|
+
// "Apr 4 12:00:00" — no year, assume current
|
|
143
|
+
try {
|
|
144
|
+
const withYear = `${s} ${new Date().getFullYear()}`;
|
|
145
|
+
return new Date(withYear);
|
|
146
|
+
} catch {
|
|
147
|
+
return new Date();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import type { EventSeverity } from '../types/events.js';
|
|
3
|
+
|
|
4
|
+
const SEVERITY_COLORS: Record<EventSeverity, (s: string) => string> = {
|
|
5
|
+
info: chalk.green,
|
|
6
|
+
low: chalk.cyan,
|
|
7
|
+
medium: chalk.yellow,
|
|
8
|
+
high: chalk.red,
|
|
9
|
+
critical: chalk.bgRed.white.bold,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const LEVEL_COLORS: Record<string, (s: string) => string> = {
|
|
13
|
+
debug: chalk.gray,
|
|
14
|
+
info: chalk.green,
|
|
15
|
+
warn: chalk.yellow,
|
|
16
|
+
error: chalk.red,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function severityColor(severity: EventSeverity, text: string): string {
|
|
20
|
+
return (SEVERITY_COLORS[severity] || chalk.white)(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatTimestamp(date: Date = new Date()): string {
|
|
24
|
+
return chalk.gray(date.toISOString().replace('T', ' ').slice(0, 19));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatEvent(
|
|
28
|
+
module: string,
|
|
29
|
+
severity: EventSeverity,
|
|
30
|
+
message: string,
|
|
31
|
+
ip?: string,
|
|
32
|
+
): string {
|
|
33
|
+
const ts = formatTimestamp();
|
|
34
|
+
const sev = severityColor(severity, `[${severity.toUpperCase()}]`.padEnd(10));
|
|
35
|
+
const mod = chalk.cyan(`[${module}]`.padEnd(14));
|
|
36
|
+
const src = ip ? chalk.magenta(` (${ip})`) : '';
|
|
37
|
+
return `${ts} ${sev} ${mod} ${message}${src}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const logger = {
|
|
41
|
+
debug: (msg: string) => console.log(`${formatTimestamp()} ${LEVEL_COLORS.debug('[DEBUG]')} ${msg}`),
|
|
42
|
+
info: (msg: string) => console.log(`${formatTimestamp()} ${LEVEL_COLORS.info('[INFO]')} ${msg}`),
|
|
43
|
+
warn: (msg: string) => console.log(`${formatTimestamp()} ${LEVEL_COLORS.warn('[WARN]')} ${msg}`),
|
|
44
|
+
error: (msg: string) => console.error(`${formatTimestamp()} ${LEVEL_COLORS.error('[ERROR]')} ${msg}`),
|
|
45
|
+
success: (msg: string) => console.log(`${formatTimestamp()} ${chalk.green('[OK]')} ${msg}`),
|
|
46
|
+
threat: (msg: string, ip?: string) => {
|
|
47
|
+
const src = ip ? chalk.magenta(` from ${ip}`) : '';
|
|
48
|
+
console.log(`${formatTimestamp()} ${chalk.red.bold('[THREAT]')} ${chalk.red(msg)}${src}`);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function banner(): void {
|
|
53
|
+
console.log(chalk.green.bold(`
|
|
54
|
+
████████╗██╗ ██╗██████╗ ███████╗ █████╗ ████████╗ ██████╗██████╗ ██╗ ██╗███████╗██╗ ██╗
|
|
55
|
+
╚══██╔══╝██║ ██║██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██╔════╝██╔══██╗██║ ██║██╔════╝██║ ██║
|
|
56
|
+
██║ ███████║██████╔╝█████╗ ███████║ ██║ ██║ ██████╔╝██║ ██║███████╗███████║
|
|
57
|
+
██║ ██╔══██║██╔══██╗██╔══╝ ██╔══██║ ██║ ██║ ██╔══██╗██║ ██║╚════██║██╔══██║
|
|
58
|
+
██║ ██║ ██║██║ ██║███████╗██║ ██║ ██║ ╚██████╗██║ ██║╚██████╔╝███████║██║ ██║
|
|
59
|
+
╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝
|
|
60
|
+
`));
|
|
61
|
+
console.log(chalk.gray(' All-in-one security agent daemon — v0.1.0\n'));
|
|
62
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import TOML from '@iarna/toml';
|
|
4
|
+
import type { LoadedModule } from '../types/module.js';
|
|
5
|
+
import type { ModuleConfig, ModuleManifest } from '../types/config.js';
|
|
6
|
+
import { loadModuleConfigs } from './config.js';
|
|
7
|
+
|
|
8
|
+
const SYSTEM_MODULE_DIR = '/etc/threatcrush/modules';
|
|
9
|
+
const SYSTEM_CONFDIR = '/etc/threatcrush/threatcrushd.conf.d';
|
|
10
|
+
|
|
11
|
+
export function discoverModules(
|
|
12
|
+
moduleDir?: string,
|
|
13
|
+
confDir?: string,
|
|
14
|
+
): LoadedModule[] {
|
|
15
|
+
const modules: LoadedModule[] = [];
|
|
16
|
+
const configs = loadModuleConfigs(confDir || SYSTEM_CONFDIR);
|
|
17
|
+
|
|
18
|
+
// Search paths: system dir + local ./modules/
|
|
19
|
+
const searchPaths = [
|
|
20
|
+
moduleDir || SYSTEM_MODULE_DIR,
|
|
21
|
+
resolve(process.cwd(), 'modules'),
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Also check for built-in modules relative to this package
|
|
25
|
+
const builtinDir = resolve(import.meta.dirname || '.', '..', 'modules');
|
|
26
|
+
if (existsSync(builtinDir)) {
|
|
27
|
+
searchPaths.push(builtinDir);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const basePath of searchPaths) {
|
|
31
|
+
if (!existsSync(basePath)) continue;
|
|
32
|
+
|
|
33
|
+
const entries = readdirSync(basePath, { withFileTypes: true });
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!entry.isDirectory()) continue;
|
|
36
|
+
|
|
37
|
+
const modPath = join(basePath, entry.name);
|
|
38
|
+
const manifestPath = join(modPath, 'mod.toml');
|
|
39
|
+
|
|
40
|
+
if (!existsSync(manifestPath)) continue;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(manifestPath, 'utf-8');
|
|
44
|
+
const manifest = TOML.parse(raw) as unknown as ModuleManifest;
|
|
45
|
+
const config = configs.get(manifest.module.name) || { enabled: true };
|
|
46
|
+
|
|
47
|
+
modules.push({
|
|
48
|
+
manifest: {
|
|
49
|
+
name: manifest.module.name,
|
|
50
|
+
version: manifest.module.version,
|
|
51
|
+
description: manifest.module.description,
|
|
52
|
+
author: manifest.module.author,
|
|
53
|
+
},
|
|
54
|
+
config,
|
|
55
|
+
status: 'loaded',
|
|
56
|
+
eventCount: 0,
|
|
57
|
+
path: modPath,
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
// skip bad modules
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return modules;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getBuiltinModulePaths(): string[] {
|
|
69
|
+
return [
|
|
70
|
+
resolve(import.meta.dirname || '.', '..', 'modules', 'log-watcher'),
|
|
71
|
+
resolve(import.meta.dirname || '.', '..', 'modules', 'ssh-guard'),
|
|
72
|
+
];
|
|
73
|
+
}
|