@profullstack/threatcrush 0.2.0 → 0.2.2
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/daemon.js +1933 -234
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +2560 -222
- package/dist/index.js.map +1 -1
- package/dist/systemd/threatcrushd.service +10 -3
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -2022,27 +2022,18 @@ var require_toml = __commonJS({
|
|
|
2022
2022
|
});
|
|
2023
2023
|
|
|
2024
2024
|
// src/daemon/index.ts
|
|
2025
|
-
var
|
|
2026
|
-
var
|
|
2025
|
+
var import_node_fs13 = require("fs");
|
|
2026
|
+
var import_node_path7 = require("path");
|
|
2027
2027
|
|
|
2028
2028
|
// src/daemon/paths.ts
|
|
2029
2029
|
var import_node_fs = require("fs");
|
|
2030
2030
|
var import_node_os = require("os");
|
|
2031
2031
|
var import_node_path = require("path");
|
|
2032
|
-
function
|
|
2033
|
-
|
|
2034
|
-
if (process.getuid && process.getuid() === 0) return true;
|
|
2035
|
-
try {
|
|
2036
|
-
if (!(0, import_node_fs.existsSync)("/etc/threatcrush")) return false;
|
|
2037
|
-
(0, import_node_fs.mkdirSync)("/etc/threatcrush/.probe", { recursive: true });
|
|
2038
|
-
return true;
|
|
2039
|
-
} catch {
|
|
2040
|
-
return false;
|
|
2041
|
-
}
|
|
2032
|
+
function isRoot() {
|
|
2033
|
+
return process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
|
|
2042
2034
|
}
|
|
2043
|
-
var systemMode = canWriteSystemPaths();
|
|
2044
2035
|
var userBase = (0, import_node_path.join)((0, import_node_os.homedir)(), ".threatcrush");
|
|
2045
|
-
var
|
|
2036
|
+
var SYSTEM_PATHS = {
|
|
2046
2037
|
mode: "system",
|
|
2047
2038
|
configDir: "/etc/threatcrush",
|
|
2048
2039
|
configFile: "/etc/threatcrush/threatcrushd.conf",
|
|
@@ -2055,7 +2046,8 @@ var PATHS = systemMode ? {
|
|
|
2055
2046
|
runDir: "/var/run/threatcrush",
|
|
2056
2047
|
pidFile: "/var/run/threatcrush/threatcrushd.pid",
|
|
2057
2048
|
socket: "/var/run/threatcrush/threatcrushd.sock"
|
|
2058
|
-
}
|
|
2049
|
+
};
|
|
2050
|
+
var USER_PATHS = {
|
|
2059
2051
|
mode: "user",
|
|
2060
2052
|
configDir: userBase,
|
|
2061
2053
|
configFile: (0, import_node_path.join)(userBase, "threatcrushd.conf"),
|
|
@@ -2069,6 +2061,7 @@ var PATHS = systemMode ? {
|
|
|
2069
2061
|
pidFile: (0, import_node_path.join)(userBase, "run", "threatcrushd.pid"),
|
|
2070
2062
|
socket: (0, import_node_path.join)(userBase, "run", "threatcrushd.sock")
|
|
2071
2063
|
};
|
|
2064
|
+
var PATHS = isRoot() ? SYSTEM_PATHS : USER_PATHS;
|
|
2072
2065
|
function ensureRuntimeDirs() {
|
|
2073
2066
|
for (const dir of [PATHS.configDir, PATHS.confD, PATHS.moduleDir, PATHS.logDir, PATHS.stateDir, PATHS.runDir]) {
|
|
2074
2067
|
try {
|
|
@@ -2100,8 +2093,9 @@ function isProcessAlive(pid) {
|
|
|
2100
2093
|
try {
|
|
2101
2094
|
process.kill(pid, 0);
|
|
2102
2095
|
return true;
|
|
2103
|
-
} catch {
|
|
2104
|
-
|
|
2096
|
+
} catch (err) {
|
|
2097
|
+
const code = err.code;
|
|
2098
|
+
return code === "EPERM";
|
|
2105
2099
|
}
|
|
2106
2100
|
}
|
|
2107
2101
|
function findRunningDaemon() {
|
|
@@ -2134,12 +2128,21 @@ bus.setMaxListeners(50);
|
|
|
2134
2128
|
// src/core/state.ts
|
|
2135
2129
|
var import_better_sqlite3 = __toESM(require("better-sqlite3"));
|
|
2136
2130
|
var db = null;
|
|
2131
|
+
var dbUnavailable = false;
|
|
2137
2132
|
function initStateDB(dbPath = "/var/lib/threatcrush/state.db") {
|
|
2138
2133
|
if (db) return db;
|
|
2134
|
+
if (dbUnavailable) {
|
|
2135
|
+
throw new Error("state db unavailable (previous init failed)");
|
|
2136
|
+
}
|
|
2139
2137
|
try {
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2138
|
+
try {
|
|
2139
|
+
db = new import_better_sqlite3.default(dbPath);
|
|
2140
|
+
} catch {
|
|
2141
|
+
db = new import_better_sqlite3.default(":memory:");
|
|
2142
|
+
}
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
dbUnavailable = true;
|
|
2145
|
+
throw err;
|
|
2143
2146
|
}
|
|
2144
2147
|
db.pragma("journal_mode = WAL");
|
|
2145
2148
|
db.exec(`
|
|
@@ -2175,7 +2178,8 @@ function initStateDB(dbPath = "/var/lib/threatcrush/state.db") {
|
|
|
2175
2178
|
return db;
|
|
2176
2179
|
}
|
|
2177
2180
|
function insertEvent(event) {
|
|
2178
|
-
const database =
|
|
2181
|
+
const database = tryDb();
|
|
2182
|
+
if (!database) return -1;
|
|
2179
2183
|
const stmt = database.prepare(`
|
|
2180
2184
|
INSERT INTO events (timestamp, module, category, severity, message, source_ip, details)
|
|
2181
2185
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -2191,22 +2195,34 @@ function insertEvent(event) {
|
|
|
2191
2195
|
);
|
|
2192
2196
|
return result.lastInsertRowid;
|
|
2193
2197
|
}
|
|
2198
|
+
function tryDb() {
|
|
2199
|
+
if (db) return db;
|
|
2200
|
+
if (dbUnavailable) return null;
|
|
2201
|
+
try {
|
|
2202
|
+
return initStateDB();
|
|
2203
|
+
} catch {
|
|
2204
|
+
return null;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2194
2207
|
function getRecentEvents(limit = 50) {
|
|
2195
|
-
const database =
|
|
2208
|
+
const database = tryDb();
|
|
2209
|
+
if (!database) return [];
|
|
2196
2210
|
const rows = database.prepare(`
|
|
2197
2211
|
SELECT * FROM events ORDER BY timestamp DESC LIMIT ?
|
|
2198
2212
|
`).all(limit);
|
|
2199
2213
|
return rows.map(rowToEvent);
|
|
2200
2214
|
}
|
|
2201
2215
|
function getEventCount(since) {
|
|
2202
|
-
const database =
|
|
2216
|
+
const database = tryDb();
|
|
2217
|
+
if (!database) return 0;
|
|
2203
2218
|
if (since) {
|
|
2204
2219
|
return database.prepare(`SELECT COUNT(*) as count FROM events WHERE timestamp >= ?`).get(since.toISOString()).count;
|
|
2205
2220
|
}
|
|
2206
2221
|
return database.prepare(`SELECT COUNT(*) as count FROM events`).get().count;
|
|
2207
2222
|
}
|
|
2208
2223
|
function getThreatCount(since) {
|
|
2209
|
-
const database =
|
|
2224
|
+
const database = tryDb();
|
|
2225
|
+
if (!database) return 0;
|
|
2210
2226
|
const severities = "('medium','high','critical')";
|
|
2211
2227
|
if (since) {
|
|
2212
2228
|
return database.prepare(
|
|
@@ -2218,13 +2234,30 @@ function getThreatCount(since) {
|
|
|
2218
2234
|
).get().count;
|
|
2219
2235
|
}
|
|
2220
2236
|
function getTopSources(limit = 10) {
|
|
2221
|
-
const database =
|
|
2237
|
+
const database = tryDb();
|
|
2238
|
+
if (!database) return [];
|
|
2222
2239
|
return database.prepare(`
|
|
2223
2240
|
SELECT source_ip as ip, COUNT(*) as count FROM events
|
|
2224
2241
|
WHERE source_ip IS NOT NULL
|
|
2225
2242
|
GROUP BY source_ip ORDER BY count DESC LIMIT ?
|
|
2226
2243
|
`).all(limit);
|
|
2227
2244
|
}
|
|
2245
|
+
function getModuleState(module2, key) {
|
|
2246
|
+
const database = db || initStateDB();
|
|
2247
|
+
const row = database.prepare(`SELECT value FROM module_state WHERE module = ? AND key = ?`).get(module2, key);
|
|
2248
|
+
if (!row) return void 0;
|
|
2249
|
+
try {
|
|
2250
|
+
return JSON.parse(row.value);
|
|
2251
|
+
} catch {
|
|
2252
|
+
return row.value;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
function setModuleState(module2, key, value) {
|
|
2256
|
+
const database = db || initStateDB();
|
|
2257
|
+
database.prepare(`
|
|
2258
|
+
INSERT OR REPLACE INTO module_state (module, key, value) VALUES (?, ?, ?)
|
|
2259
|
+
`).run(module2, key, JSON.stringify(value));
|
|
2260
|
+
}
|
|
2228
2261
|
function rowToEvent(row) {
|
|
2229
2262
|
return {
|
|
2230
2263
|
id: row.id,
|
|
@@ -2281,10 +2314,19 @@ var IpcServer = class {
|
|
|
2281
2314
|
this.server = (0, import_node_net.createServer)((sock) => this.handleClient(sock));
|
|
2282
2315
|
this.server.on("error", reject);
|
|
2283
2316
|
this.server.listen(PATHS.socket, () => {
|
|
2317
|
+
const nodeFs = require("fs");
|
|
2284
2318
|
try {
|
|
2285
|
-
|
|
2319
|
+
nodeFs.chmodSync(PATHS.socket, 432);
|
|
2286
2320
|
} catch {
|
|
2287
2321
|
}
|
|
2322
|
+
const isRoot2 = process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
|
|
2323
|
+
if (isRoot2) {
|
|
2324
|
+
try {
|
|
2325
|
+
const { gid } = nodeFs.statSync("/var/log/auth.log");
|
|
2326
|
+
nodeFs.chownSync(PATHS.socket, 0, gid);
|
|
2327
|
+
} catch {
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2288
2330
|
resolve();
|
|
2289
2331
|
});
|
|
2290
2332
|
});
|
|
@@ -2413,9 +2455,10 @@ var IpcServer = class {
|
|
|
2413
2455
|
};
|
|
2414
2456
|
|
|
2415
2457
|
// src/daemon/module-host.ts
|
|
2416
|
-
var
|
|
2417
|
-
var
|
|
2418
|
-
var
|
|
2458
|
+
var import_node_fs8 = require("fs");
|
|
2459
|
+
var import_node_path3 = require("path");
|
|
2460
|
+
var import_node_url = require("url");
|
|
2461
|
+
var import_toml2 = __toESM(require_toml());
|
|
2419
2462
|
|
|
2420
2463
|
// src/daemon/watchers/log-watcher.ts
|
|
2421
2464
|
var import_node_fs4 = require("fs");
|
|
@@ -2674,185 +2717,1021 @@ var LogWatcher = class {
|
|
|
2674
2717
|
}
|
|
2675
2718
|
};
|
|
2676
2719
|
|
|
2677
|
-
// src/daemon/
|
|
2678
|
-
var
|
|
2720
|
+
// src/daemon/watchers/journal-watcher.ts
|
|
2721
|
+
var import_node_child_process = require("child_process");
|
|
2722
|
+
var JournalWatcher = class _JournalWatcher {
|
|
2679
2723
|
constructor(bus2) {
|
|
2680
2724
|
this.bus = bus2;
|
|
2681
|
-
bus2.on("event", (event) => {
|
|
2682
|
-
const mod = this.modules.get(event.module);
|
|
2683
|
-
if (mod) mod.events++;
|
|
2684
|
-
});
|
|
2685
2725
|
}
|
|
2686
2726
|
bus;
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
mod.detail = `watching ${watched.length} log source(s)`;
|
|
2699
|
-
this.bus.announceModule(modName, "running", mod.detail);
|
|
2700
|
-
}
|
|
2701
|
-
}
|
|
2702
|
-
}
|
|
2703
|
-
async stop() {
|
|
2704
|
-
this.logWatcher?.stop();
|
|
2705
|
-
for (const mod of this.modules.values()) {
|
|
2706
|
-
mod.status = "loaded";
|
|
2707
|
-
this.bus.announceModule(mod.name, "stopped");
|
|
2708
|
-
}
|
|
2727
|
+
proc = null;
|
|
2728
|
+
buffer = "";
|
|
2729
|
+
moduleName = "user-journal";
|
|
2730
|
+
active = false;
|
|
2731
|
+
// When the daemon runs as root (system mode), tail the SYSTEM journal so
|
|
2732
|
+
// we pick up sshd / sudo / kernel / UFW events. Falling back to --user
|
|
2733
|
+
// would give us root's mostly-empty per-user journal. Otherwise we use
|
|
2734
|
+
// --user so the daemon can run unprivileged on a workstation.
|
|
2735
|
+
static scopeArgs() {
|
|
2736
|
+
const isRoot2 = process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
|
|
2737
|
+
return isRoot2 ? [] : ["--user"];
|
|
2709
2738
|
}
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
detail: m.detail
|
|
2716
|
-
}));
|
|
2739
|
+
static isAvailable() {
|
|
2740
|
+
const probe = (0, import_node_child_process.spawnSync)("journalctl", [...this.scopeArgs(), "-n", "0", "--no-pager"], {
|
|
2741
|
+
stdio: ["ignore", "ignore", "ignore"]
|
|
2742
|
+
});
|
|
2743
|
+
return probe.status === 0;
|
|
2717
2744
|
}
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2745
|
+
start() {
|
|
2746
|
+
if (!_JournalWatcher.isAvailable()) return false;
|
|
2747
|
+
const child = (0, import_node_child_process.spawn)(
|
|
2748
|
+
"journalctl",
|
|
2749
|
+
[..._JournalWatcher.scopeArgs(), "-o", "json", "-f", "--since", "now"],
|
|
2750
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
2751
|
+
);
|
|
2752
|
+
if (!child.stdout) return false;
|
|
2753
|
+
child.stdout.setEncoding("utf-8");
|
|
2754
|
+
child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
2755
|
+
child.on("exit", () => {
|
|
2756
|
+
this.proc = null;
|
|
2757
|
+
this.active = false;
|
|
2758
|
+
});
|
|
2759
|
+
this.proc = child;
|
|
2760
|
+
this.active = true;
|
|
2761
|
+
return true;
|
|
2724
2762
|
}
|
|
2725
|
-
|
|
2726
|
-
if (
|
|
2727
|
-
const entries = (0, import_node_fs5.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
|
|
2728
|
-
for (const entry of entries) {
|
|
2729
|
-
if (!entry.isDirectory()) continue;
|
|
2730
|
-
const manifestPath = (0, import_node_path2.join)(PATHS.moduleDir, entry.name, "mod.toml");
|
|
2731
|
-
if (!(0, import_node_fs5.existsSync)(manifestPath)) continue;
|
|
2763
|
+
stop() {
|
|
2764
|
+
if (this.proc) {
|
|
2732
2765
|
try {
|
|
2733
|
-
|
|
2734
|
-
const name = manifest.module?.name || entry.name;
|
|
2735
|
-
this.modules.set(name, {
|
|
2736
|
-
name,
|
|
2737
|
-
version: manifest.module?.version || "0.0.0",
|
|
2738
|
-
source: "installed",
|
|
2739
|
-
status: "loaded",
|
|
2740
|
-
events: 0
|
|
2741
|
-
});
|
|
2766
|
+
this.proc.kill("SIGTERM");
|
|
2742
2767
|
} catch {
|
|
2743
2768
|
}
|
|
2769
|
+
this.proc = null;
|
|
2744
2770
|
}
|
|
2771
|
+
this.active = false;
|
|
2745
2772
|
}
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2773
|
+
isActive() {
|
|
2774
|
+
return this.active;
|
|
2775
|
+
}
|
|
2776
|
+
moduleNameValue() {
|
|
2777
|
+
return this.moduleName;
|
|
2778
|
+
}
|
|
2779
|
+
onData(chunk) {
|
|
2780
|
+
this.buffer += chunk;
|
|
2781
|
+
let idx;
|
|
2782
|
+
while ((idx = this.buffer.indexOf("\n")) >= 0) {
|
|
2783
|
+
const line = this.buffer.slice(0, idx);
|
|
2784
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
2785
|
+
if (!line.trim()) continue;
|
|
2786
|
+
this.handleLine(line);
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
handleLine(line) {
|
|
2790
|
+
let entry;
|
|
2762
2791
|
try {
|
|
2763
|
-
|
|
2792
|
+
entry = JSON.parse(line);
|
|
2793
|
+
} catch {
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
const message = entry.MESSAGE;
|
|
2797
|
+
if (!message) return;
|
|
2798
|
+
const priority = parseInt(entry.PRIORITY ?? "6", 10);
|
|
2799
|
+
const severity = priorityToSeverity(priority);
|
|
2800
|
+
const ident = entry.SYSLOG_IDENTIFIER || entry._COMM || "journal";
|
|
2801
|
+
const bumpedSeverity = bumpForIdent(ident, message, severity);
|
|
2802
|
+
const event = {
|
|
2803
|
+
timestamp: realtimeToDate(entry.__REALTIME_TIMESTAMP) || /* @__PURE__ */ new Date(),
|
|
2804
|
+
module: this.moduleName,
|
|
2805
|
+
category: "system",
|
|
2806
|
+
severity: bumpedSeverity,
|
|
2807
|
+
message: `[${ident}] ${message}`.slice(0, 500)
|
|
2808
|
+
};
|
|
2809
|
+
try {
|
|
2810
|
+
insertEvent(event);
|
|
2764
2811
|
} catch {
|
|
2765
|
-
return null;
|
|
2766
2812
|
}
|
|
2813
|
+
this.bus.publish(event);
|
|
2767
2814
|
}
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
return
|
|
2775
|
-
}
|
|
2776
|
-
function meetsSeverity(event, min) {
|
|
2777
|
-
if (!min) return true;
|
|
2778
|
-
return (SEVERITY_RANK[event.severity] ?? 0) >= (SEVERITY_RANK[min] ?? 0);
|
|
2815
|
+
};
|
|
2816
|
+
function priorityToSeverity(priority) {
|
|
2817
|
+
if (priority <= 2) return "critical";
|
|
2818
|
+
if (priority === 3) return "high";
|
|
2819
|
+
if (priority === 4) return "medium";
|
|
2820
|
+
if (priority === 5) return "low";
|
|
2821
|
+
return "info";
|
|
2779
2822
|
}
|
|
2780
|
-
function
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
When: ${ts}
|
|
2789
|
-
Category: ${event.category}`;
|
|
2790
|
-
const html = `<div style="font-family:system-ui,sans-serif;line-height:1.5"><h2 style="margin:0 0 8px">\u26A0 ${event.severity.toUpperCase()} \u2014 ${event.module}</h2><p>${event.message}</p>` + (event.source_ip ? `<p><strong>Source IP:</strong> <code>${event.source_ip}</code></p>` : "") + `<p style="color:#888;margin-top:16px"><small>${ts} \xB7 ${event.category}</small></p></div>`;
|
|
2791
|
-
return { text, html };
|
|
2823
|
+
function bumpForIdent(ident, message, base) {
|
|
2824
|
+
if (/sudo/i.test(ident) && /authentication failure|incorrect password|FAILED/i.test(message)) {
|
|
2825
|
+
return "high";
|
|
2826
|
+
}
|
|
2827
|
+
if (/sshd/i.test(ident) && /failed|invalid user|break-in/i.test(message)) {
|
|
2828
|
+
return "high";
|
|
2829
|
+
}
|
|
2830
|
+
return base;
|
|
2792
2831
|
}
|
|
2793
|
-
function
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
const to = Array.isArray(config.to) ? config.to.join(", ") : config.to;
|
|
2799
|
-
if (!to) return;
|
|
2800
|
-
const { text, html } = renderBody(event);
|
|
2801
|
-
await t.sendMail({
|
|
2802
|
-
from: config.from,
|
|
2803
|
-
to,
|
|
2804
|
-
subject: `[ThreatCrush ${event.severity}] ${event.module} \u2014 ${event.message.slice(0, 60)}`,
|
|
2805
|
-
text,
|
|
2806
|
-
html
|
|
2807
|
-
});
|
|
2808
|
-
};
|
|
2832
|
+
function realtimeToDate(rt) {
|
|
2833
|
+
if (!rt) return null;
|
|
2834
|
+
const us = parseInt(rt, 10);
|
|
2835
|
+
if (!Number.isFinite(us)) return null;
|
|
2836
|
+
return new Date(Math.floor(us / 1e3));
|
|
2809
2837
|
}
|
|
2810
2838
|
|
|
2811
|
-
// src/
|
|
2812
|
-
var
|
|
2813
|
-
|
|
2839
|
+
// src/modules/network-monitor/index.ts
|
|
2840
|
+
var import_node_child_process2 = require("child_process");
|
|
2841
|
+
var import_node_fs5 = require("fs");
|
|
2842
|
+
var NetworkMonitor = class {
|
|
2843
|
+
constructor(bus2) {
|
|
2814
2844
|
this.bus = bus2;
|
|
2815
|
-
this.config = config;
|
|
2816
|
-
this.bindChannels();
|
|
2817
|
-
bus2.on("alert", (event) => {
|
|
2818
|
-
void this.dispatch(event);
|
|
2819
|
-
});
|
|
2820
2845
|
}
|
|
2821
2846
|
bus;
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2847
|
+
active = false;
|
|
2848
|
+
pollTimer = null;
|
|
2849
|
+
scanTrackers = /* @__PURE__ */ new Map();
|
|
2850
|
+
halfOpenTrackers = /* @__PURE__ */ new Map();
|
|
2851
|
+
lastConnections = /* @__PURE__ */ new Set();
|
|
2852
|
+
// Config
|
|
2853
|
+
pollIntervalMs = 5e3;
|
|
2854
|
+
portScanThreshold = 10;
|
|
2855
|
+
// unique ports in window
|
|
2856
|
+
portScanWindowMs = 3e4;
|
|
2857
|
+
synFloodThreshold = 50;
|
|
2858
|
+
// half-open connections
|
|
2859
|
+
synFloodWindowMs = 1e4;
|
|
2860
|
+
start() {
|
|
2861
|
+
if (!this.hasConntrackOrSs()) {
|
|
2862
|
+
return false;
|
|
2863
|
+
}
|
|
2864
|
+
this.active = true;
|
|
2865
|
+
this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
|
|
2866
|
+
return true;
|
|
2867
|
+
}
|
|
2868
|
+
stop() {
|
|
2869
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
2870
|
+
this.pollTimer = null;
|
|
2871
|
+
this.active = false;
|
|
2872
|
+
}
|
|
2873
|
+
isActive() {
|
|
2874
|
+
return this.active;
|
|
2875
|
+
}
|
|
2876
|
+
hasConntrackOrSs() {
|
|
2877
|
+
const ss = (0, import_node_child_process2.spawnSync)("ss", ["--version"], { stdio: "pipe" });
|
|
2878
|
+
if (ss.status === 0) return true;
|
|
2879
|
+
return (0, import_node_fs5.existsSync)("/proc/net/tcp");
|
|
2880
|
+
}
|
|
2881
|
+
poll() {
|
|
2882
|
+
try {
|
|
2883
|
+
const connections = this.getConnections();
|
|
2884
|
+
this.analyzePortScans(connections);
|
|
2885
|
+
this.analyzeSynFlood(connections);
|
|
2886
|
+
this.cleanupTrackers();
|
|
2887
|
+
} catch {
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
getConnections() {
|
|
2891
|
+
const records = [];
|
|
2892
|
+
const now = Date.now();
|
|
2893
|
+
try {
|
|
2894
|
+
const ct = (0, import_node_child_process2.spawnSync)("conntrack", ["-L", "-p", "tcp", "-o", "extended"], {
|
|
2895
|
+
encoding: "utf-8",
|
|
2896
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2897
|
+
timeout: 3e3
|
|
2898
|
+
});
|
|
2899
|
+
if (ct.status === 0 && ct.stdout) {
|
|
2900
|
+
for (const line of ct.stdout.split("\n")) {
|
|
2901
|
+
const srcMatch = line.match(/src=(\d+\.\d+\.\d+\.\d+)/);
|
|
2902
|
+
const dportMatch = line.match(/dport=(\d+)/);
|
|
2903
|
+
if (srcMatch && dportMatch) {
|
|
2904
|
+
records.push({ source_ip: srcMatch[1], dest_port: parseInt(dportMatch[1]), timestamp: now });
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
if (records.length > 0) return records;
|
|
2831
2908
|
}
|
|
2832
|
-
|
|
2833
|
-
|
|
2909
|
+
} catch {
|
|
2910
|
+
}
|
|
2911
|
+
try {
|
|
2912
|
+
const ss = (0, import_node_child_process2.spawnSync)("ss", ["-tnp", "-H"], {
|
|
2913
|
+
encoding: "utf-8",
|
|
2914
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2915
|
+
timeout: 3e3
|
|
2916
|
+
});
|
|
2917
|
+
if (ss.status === 0 && ss.stdout) {
|
|
2918
|
+
for (const line of ss.stdout.split("\n")) {
|
|
2919
|
+
const parts = line.trim().split(/\s+/);
|
|
2920
|
+
if (parts.length < 5) continue;
|
|
2921
|
+
const peerParts = parts[4].split(":");
|
|
2922
|
+
const localParts = parts[3].split(":");
|
|
2923
|
+
if (peerParts.length >= 2 && localParts.length >= 2) {
|
|
2924
|
+
const sourceIp = peerParts.slice(0, -1).join(":");
|
|
2925
|
+
const destPort = parseInt(localParts[localParts.length - 1]);
|
|
2926
|
+
if (sourceIp && !isNaN(destPort) && !this.isLocalIp(sourceIp)) {
|
|
2927
|
+
records.push({ source_ip: sourceIp, dest_port: destPort, timestamp: now });
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2834
2931
|
}
|
|
2835
|
-
|
|
2836
|
-
|
|
2932
|
+
} catch {
|
|
2933
|
+
}
|
|
2934
|
+
return records;
|
|
2935
|
+
}
|
|
2936
|
+
analyzePortScans(connections) {
|
|
2937
|
+
const now = Date.now();
|
|
2938
|
+
for (const conn of connections) {
|
|
2939
|
+
const key = conn.source_ip;
|
|
2940
|
+
let tracker = this.scanTrackers.get(key);
|
|
2941
|
+
if (!tracker) {
|
|
2942
|
+
tracker = { ports: /* @__PURE__ */ new Set(), firstSeen: now, lastSeen: now, count: 0 };
|
|
2943
|
+
this.scanTrackers.set(key, tracker);
|
|
2944
|
+
}
|
|
2945
|
+
tracker.ports.add(conn.dest_port);
|
|
2946
|
+
tracker.lastSeen = now;
|
|
2947
|
+
tracker.count++;
|
|
2948
|
+
if (tracker.ports.size >= this.portScanThreshold && now - tracker.firstSeen <= this.portScanWindowMs) {
|
|
2949
|
+
this.emitEvent(
|
|
2950
|
+
"high",
|
|
2951
|
+
`Port scan detected: ${conn.source_ip} probed ${tracker.ports.size} ports in ${Math.round((now - tracker.firstSeen) / 1e3)}s`,
|
|
2952
|
+
conn.source_ip,
|
|
2953
|
+
{ ports_scanned: tracker.ports.size, window_seconds: Math.round((now - tracker.firstSeen) / 1e3) }
|
|
2954
|
+
);
|
|
2955
|
+
this.scanTrackers.delete(key);
|
|
2837
2956
|
}
|
|
2838
2957
|
}
|
|
2839
2958
|
}
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2959
|
+
analyzeSynFlood(connections) {
|
|
2960
|
+
try {
|
|
2961
|
+
const ss = (0, import_node_child_process2.spawnSync)("ss", ["-tn", "state", "syn-recv", "-H"], {
|
|
2962
|
+
encoding: "utf-8",
|
|
2963
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2964
|
+
timeout: 3e3
|
|
2965
|
+
});
|
|
2966
|
+
if (ss.status !== 0 || !ss.stdout) return;
|
|
2967
|
+
const perSource = /* @__PURE__ */ new Map();
|
|
2968
|
+
for (const line of ss.stdout.split("\n")) {
|
|
2969
|
+
const parts = line.trim().split(/\s+/);
|
|
2970
|
+
if (parts.length < 5) continue;
|
|
2971
|
+
const peer = parts[4].split(":");
|
|
2972
|
+
const ip = peer.slice(0, -1).join(":");
|
|
2973
|
+
if (ip) perSource.set(ip, (perSource.get(ip) || 0) + 1);
|
|
2974
|
+
}
|
|
2975
|
+
for (const [ip, count] of perSource) {
|
|
2976
|
+
if (count >= this.synFloodThreshold) {
|
|
2977
|
+
this.emitEvent(
|
|
2978
|
+
"critical",
|
|
2979
|
+
`SYN flood indicators: ${count} half-open connections from ${ip}`,
|
|
2980
|
+
ip,
|
|
2981
|
+
{ half_open_count: count }
|
|
2982
|
+
);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
} catch {
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
emitEvent(severity, message, sourceIp, details) {
|
|
2989
|
+
const event = {
|
|
2990
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2991
|
+
module: "network-monitor",
|
|
2992
|
+
category: "network",
|
|
2993
|
+
severity,
|
|
2994
|
+
message,
|
|
2995
|
+
source_ip: sourceIp,
|
|
2996
|
+
details
|
|
2997
|
+
};
|
|
2998
|
+
try {
|
|
2999
|
+
insertEvent(event);
|
|
3000
|
+
} catch {
|
|
3001
|
+
}
|
|
3002
|
+
this.bus.publish(event);
|
|
3003
|
+
}
|
|
3004
|
+
cleanupTrackers() {
|
|
3005
|
+
const now = Date.now();
|
|
3006
|
+
for (const [key, tracker] of this.scanTrackers) {
|
|
3007
|
+
if (now - tracker.lastSeen > this.portScanWindowMs * 2) {
|
|
3008
|
+
this.scanTrackers.delete(key);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
isLocalIp(ip) {
|
|
3013
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0" || ip.startsWith("::ffff:127.");
|
|
2843
3014
|
}
|
|
2844
3015
|
};
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
3016
|
+
|
|
3017
|
+
// src/modules/dns-monitor/index.ts
|
|
3018
|
+
var import_node_fs6 = require("fs");
|
|
3019
|
+
var import_node_readline2 = require("readline");
|
|
3020
|
+
var DNS_LOG_SOURCES = [
|
|
3021
|
+
"/var/log/syslog",
|
|
3022
|
+
// systemd-resolved logs here
|
|
3023
|
+
"/var/log/dnsmasq.log",
|
|
3024
|
+
// dnsmasq
|
|
3025
|
+
"/var/log/named/queries.log",
|
|
3026
|
+
// bind9
|
|
3027
|
+
"/var/log/pihole.log"
|
|
3028
|
+
// Pi-hole
|
|
3029
|
+
];
|
|
3030
|
+
var DnsMonitor = class {
|
|
3031
|
+
// Shannon entropy threshold for DGA
|
|
3032
|
+
constructor(bus2) {
|
|
3033
|
+
this.bus = bus2;
|
|
3034
|
+
}
|
|
3035
|
+
bus;
|
|
3036
|
+
active = false;
|
|
3037
|
+
timers = /* @__PURE__ */ new Map();
|
|
3038
|
+
positions = /* @__PURE__ */ new Map();
|
|
3039
|
+
// Tracking windows
|
|
3040
|
+
txtQueryCounts = /* @__PURE__ */ new Map();
|
|
3041
|
+
domainBuffer = [];
|
|
3042
|
+
// Config
|
|
3043
|
+
txtRateThreshold = 20;
|
|
3044
|
+
// TXT queries per source per window
|
|
3045
|
+
txtWindowMs = 6e4;
|
|
3046
|
+
dgaBurstThreshold = 15;
|
|
3047
|
+
// unique high-entropy domains per window
|
|
3048
|
+
dgaWindowMs = 6e4;
|
|
3049
|
+
entropyThreshold = 3.5;
|
|
3050
|
+
start() {
|
|
3051
|
+
const sources = DNS_LOG_SOURCES.filter((p) => {
|
|
3052
|
+
if (!(0, import_node_fs6.existsSync)(p)) return false;
|
|
3053
|
+
try {
|
|
3054
|
+
(0, import_node_fs6.accessSync)(p, import_node_fs6.constants.R_OK);
|
|
3055
|
+
return true;
|
|
3056
|
+
} catch {
|
|
3057
|
+
return false;
|
|
3058
|
+
}
|
|
3059
|
+
});
|
|
3060
|
+
if (sources.length === 0) return false;
|
|
3061
|
+
this.active = true;
|
|
3062
|
+
for (const src of sources) {
|
|
3063
|
+
this.tailLog(src);
|
|
3064
|
+
}
|
|
3065
|
+
setInterval(() => this.analyzeBuffer(), 1e4);
|
|
3066
|
+
return true;
|
|
3067
|
+
}
|
|
3068
|
+
stop() {
|
|
3069
|
+
for (const t of this.timers.values()) clearInterval(t);
|
|
3070
|
+
this.timers.clear();
|
|
3071
|
+
this.active = false;
|
|
3072
|
+
}
|
|
3073
|
+
isActive() {
|
|
3074
|
+
return this.active;
|
|
3075
|
+
}
|
|
3076
|
+
tailLog(path) {
|
|
3077
|
+
try {
|
|
3078
|
+
this.positions.set(path, (0, import_node_fs6.statSync)(path).size);
|
|
3079
|
+
} catch {
|
|
3080
|
+
this.positions.set(path, 0);
|
|
3081
|
+
}
|
|
3082
|
+
const timer = setInterval(() => this.pollLog(path), 2e3);
|
|
3083
|
+
this.timers.set(path, timer);
|
|
3084
|
+
}
|
|
3085
|
+
pollLog(path) {
|
|
3086
|
+
let stat;
|
|
3087
|
+
try {
|
|
3088
|
+
stat = (0, import_node_fs6.statSync)(path);
|
|
3089
|
+
} catch {
|
|
3090
|
+
return;
|
|
3091
|
+
}
|
|
3092
|
+
const prev = this.positions.get(path) ?? 0;
|
|
3093
|
+
if (stat.size < prev) {
|
|
3094
|
+
this.positions.set(path, 0);
|
|
3095
|
+
return;
|
|
3096
|
+
}
|
|
3097
|
+
if (stat.size === prev) return;
|
|
3098
|
+
const stream = (0, import_node_fs6.createReadStream)(path, { start: prev, encoding: "utf-8" });
|
|
3099
|
+
stream.on("error", () => this.positions.set(path, stat.size));
|
|
3100
|
+
const rl = (0, import_node_readline2.createInterface)({ input: stream });
|
|
3101
|
+
rl.on("line", (line) => this.parseDnsLine(line));
|
|
3102
|
+
rl.on("close", () => this.positions.set(path, stat.size));
|
|
3103
|
+
}
|
|
3104
|
+
parseDnsLine(line) {
|
|
3105
|
+
const resolvedMatch = line.match(/query\[(\w+)\]\s+(\S+)\s+from\s+(\S+)/i);
|
|
3106
|
+
if (resolvedMatch) {
|
|
3107
|
+
this.domainBuffer.push({
|
|
3108
|
+
type: resolvedMatch[1],
|
|
3109
|
+
domain: resolvedMatch[2],
|
|
3110
|
+
source_ip: resolvedMatch[3],
|
|
3111
|
+
timestamp: Date.now()
|
|
3112
|
+
});
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
3115
|
+
const dnsmasqMatch = line.match(/query\[(\w+)\]\s+(\S+)\s+from\s+(\S+)/i);
|
|
3116
|
+
if (dnsmasqMatch) {
|
|
3117
|
+
this.domainBuffer.push({
|
|
3118
|
+
type: dnsmasqMatch[1],
|
|
3119
|
+
domain: dnsmasqMatch[2],
|
|
3120
|
+
source_ip: dnsmasqMatch[3],
|
|
3121
|
+
timestamp: Date.now()
|
|
3122
|
+
});
|
|
3123
|
+
return;
|
|
3124
|
+
}
|
|
3125
|
+
const genericMatch = line.match(/(?:query|lookup|resolve)[:\s]+(\S+)/i);
|
|
3126
|
+
if (genericMatch) {
|
|
3127
|
+
const typeMatch = line.match(/type[:\s]+(\w+)/i);
|
|
3128
|
+
this.domainBuffer.push({
|
|
3129
|
+
type: typeMatch?.[1] || "A",
|
|
3130
|
+
domain: genericMatch[1],
|
|
3131
|
+
timestamp: Date.now()
|
|
3132
|
+
});
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
analyzeBuffer() {
|
|
3136
|
+
const now = Date.now();
|
|
3137
|
+
const cutoff = now - this.txtWindowMs;
|
|
3138
|
+
this.domainBuffer = this.domainBuffer.filter((q) => q.timestamp > cutoff);
|
|
3139
|
+
this.detectTunneling();
|
|
3140
|
+
this.detectDga();
|
|
3141
|
+
}
|
|
3142
|
+
detectTunneling() {
|
|
3143
|
+
const txtBySource = /* @__PURE__ */ new Map();
|
|
3144
|
+
const longLabelDomains = [];
|
|
3145
|
+
for (const q of this.domainBuffer) {
|
|
3146
|
+
if (q.type === "TXT") {
|
|
3147
|
+
const key = q.source_ip || "unknown";
|
|
3148
|
+
txtBySource.set(key, (txtBySource.get(key) || 0) + 1);
|
|
3149
|
+
}
|
|
3150
|
+
const labels = q.domain.split(".");
|
|
3151
|
+
const maxLabel = Math.max(...labels.map((l) => l.length));
|
|
3152
|
+
if (maxLabel > 50) {
|
|
3153
|
+
longLabelDomains.push(q.domain);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
for (const [source, count] of txtBySource) {
|
|
3157
|
+
if (count >= this.txtRateThreshold) {
|
|
3158
|
+
this.emitEvent(
|
|
3159
|
+
"high",
|
|
3160
|
+
`DNS tunneling indicators: ${count} TXT queries from ${source} in ${this.txtWindowMs / 1e3}s`,
|
|
3161
|
+
source !== "unknown" ? source : void 0,
|
|
3162
|
+
{ txt_query_count: count, type: "tunneling" }
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
if (longLabelDomains.length >= 5) {
|
|
3167
|
+
this.emitEvent(
|
|
3168
|
+
"high",
|
|
3169
|
+
`DNS tunneling: ${longLabelDomains.length} queries with abnormally long labels detected`,
|
|
3170
|
+
void 0,
|
|
3171
|
+
{ domains: longLabelDomains.slice(0, 5), type: "tunneling-labels" }
|
|
3172
|
+
);
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
detectDga() {
|
|
3176
|
+
const highEntropyDomains = [];
|
|
3177
|
+
for (const q of this.domainBuffer) {
|
|
3178
|
+
const domain = q.domain.toLowerCase();
|
|
3179
|
+
const parts = domain.split(".");
|
|
3180
|
+
if (parts.length < 2) continue;
|
|
3181
|
+
const sld = parts[parts.length - 2];
|
|
3182
|
+
if (sld.length >= 8 && this.shannonEntropy(sld) >= this.entropyThreshold) {
|
|
3183
|
+
highEntropyDomains.push(domain);
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
const unique = [...new Set(highEntropyDomains)];
|
|
3187
|
+
if (unique.length >= this.dgaBurstThreshold) {
|
|
3188
|
+
this.emitEvent(
|
|
3189
|
+
"critical",
|
|
3190
|
+
`DGA-like domain burst: ${unique.length} unique high-entropy domains detected`,
|
|
3191
|
+
void 0,
|
|
3192
|
+
{ sample_domains: unique.slice(0, 10), type: "dga", unique_count: unique.length }
|
|
3193
|
+
);
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
shannonEntropy(str) {
|
|
3197
|
+
const freq = /* @__PURE__ */ new Map();
|
|
3198
|
+
for (const ch of str) {
|
|
3199
|
+
freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
3200
|
+
}
|
|
3201
|
+
let entropy = 0;
|
|
3202
|
+
for (const count of freq.values()) {
|
|
3203
|
+
const p = count / str.length;
|
|
3204
|
+
if (p > 0) entropy -= p * Math.log2(p);
|
|
3205
|
+
}
|
|
3206
|
+
return entropy;
|
|
3207
|
+
}
|
|
3208
|
+
emitEvent(severity, message, sourceIp, details) {
|
|
3209
|
+
const event = {
|
|
3210
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3211
|
+
module: "dns-monitor",
|
|
3212
|
+
category: "network",
|
|
3213
|
+
severity,
|
|
3214
|
+
message,
|
|
3215
|
+
source_ip: sourceIp,
|
|
3216
|
+
details
|
|
3217
|
+
};
|
|
3218
|
+
try {
|
|
3219
|
+
insertEvent(event);
|
|
3220
|
+
} catch {
|
|
3221
|
+
}
|
|
3222
|
+
this.bus.publish(event);
|
|
3223
|
+
}
|
|
3224
|
+
};
|
|
3225
|
+
|
|
3226
|
+
// src/core/config.ts
|
|
3227
|
+
var import_node_fs7 = require("fs");
|
|
3228
|
+
var import_node_path2 = require("path");
|
|
3229
|
+
var import_toml = __toESM(require_toml());
|
|
3230
|
+
var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
|
|
3231
|
+
var DEFAULT_CONFDIR = "/etc/threatcrush/threatcrushd.conf.d";
|
|
3232
|
+
var DEFAULT_CONFIG = {
|
|
3233
|
+
daemon: {
|
|
3234
|
+
pid_file: "/var/run/threatcrush/threatcrushd.pid",
|
|
3235
|
+
log_level: "info",
|
|
3236
|
+
log_file: "/var/log/threatcrush/threatcrushd.log",
|
|
3237
|
+
state_db: "/var/lib/threatcrush/state.db"
|
|
3238
|
+
},
|
|
3239
|
+
api: {
|
|
3240
|
+
enabled: true,
|
|
3241
|
+
bind: "127.0.0.1:9393",
|
|
3242
|
+
tls: false
|
|
3243
|
+
},
|
|
3244
|
+
alerts: {},
|
|
3245
|
+
modules: {
|
|
3246
|
+
auto_update: true,
|
|
3247
|
+
update_interval: "24h",
|
|
3248
|
+
module_dir: "/etc/threatcrush/modules",
|
|
3249
|
+
config_dir: DEFAULT_CONFDIR
|
|
3250
|
+
}
|
|
3251
|
+
};
|
|
3252
|
+
function loadConfig(configPath) {
|
|
3253
|
+
const path = configPath || DEFAULT_CONFIG_PATH;
|
|
3254
|
+
if (!(0, import_node_fs7.existsSync)(path)) {
|
|
3255
|
+
return { ...DEFAULT_CONFIG };
|
|
3256
|
+
}
|
|
3257
|
+
try {
|
|
3258
|
+
const raw = (0, import_node_fs7.readFileSync)(path, "utf-8");
|
|
3259
|
+
const parsed = import_toml.default.parse(raw);
|
|
3260
|
+
return {
|
|
3261
|
+
daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
|
|
3262
|
+
api: { ...DEFAULT_CONFIG.api, ...parsed.api },
|
|
3263
|
+
alerts: parsed.alerts || {},
|
|
3264
|
+
modules: { ...DEFAULT_CONFIG.modules, ...parsed.modules },
|
|
3265
|
+
license: parsed.license
|
|
3266
|
+
};
|
|
3267
|
+
} catch {
|
|
3268
|
+
return { ...DEFAULT_CONFIG };
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
function loadModuleConfigs(confDir) {
|
|
3272
|
+
const dir = confDir || DEFAULT_CONFDIR;
|
|
3273
|
+
const configs = /* @__PURE__ */ new Map();
|
|
3274
|
+
if (!(0, import_node_fs7.existsSync)(dir)) {
|
|
3275
|
+
return configs;
|
|
3276
|
+
}
|
|
3277
|
+
const files = (0, import_node_fs7.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
|
|
3278
|
+
for (const file of files) {
|
|
3279
|
+
try {
|
|
3280
|
+
const raw = (0, import_node_fs7.readFileSync)((0, import_node_path2.join)(dir, file), "utf-8");
|
|
3281
|
+
const parsed = import_toml.default.parse(raw);
|
|
3282
|
+
for (const [name, config] of Object.entries(parsed)) {
|
|
3283
|
+
configs.set(name, config);
|
|
3284
|
+
}
|
|
3285
|
+
} catch {
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
return configs;
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
// src/daemon/module-host.ts
|
|
3292
|
+
var ModuleHost = class {
|
|
3293
|
+
constructor(bus2) {
|
|
3294
|
+
this.bus = bus2;
|
|
3295
|
+
bus2.on("event", (event) => {
|
|
3296
|
+
const mod = this.modules.get(event.module);
|
|
3297
|
+
if (mod) mod.events++;
|
|
3298
|
+
for (const hosted of this.modules.values()) {
|
|
3299
|
+
if (hosted.status !== "running" || !hosted.instance?.onEvent) continue;
|
|
3300
|
+
void hosted.instance.onEvent(event).catch((err) => {
|
|
3301
|
+
hosted.status = "error";
|
|
3302
|
+
hosted.detail = `onEvent failed: ${String(err.message || err)}`;
|
|
3303
|
+
this.bus.announceModule(hosted.name, "error", hosted.detail);
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
});
|
|
3307
|
+
}
|
|
3308
|
+
bus;
|
|
3309
|
+
modules = /* @__PURE__ */ new Map();
|
|
3310
|
+
logWatcher = null;
|
|
3311
|
+
journalWatcher = null;
|
|
3312
|
+
networkMonitor = null;
|
|
3313
|
+
dnsMonitor = null;
|
|
3314
|
+
async start() {
|
|
3315
|
+
this.registerBuiltins();
|
|
3316
|
+
await this.discoverAndStartInstalled();
|
|
3317
|
+
this.logWatcher = new LogWatcher(this.bus);
|
|
3318
|
+
const watched = this.logWatcher.start();
|
|
3319
|
+
for (const modName of this.logWatcher.activeModules()) {
|
|
3320
|
+
const mod = this.modules.get(modName);
|
|
3321
|
+
if (mod) {
|
|
3322
|
+
mod.status = "running";
|
|
3323
|
+
mod.detail = `watching ${watched.length} log source(s)`;
|
|
3324
|
+
this.bus.announceModule(modName, "running", mod.detail);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
this.journalWatcher = new JournalWatcher(this.bus);
|
|
3328
|
+
if (this.journalWatcher.start()) {
|
|
3329
|
+
const mod = this.modules.get("user-journal");
|
|
3330
|
+
if (mod) {
|
|
3331
|
+
mod.status = "running";
|
|
3332
|
+
mod.detail = `tailing ${JournalWatcher.scopeArgs().includes("--user") ? "user journal" : "system journal"}`;
|
|
3333
|
+
this.bus.announceModule("user-journal", "running", mod.detail);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
this.networkMonitor = new NetworkMonitor(this.bus);
|
|
3337
|
+
if (this.networkMonitor.start()) {
|
|
3338
|
+
const nmod = this.modules.get("network-monitor");
|
|
3339
|
+
if (nmod) {
|
|
3340
|
+
nmod.status = "running";
|
|
3341
|
+
nmod.detail = "monitoring connections via conntrack/ss";
|
|
3342
|
+
this.bus.announceModule("network-monitor", "running", nmod.detail);
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
this.dnsMonitor = new DnsMonitor(this.bus);
|
|
3346
|
+
if (this.dnsMonitor.start()) {
|
|
3347
|
+
const dmod = this.modules.get("dns-monitor");
|
|
3348
|
+
if (dmod) {
|
|
3349
|
+
dmod.status = "running";
|
|
3350
|
+
dmod.detail = "monitoring DNS queries";
|
|
3351
|
+
this.bus.announceModule("dns-monitor", "running", dmod.detail);
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
async stop() {
|
|
3356
|
+
this.logWatcher?.stop();
|
|
3357
|
+
this.journalWatcher?.stop();
|
|
3358
|
+
this.networkMonitor?.stop();
|
|
3359
|
+
this.dnsMonitor?.stop();
|
|
3360
|
+
for (const mod of this.modules.values()) {
|
|
3361
|
+
try {
|
|
3362
|
+
if (mod.instance && mod.status === "running") {
|
|
3363
|
+
await mod.instance.stop();
|
|
3364
|
+
}
|
|
3365
|
+
} catch (err) {
|
|
3366
|
+
mod.status = "error";
|
|
3367
|
+
mod.detail = `stop failed: ${String(err.message || err)}`;
|
|
3368
|
+
this.bus.announceModule(mod.name, "error", mod.detail);
|
|
3369
|
+
continue;
|
|
3370
|
+
}
|
|
3371
|
+
mod.status = "loaded";
|
|
3372
|
+
this.bus.announceModule(mod.name, "stopped");
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
summary() {
|
|
3376
|
+
return [...this.modules.values()].map((m) => ({
|
|
3377
|
+
name: m.name,
|
|
3378
|
+
status: m.status,
|
|
3379
|
+
events: m.events,
|
|
3380
|
+
detail: m.detail
|
|
3381
|
+
}));
|
|
3382
|
+
}
|
|
3383
|
+
registerBuiltins() {
|
|
3384
|
+
const builtins = [
|
|
3385
|
+
{ name: "log-watcher", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
|
|
3386
|
+
{ name: "ssh-guard", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
|
|
3387
|
+
{ name: "user-journal", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
|
|
3388
|
+
{ name: "network-monitor", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
|
|
3389
|
+
{ name: "dns-monitor", version: "0.1.0", source: "builtin", status: "loaded", events: 0 }
|
|
3390
|
+
];
|
|
3391
|
+
for (const m of builtins) this.modules.set(m.name, m);
|
|
3392
|
+
}
|
|
3393
|
+
async discoverAndStartInstalled() {
|
|
3394
|
+
if (!(0, import_node_fs8.existsSync)(PATHS.moduleDir)) return;
|
|
3395
|
+
const configs = loadModuleConfigs(PATHS.confD);
|
|
3396
|
+
const entries = (0, import_node_fs8.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
|
|
3397
|
+
for (const entry of entries) {
|
|
3398
|
+
if (!entry.isDirectory()) continue;
|
|
3399
|
+
const manifestPath = (0, import_node_path3.join)(PATHS.moduleDir, entry.name, "mod.toml");
|
|
3400
|
+
if (!(0, import_node_fs8.existsSync)(manifestPath)) continue;
|
|
3401
|
+
try {
|
|
3402
|
+
const manifest = import_toml2.default.parse((0, import_node_fs8.readFileSync)(manifestPath, "utf-8"));
|
|
3403
|
+
const name = manifest.module?.name || entry.name;
|
|
3404
|
+
const defaults = manifest.module?.config?.defaults || {};
|
|
3405
|
+
const config = {
|
|
3406
|
+
enabled: true,
|
|
3407
|
+
...defaults,
|
|
3408
|
+
...configs.get(name) || {}
|
|
3409
|
+
};
|
|
3410
|
+
const hosted = {
|
|
3411
|
+
name,
|
|
3412
|
+
version: manifest.module?.version || "0.0.0",
|
|
3413
|
+
source: "installed",
|
|
3414
|
+
status: config.enabled === false ? "disabled" : "loaded",
|
|
3415
|
+
events: 0,
|
|
3416
|
+
path: (0, import_node_path3.join)(PATHS.moduleDir, entry.name),
|
|
3417
|
+
config
|
|
3418
|
+
};
|
|
3419
|
+
this.modules.set(name, hosted);
|
|
3420
|
+
if (config.enabled === false) continue;
|
|
3421
|
+
await this.startInstalled(hosted);
|
|
3422
|
+
} catch (err) {
|
|
3423
|
+
const name = entry.name;
|
|
3424
|
+
this.modules.set(name, {
|
|
3425
|
+
name,
|
|
3426
|
+
version: "0.0.0",
|
|
3427
|
+
source: "installed",
|
|
3428
|
+
status: "error",
|
|
3429
|
+
events: 0,
|
|
3430
|
+
detail: `manifest load failed: ${String(err.message || err)}`,
|
|
3431
|
+
path: (0, import_node_path3.join)(PATHS.moduleDir, entry.name)
|
|
3432
|
+
});
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
async startInstalled(hosted) {
|
|
3437
|
+
const entrypoint = this.installedEntrypoint(hosted.path);
|
|
3438
|
+
if (!entrypoint) {
|
|
3439
|
+
hosted.status = "loaded";
|
|
3440
|
+
hosted.detail = "no built entrypoint found; run npm install && npm run build in the module directory";
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
try {
|
|
3444
|
+
const imported = await import((0, import_node_url.pathToFileURL)(entrypoint).href);
|
|
3445
|
+
const exported = imported.default || imported.module || imported;
|
|
3446
|
+
const instance = typeof exported === "function" ? new exported() : exported;
|
|
3447
|
+
if (!this.isThreatCrushModule(instance)) {
|
|
3448
|
+
throw new Error("entrypoint does not export a ThreatCrush module");
|
|
3449
|
+
}
|
|
3450
|
+
hosted.instance = instance;
|
|
3451
|
+
await instance.init(this.contextFor(hosted));
|
|
3452
|
+
await instance.start();
|
|
3453
|
+
hosted.status = "running";
|
|
3454
|
+
hosted.detail = `started from ${entrypoint}`;
|
|
3455
|
+
this.bus.announceModule(hosted.name, "running", hosted.detail);
|
|
3456
|
+
} catch (err) {
|
|
3457
|
+
hosted.status = "error";
|
|
3458
|
+
hosted.detail = String(err.message || err);
|
|
3459
|
+
this.bus.announceModule(hosted.name, "error", hosted.detail);
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
installedEntrypoint(modulePath) {
|
|
3463
|
+
const packageJson = (0, import_node_path3.join)(modulePath, "package.json");
|
|
3464
|
+
const candidates = [];
|
|
3465
|
+
if ((0, import_node_fs8.existsSync)(packageJson)) {
|
|
3466
|
+
try {
|
|
3467
|
+
const pkg = JSON.parse((0, import_node_fs8.readFileSync)(packageJson, "utf-8"));
|
|
3468
|
+
if (pkg.main) candidates.push((0, import_node_path3.join)(modulePath, pkg.main));
|
|
3469
|
+
} catch {
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
candidates.push((0, import_node_path3.join)(modulePath, "dist", "index.js"), (0, import_node_path3.join)(modulePath, "index.js"));
|
|
3473
|
+
return candidates.find((candidate) => (0, import_node_fs8.existsSync)(candidate)) || null;
|
|
3474
|
+
}
|
|
3475
|
+
isThreatCrushModule(value) {
|
|
3476
|
+
return Boolean(
|
|
3477
|
+
value && typeof value === "object" && typeof value.init === "function" && typeof value.start === "function" && typeof value.stop === "function"
|
|
3478
|
+
);
|
|
3479
|
+
}
|
|
3480
|
+
contextFor(hosted) {
|
|
3481
|
+
return {
|
|
3482
|
+
config: hosted.config || { enabled: true },
|
|
3483
|
+
logger: this.loggerFor(hosted.name),
|
|
3484
|
+
emit: (event) => this.bus.publish(event),
|
|
3485
|
+
subscribe: (eventType, handler) => {
|
|
3486
|
+
this.bus.on("event", (event) => {
|
|
3487
|
+
if (event.category === eventType || event.module === eventType) handler(event);
|
|
3488
|
+
});
|
|
3489
|
+
},
|
|
3490
|
+
alert: (alert) => {
|
|
3491
|
+
this.bus.emit("alert", alert.event || {
|
|
3492
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
3493
|
+
module: hosted.name,
|
|
3494
|
+
category: "system",
|
|
3495
|
+
severity: alert.severity,
|
|
3496
|
+
message: alert.title,
|
|
3497
|
+
details: alert.body ? { body: alert.body } : void 0
|
|
3498
|
+
});
|
|
3499
|
+
},
|
|
3500
|
+
getState: (key) => getModuleState(hosted.name, key),
|
|
3501
|
+
setState: (key, value) => setModuleState(hosted.name, key, value)
|
|
3502
|
+
};
|
|
3503
|
+
}
|
|
3504
|
+
loggerFor(moduleName) {
|
|
3505
|
+
return {
|
|
3506
|
+
debug: (msg, ...args) => console.debug(`[${moduleName}] ${msg}`, ...args),
|
|
3507
|
+
info: (msg, ...args) => console.info(`[${moduleName}] ${msg}`, ...args),
|
|
3508
|
+
warn: (msg, ...args) => console.warn(`[${moduleName}] ${msg}`, ...args),
|
|
3509
|
+
error: (msg, ...args) => console.error(`[${moduleName}] ${msg}`, ...args)
|
|
3510
|
+
};
|
|
3511
|
+
}
|
|
3512
|
+
};
|
|
3513
|
+
|
|
3514
|
+
// src/daemon/alerts/smtp.ts
|
|
3515
|
+
var transporter = null;
|
|
3516
|
+
var nodemailer = null;
|
|
3517
|
+
var SEVERITY_RANK = {
|
|
3518
|
+
info: 0,
|
|
3519
|
+
low: 1,
|
|
3520
|
+
medium: 2,
|
|
3521
|
+
high: 3,
|
|
3522
|
+
critical: 4
|
|
3523
|
+
};
|
|
3524
|
+
async function ensureTransporter(config) {
|
|
3525
|
+
if (!config.host || !config.from) return null;
|
|
3526
|
+
if (transporter) return transporter;
|
|
3527
|
+
if (!nodemailer) {
|
|
3528
|
+
try {
|
|
3529
|
+
nodemailer = await import("nodemailer");
|
|
3530
|
+
} catch {
|
|
3531
|
+
return null;
|
|
3532
|
+
}
|
|
3533
|
+
}
|
|
3534
|
+
transporter = nodemailer.createTransport({
|
|
3535
|
+
host: config.host,
|
|
3536
|
+
port: config.port ?? 587,
|
|
3537
|
+
secure: config.secure ?? false,
|
|
3538
|
+
auth: config.user && config.pass ? { user: config.user, pass: config.pass } : void 0
|
|
3539
|
+
});
|
|
3540
|
+
return transporter;
|
|
3541
|
+
}
|
|
3542
|
+
function meetsSeverity(event, min) {
|
|
3543
|
+
if (!min) return true;
|
|
3544
|
+
return (SEVERITY_RANK[event.severity] ?? 0) >= (SEVERITY_RANK[min] ?? 0);
|
|
3545
|
+
}
|
|
3546
|
+
function renderBody(event) {
|
|
3547
|
+
const ts = event.timestamp.toISOString();
|
|
3548
|
+
const ip = event.source_ip ? `
|
|
3549
|
+
Source IP: ${event.source_ip}` : "";
|
|
3550
|
+
const text = `[${event.severity.toUpperCase()}] ${event.module}
|
|
3551
|
+
|
|
3552
|
+
${event.message}${ip}
|
|
3553
|
+
|
|
3554
|
+
When: ${ts}
|
|
3555
|
+
Category: ${event.category}`;
|
|
3556
|
+
const html = `<div style="font-family:system-ui,sans-serif;line-height:1.5"><h2 style="margin:0 0 8px">\u26A0 ${event.severity.toUpperCase()} \u2014 ${event.module}</h2><p>${event.message}</p>` + (event.source_ip ? `<p><strong>Source IP:</strong> <code>${event.source_ip}</code></p>` : "") + `<p style="color:#888;margin-top:16px"><small>${ts} \xB7 ${event.category}</small></p></div>`;
|
|
3557
|
+
return { text, html };
|
|
3558
|
+
}
|
|
3559
|
+
function smtpChannel(config) {
|
|
3560
|
+
return async (event) => {
|
|
3561
|
+
if (!meetsSeverity(event, config.min_severity)) return;
|
|
3562
|
+
const t = await ensureTransporter(config);
|
|
3563
|
+
if (!t) return;
|
|
3564
|
+
const to = Array.isArray(config.to) ? config.to.join(", ") : config.to;
|
|
3565
|
+
if (!to) return;
|
|
3566
|
+
const { text, html } = renderBody(event);
|
|
3567
|
+
await t.sendMail({
|
|
3568
|
+
from: config.from,
|
|
3569
|
+
to,
|
|
3570
|
+
subject: `[ThreatCrush ${event.severity}] ${event.module} \u2014 ${event.message.slice(0, 60)}`,
|
|
3571
|
+
text,
|
|
3572
|
+
html
|
|
3573
|
+
});
|
|
3574
|
+
};
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
// src/daemon/alerts/discord.ts
|
|
3578
|
+
var SEVERITY_RANK2 = {
|
|
3579
|
+
info: 0,
|
|
3580
|
+
low: 1,
|
|
3581
|
+
medium: 2,
|
|
3582
|
+
high: 3,
|
|
3583
|
+
critical: 4
|
|
3584
|
+
};
|
|
3585
|
+
var SEVERITY_COLORS = {
|
|
3586
|
+
info: 3066993,
|
|
3587
|
+
// green
|
|
3588
|
+
low: 3447003,
|
|
3589
|
+
// blue
|
|
3590
|
+
medium: 15965202,
|
|
3591
|
+
// orange
|
|
3592
|
+
high: 15158332,
|
|
3593
|
+
// red
|
|
3594
|
+
critical: 10181046
|
|
3595
|
+
// purple
|
|
3596
|
+
};
|
|
3597
|
+
function discordChannel(config) {
|
|
3598
|
+
return async (event) => {
|
|
3599
|
+
if (config.min_severity) {
|
|
3600
|
+
const eventRank = SEVERITY_RANK2[event.severity] ?? 0;
|
|
3601
|
+
const minRank = SEVERITY_RANK2[config.min_severity] ?? 0;
|
|
3602
|
+
if (eventRank < minRank) return;
|
|
3603
|
+
}
|
|
3604
|
+
const embed = {
|
|
3605
|
+
title: `${event.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F"} [${event.severity.toUpperCase()}] ${event.module}`,
|
|
3606
|
+
description: event.message,
|
|
3607
|
+
color: SEVERITY_COLORS[event.severity] ?? 16777215,
|
|
3608
|
+
fields: [
|
|
3609
|
+
...event.source_ip ? [{ name: "Source IP", value: `\`${event.source_ip}\``, inline: true }] : [],
|
|
3610
|
+
{ name: "Category", value: event.category, inline: true },
|
|
3611
|
+
{ name: "Time", value: event.timestamp.toISOString(), inline: true }
|
|
3612
|
+
],
|
|
3613
|
+
footer: { text: "ThreatCrush Security Alert" }
|
|
3614
|
+
};
|
|
3615
|
+
await fetch(config.webhook_url, {
|
|
3616
|
+
method: "POST",
|
|
3617
|
+
headers: { "Content-Type": "application/json" },
|
|
3618
|
+
body: JSON.stringify({ embeds: [embed] })
|
|
3619
|
+
});
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
// src/daemon/alerts/pagerduty.ts
|
|
3624
|
+
var SEVERITY_RANK3 = {
|
|
3625
|
+
info: 0,
|
|
3626
|
+
low: 1,
|
|
3627
|
+
medium: 2,
|
|
3628
|
+
high: 3,
|
|
3629
|
+
critical: 4
|
|
3630
|
+
};
|
|
3631
|
+
var PD_SEVERITY = {
|
|
3632
|
+
info: "info",
|
|
3633
|
+
low: "info",
|
|
3634
|
+
medium: "warning",
|
|
3635
|
+
high: "error",
|
|
3636
|
+
critical: "critical"
|
|
3637
|
+
};
|
|
3638
|
+
function pagerdutyChannel(config) {
|
|
3639
|
+
return async (event) => {
|
|
3640
|
+
if (config.min_severity) {
|
|
3641
|
+
const eventRank = SEVERITY_RANK3[event.severity] ?? 0;
|
|
3642
|
+
const minRank = SEVERITY_RANK3[config.min_severity] ?? 0;
|
|
3643
|
+
if (eventRank < minRank) return;
|
|
3644
|
+
}
|
|
3645
|
+
const payload = {
|
|
3646
|
+
routing_key: config.routing_key,
|
|
3647
|
+
event_action: "trigger",
|
|
3648
|
+
payload: {
|
|
3649
|
+
summary: `[${event.severity.toUpperCase()}] ${event.module}: ${event.message}`,
|
|
3650
|
+
source: "threatcrush",
|
|
3651
|
+
severity: PD_SEVERITY[event.severity] || "warning",
|
|
3652
|
+
timestamp: event.timestamp.toISOString(),
|
|
3653
|
+
custom_details: {
|
|
3654
|
+
module: event.module,
|
|
3655
|
+
category: event.category,
|
|
3656
|
+
source_ip: event.source_ip,
|
|
3657
|
+
details: event.details
|
|
3658
|
+
}
|
|
3659
|
+
}
|
|
3660
|
+
};
|
|
3661
|
+
await fetch("https://events.pagerduty.com/v2/enqueue", {
|
|
3662
|
+
method: "POST",
|
|
3663
|
+
headers: { "Content-Type": "application/json" },
|
|
3664
|
+
body: JSON.stringify(payload)
|
|
3665
|
+
});
|
|
3666
|
+
};
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
// src/daemon/alerts/index.ts
|
|
3670
|
+
var AlertDispatcher = class {
|
|
3671
|
+
constructor(bus2, config) {
|
|
3672
|
+
this.bus = bus2;
|
|
3673
|
+
this.config = config;
|
|
3674
|
+
this.bindChannels();
|
|
3675
|
+
bus2.on("alert", (event) => {
|
|
3676
|
+
void this.dispatch(event);
|
|
3677
|
+
});
|
|
3678
|
+
}
|
|
3679
|
+
bus;
|
|
3680
|
+
config;
|
|
3681
|
+
channels = [];
|
|
3682
|
+
rateLimits = /* @__PURE__ */ new Map();
|
|
3683
|
+
bindChannels() {
|
|
3684
|
+
const alerts = this.config.alerts || {};
|
|
3685
|
+
for (const [name, raw] of Object.entries(alerts)) {
|
|
3686
|
+
const cfg = raw;
|
|
3687
|
+
if (!cfg.enabled) continue;
|
|
3688
|
+
if (name === "webhook" && typeof cfg.url === "string") {
|
|
3689
|
+
this.channels.push(webhookChannel(cfg.url, cfg.secret));
|
|
3690
|
+
}
|
|
3691
|
+
if (name === "slack" && typeof cfg.webhook_url === "string") {
|
|
3692
|
+
this.channels.push(slackChannel(cfg.webhook_url));
|
|
3693
|
+
}
|
|
3694
|
+
if (name === "email" && typeof cfg.host === "string" && typeof cfg.from === "string") {
|
|
3695
|
+
this.channels.push(smtpChannel(cfg));
|
|
3696
|
+
}
|
|
3697
|
+
if (name === "discord" && typeof cfg.webhook_url === "string") {
|
|
3698
|
+
this.channels.push(discordChannel(cfg));
|
|
3699
|
+
}
|
|
3700
|
+
if (name === "pagerduty" && typeof cfg.routing_key === "string") {
|
|
3701
|
+
this.channels.push(pagerdutyChannel(cfg));
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
checkRateLimit(channelIdx, maxPerHour = 60) {
|
|
3706
|
+
const key = String(channelIdx);
|
|
3707
|
+
const now = Date.now();
|
|
3708
|
+
const hour = 36e5;
|
|
3709
|
+
let timestamps = this.rateLimits.get(key) || [];
|
|
3710
|
+
timestamps = timestamps.filter((t) => t > now - hour);
|
|
3711
|
+
if (timestamps.length >= maxPerHour) return false;
|
|
3712
|
+
timestamps.push(now);
|
|
3713
|
+
this.rateLimits.set(key, timestamps);
|
|
3714
|
+
return true;
|
|
3715
|
+
}
|
|
3716
|
+
async dispatch(event) {
|
|
3717
|
+
await Promise.all(this.channels.map((ch, idx) => {
|
|
3718
|
+
if (!this.checkRateLimit(idx)) return Promise.resolve();
|
|
3719
|
+
return ch(event).catch(() => {
|
|
3720
|
+
});
|
|
3721
|
+
}));
|
|
3722
|
+
}
|
|
3723
|
+
};
|
|
3724
|
+
function webhookChannel(url, secret) {
|
|
3725
|
+
return async (event) => {
|
|
3726
|
+
const body = JSON.stringify({ event });
|
|
3727
|
+
const headers = { "Content-Type": "application/json" };
|
|
3728
|
+
if (secret) headers["X-Threatcrush-Signature"] = secret;
|
|
3729
|
+
await fetch(url, { method: "POST", headers, body });
|
|
3730
|
+
};
|
|
3731
|
+
}
|
|
3732
|
+
function slackChannel(webhookUrl) {
|
|
3733
|
+
return async (event) => {
|
|
3734
|
+
const emoji = event.severity === "critical" ? ":rotating_light:" : ":warning:";
|
|
2856
3735
|
const text = `${emoji} *[${event.severity.toUpperCase()}]* \`${event.module}\` \u2014 ${event.message}${event.source_ip ? ` (from ${event.source_ip})` : ""}`;
|
|
2857
3736
|
await fetch(webhookUrl, {
|
|
2858
3737
|
method: "POST",
|
|
@@ -2863,14 +3742,14 @@ function slackChannel(webhookUrl) {
|
|
|
2863
3742
|
}
|
|
2864
3743
|
|
|
2865
3744
|
// src/core/cli-config.ts
|
|
2866
|
-
var
|
|
2867
|
-
var
|
|
3745
|
+
var import_node_fs9 = require("fs");
|
|
3746
|
+
var import_node_path4 = require("path");
|
|
2868
3747
|
var import_node_os2 = require("os");
|
|
2869
|
-
var CLI_CONFIG_DIR = (0,
|
|
2870
|
-
var CLI_CONFIG_PATH = (0,
|
|
3748
|
+
var CLI_CONFIG_DIR = (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".threatcrush");
|
|
3749
|
+
var CLI_CONFIG_PATH = (0, import_node_path4.join)(CLI_CONFIG_DIR, "config.json");
|
|
2871
3750
|
function readCliConfig() {
|
|
2872
3751
|
try {
|
|
2873
|
-
return JSON.parse((0,
|
|
3752
|
+
return JSON.parse((0, import_node_fs9.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
|
|
2874
3753
|
} catch {
|
|
2875
3754
|
return {};
|
|
2876
3755
|
}
|
|
@@ -2889,8 +3768,8 @@ function authHeaders() {
|
|
|
2889
3768
|
}
|
|
2890
3769
|
|
|
2891
3770
|
// src/commands/scan.ts
|
|
2892
|
-
var
|
|
2893
|
-
var
|
|
3771
|
+
var import_node_fs10 = require("fs");
|
|
3772
|
+
var import_node_path5 = require("path");
|
|
2894
3773
|
|
|
2895
3774
|
// ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
2896
3775
|
var ANSI_BACKGROUND_OFFSET = 10;
|
|
@@ -3388,7 +4267,7 @@ var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
|
|
|
3388
4267
|
var source_default = chalk;
|
|
3389
4268
|
|
|
3390
4269
|
// src/core/logger.ts
|
|
3391
|
-
var
|
|
4270
|
+
var SEVERITY_COLORS2 = {
|
|
3392
4271
|
info: source_default.green,
|
|
3393
4272
|
low: source_default.cyan,
|
|
3394
4273
|
medium: source_default.yellow,
|
|
@@ -3489,6 +4368,8 @@ async function runScan(targetPath) {
|
|
|
3489
4368
|
try {
|
|
3490
4369
|
scanDirectory(targetPath, targetPath, findings, () => {
|
|
3491
4370
|
});
|
|
4371
|
+
const depFindings = await scanDependencies(targetPath);
|
|
4372
|
+
findings.push(...depFindings);
|
|
3492
4373
|
} catch (err) {
|
|
3493
4374
|
return {
|
|
3494
4375
|
type: "scan",
|
|
@@ -3518,12 +4399,12 @@ async function runScan(targetPath) {
|
|
|
3518
4399
|
function scanDirectory(basePath, currentPath, findings, onFile) {
|
|
3519
4400
|
let entries;
|
|
3520
4401
|
try {
|
|
3521
|
-
entries = (0,
|
|
4402
|
+
entries = (0, import_node_fs10.readdirSync)(currentPath, { withFileTypes: true });
|
|
3522
4403
|
} catch {
|
|
3523
4404
|
return;
|
|
3524
4405
|
}
|
|
3525
4406
|
for (const entry of entries) {
|
|
3526
|
-
const fullPath = (0,
|
|
4407
|
+
const fullPath = (0, import_node_path5.join)(currentPath, entry.name);
|
|
3527
4408
|
if (entry.isDirectory()) {
|
|
3528
4409
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
3529
4410
|
scanDirectory(basePath, fullPath, findings, onFile);
|
|
@@ -3533,7 +4414,7 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
|
|
|
3533
4414
|
for (const mc of MISCONFIG_FILES) {
|
|
3534
4415
|
if (entry.name === mc.pattern || entry.name.endsWith(mc.pattern)) {
|
|
3535
4416
|
findings.push({
|
|
3536
|
-
file: (0,
|
|
4417
|
+
file: (0, import_node_path5.relative)(basePath, fullPath),
|
|
3537
4418
|
line: 0,
|
|
3538
4419
|
type: "Sensitive File",
|
|
3539
4420
|
severity: "high",
|
|
@@ -3542,10 +4423,10 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
|
|
|
3542
4423
|
});
|
|
3543
4424
|
}
|
|
3544
4425
|
}
|
|
3545
|
-
const ext = (0,
|
|
4426
|
+
const ext = (0, import_node_path5.extname)(entry.name).toLowerCase();
|
|
3546
4427
|
if (!SCAN_EXTENSIONS.has(ext) && !entry.name.startsWith(".env")) continue;
|
|
3547
4428
|
try {
|
|
3548
|
-
const stat = (0,
|
|
4429
|
+
const stat = (0, import_node_fs10.statSync)(fullPath);
|
|
3549
4430
|
if (stat.size > 1024 * 1024) continue;
|
|
3550
4431
|
} catch {
|
|
3551
4432
|
continue;
|
|
@@ -3553,7 +4434,7 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
|
|
|
3553
4434
|
onFile();
|
|
3554
4435
|
let content;
|
|
3555
4436
|
try {
|
|
3556
|
-
content = (0,
|
|
4437
|
+
content = (0, import_node_fs10.readFileSync)(fullPath, "utf-8");
|
|
3557
4438
|
} catch {
|
|
3558
4439
|
continue;
|
|
3559
4440
|
}
|
|
@@ -3565,7 +4446,7 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
|
|
|
3565
4446
|
pattern.pattern.lastIndex = 0;
|
|
3566
4447
|
if (pattern.pattern.test(line)) {
|
|
3567
4448
|
findings.push({
|
|
3568
|
-
file: (0,
|
|
4449
|
+
file: (0, import_node_path5.relative)(basePath, fullPath),
|
|
3569
4450
|
line: i + 1,
|
|
3570
4451
|
type: pattern.name,
|
|
3571
4452
|
severity: pattern.severity,
|
|
@@ -3577,6 +4458,92 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
|
|
|
3577
4458
|
}
|
|
3578
4459
|
}
|
|
3579
4460
|
}
|
|
4461
|
+
async function scanDependencies(targetPath) {
|
|
4462
|
+
const findings = [];
|
|
4463
|
+
const lockfiles = [
|
|
4464
|
+
{ file: "package-lock.json", ecosystem: "npm" },
|
|
4465
|
+
{ file: "pnpm-lock.yaml", ecosystem: "npm" },
|
|
4466
|
+
{ file: "yarn.lock", ecosystem: "npm" },
|
|
4467
|
+
{ file: "requirements.txt", ecosystem: "PyPI" },
|
|
4468
|
+
{ file: "Pipfile.lock", ecosystem: "PyPI" }
|
|
4469
|
+
];
|
|
4470
|
+
for (const { file, ecosystem } of lockfiles) {
|
|
4471
|
+
const lockPath = (0, import_node_path5.join)(targetPath, file);
|
|
4472
|
+
if (!(0, import_node_fs10.existsSync)(lockPath)) continue;
|
|
4473
|
+
try {
|
|
4474
|
+
const deps = parseDependencies(lockPath, file, ecosystem);
|
|
4475
|
+
for (const dep of deps.slice(0, 50)) {
|
|
4476
|
+
try {
|
|
4477
|
+
const vulns = await queryOsv(dep.name, dep.version, ecosystem);
|
|
4478
|
+
for (const vuln of vulns) {
|
|
4479
|
+
const cvssScore = vuln.severity?.find((s) => s.type === "CVSS_V3")?.score;
|
|
4480
|
+
const severity = cvssScore ? parseFloat(cvssScore) >= 9 ? "critical" : parseFloat(cvssScore) >= 7 ? "high" : parseFloat(cvssScore) >= 4 ? "medium" : "low" : "medium";
|
|
4481
|
+
findings.push({
|
|
4482
|
+
file,
|
|
4483
|
+
line: 0,
|
|
4484
|
+
type: "Dependency CVE",
|
|
4485
|
+
severity,
|
|
4486
|
+
message: `${dep.name}@${dep.version}: ${vuln.summary || vuln.id}`,
|
|
4487
|
+
snippet: `${vuln.id}${cvssScore ? ` (CVSS: ${cvssScore})` : ""}`
|
|
4488
|
+
});
|
|
4489
|
+
}
|
|
4490
|
+
} catch {
|
|
4491
|
+
}
|
|
4492
|
+
}
|
|
4493
|
+
} catch {
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
return findings;
|
|
4497
|
+
}
|
|
4498
|
+
function parseDependencies(lockPath, filename, ecosystem) {
|
|
4499
|
+
const deps = [];
|
|
4500
|
+
if (filename === "package-lock.json") {
|
|
4501
|
+
try {
|
|
4502
|
+
const lock = JSON.parse((0, import_node_fs10.readFileSync)(lockPath, "utf-8"));
|
|
4503
|
+
const packages = lock.packages || lock.dependencies || {};
|
|
4504
|
+
for (const [key, value] of Object.entries(packages)) {
|
|
4505
|
+
const name = key.replace(/^node_modules\//, "");
|
|
4506
|
+
const version = value.version;
|
|
4507
|
+
if (name && version && !name.startsWith(".")) {
|
|
4508
|
+
deps.push({ name, version });
|
|
4509
|
+
}
|
|
4510
|
+
}
|
|
4511
|
+
} catch {
|
|
4512
|
+
}
|
|
4513
|
+
} else if (filename === "requirements.txt") {
|
|
4514
|
+
try {
|
|
4515
|
+
const content = (0, import_node_fs10.readFileSync)(lockPath, "utf-8");
|
|
4516
|
+
for (const line of content.split("\n")) {
|
|
4517
|
+
const match = line.match(/^([a-zA-Z0-9_.-]+)==([0-9.]+)/);
|
|
4518
|
+
if (match) deps.push({ name: match[1], version: match[2] });
|
|
4519
|
+
}
|
|
4520
|
+
} catch {
|
|
4521
|
+
}
|
|
4522
|
+
}
|
|
4523
|
+
return deps;
|
|
4524
|
+
}
|
|
4525
|
+
function isValidPackageName(name) {
|
|
4526
|
+
return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
|
|
4527
|
+
}
|
|
4528
|
+
function isValidVersion(version) {
|
|
4529
|
+
return /^[0-9a-zA-Z._\-+]{1,50}$/.test(version);
|
|
4530
|
+
}
|
|
4531
|
+
async function queryOsv(name, version, ecosystem) {
|
|
4532
|
+
if (!isValidPackageName(name) || !isValidVersion(version)) return [];
|
|
4533
|
+
try {
|
|
4534
|
+
const res = await fetch("https://api.osv.dev/v1/query", {
|
|
4535
|
+
method: "POST",
|
|
4536
|
+
headers: { "Content-Type": "application/json" },
|
|
4537
|
+
body: JSON.stringify({ package: { name, ecosystem }, version }),
|
|
4538
|
+
signal: AbortSignal.timeout(5e3)
|
|
4539
|
+
});
|
|
4540
|
+
if (!res.ok) return [];
|
|
4541
|
+
const data = await res.json();
|
|
4542
|
+
return data.vulns || [];
|
|
4543
|
+
} catch {
|
|
4544
|
+
return [];
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
3580
4547
|
|
|
3581
4548
|
// src/commands/pentest.ts
|
|
3582
4549
|
var PENTEST_CHECKS = [
|
|
@@ -3621,6 +4588,39 @@ var PENTEST_CHECKS = [
|
|
|
3621
4588
|
test: (html) => /stack trace|traceback|exception|at\s+\w+\.\w+\(/i.test(html),
|
|
3622
4589
|
severity: "medium",
|
|
3623
4590
|
message: "Error page reveals internal information"
|
|
4591
|
+
},
|
|
4592
|
+
// PRD 07: Additional checks
|
|
4593
|
+
{
|
|
4594
|
+
name: "CORS Misconfiguration",
|
|
4595
|
+
test: (_url, _body, headers) => {
|
|
4596
|
+
const acao = headers["access-control-allow-origin"];
|
|
4597
|
+
return acao === "*" || acao === "null";
|
|
4598
|
+
},
|
|
4599
|
+
severity: "medium",
|
|
4600
|
+
message: "CORS allows any origin (Access-Control-Allow-Origin: *)"
|
|
4601
|
+
},
|
|
4602
|
+
{
|
|
4603
|
+
name: "Cookie Security",
|
|
4604
|
+
test: (_url, _body, headers) => {
|
|
4605
|
+
const setCookie = headers["set-cookie"] || "";
|
|
4606
|
+
return setCookie.length > 0 && (!setCookie.includes("HttpOnly") || !setCookie.includes("Secure"));
|
|
4607
|
+
},
|
|
4608
|
+
severity: "medium",
|
|
4609
|
+
message: "Cookies missing HttpOnly or Secure flags"
|
|
4610
|
+
},
|
|
4611
|
+
{
|
|
4612
|
+
name: "Content Security Policy",
|
|
4613
|
+
test: (_url, _body, headers) => {
|
|
4614
|
+
return !headers["content-security-policy"];
|
|
4615
|
+
},
|
|
4616
|
+
severity: "low",
|
|
4617
|
+
message: "No Content-Security-Policy header set"
|
|
4618
|
+
},
|
|
4619
|
+
{
|
|
4620
|
+
name: "Sensitive Path Exposure",
|
|
4621
|
+
test: (html) => /\.env|wp-admin|phpinfo|\.git\/config|server-status/i.test(html),
|
|
4622
|
+
severity: "high",
|
|
4623
|
+
message: "Response references sensitive paths or admin endpoints"
|
|
3624
4624
|
}
|
|
3625
4625
|
];
|
|
3626
4626
|
async function runPentest(rawUrl) {
|
|
@@ -3843,52 +4843,717 @@ var RunsWorker = class {
|
|
|
3843
4843
|
}
|
|
3844
4844
|
};
|
|
3845
4845
|
|
|
3846
|
-
// src/
|
|
3847
|
-
var
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
4846
|
+
// src/daemon/rules/engine.ts
|
|
4847
|
+
var RuleEngine = class {
|
|
4848
|
+
constructor(onDetection) {
|
|
4849
|
+
this.onDetection = onDetection;
|
|
4850
|
+
}
|
|
4851
|
+
onDetection;
|
|
4852
|
+
rules = [];
|
|
4853
|
+
windows = /* @__PURE__ */ new Map();
|
|
4854
|
+
loadRules(rules) {
|
|
4855
|
+
this.rules = rules.filter((r) => r.enabled !== false);
|
|
4856
|
+
}
|
|
4857
|
+
getRules() {
|
|
4858
|
+
return [...this.rules];
|
|
4859
|
+
}
|
|
4860
|
+
evaluate(event) {
|
|
4861
|
+
const now = Date.now();
|
|
4862
|
+
for (const rule of this.rules) {
|
|
4863
|
+
if (rule.source_types.length > 0 && !rule.source_types.includes(event.module) && !rule.source_types.includes(event.category)) {
|
|
4864
|
+
continue;
|
|
4865
|
+
}
|
|
4866
|
+
if (!this.matchesCondition(event, rule.match)) continue;
|
|
4867
|
+
const windowKey = `${rule.id}:${event.source_ip || "global"}`;
|
|
4868
|
+
let window = this.windows.get(windowKey);
|
|
4869
|
+
if (!window) {
|
|
4870
|
+
window = { events: [], lastAlert: 0 };
|
|
4871
|
+
this.windows.set(windowKey, window);
|
|
4872
|
+
}
|
|
4873
|
+
window.events.push({ timestamp: now, event });
|
|
4874
|
+
const cutoff = now - rule.window_seconds * 1e3;
|
|
4875
|
+
window.events = window.events.filter((e) => e.timestamp >= cutoff);
|
|
4876
|
+
if (window.events.length < rule.threshold) continue;
|
|
4877
|
+
if (window.lastAlert > 0 && now - window.lastAlert < rule.cooldown_seconds * 1e3) continue;
|
|
4878
|
+
window.lastAlert = now;
|
|
4879
|
+
window.events = [];
|
|
4880
|
+
this.onDetection({
|
|
4881
|
+
rule_id: rule.id,
|
|
4882
|
+
severity: rule.severity,
|
|
4883
|
+
title: rule.title,
|
|
4884
|
+
description: `${rule.description} (${rule.threshold} events in ${rule.window_seconds}s)`,
|
|
4885
|
+
source_ip: event.source_ip,
|
|
4886
|
+
username: event.details?.user || void 0,
|
|
4887
|
+
raw_metadata: {
|
|
4888
|
+
rule_version: rule.version,
|
|
4889
|
+
tags: rule.tags,
|
|
4890
|
+
category: rule.category,
|
|
4891
|
+
remediation: rule.remediation
|
|
4892
|
+
}
|
|
4893
|
+
});
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
matchesCondition(event, match) {
|
|
4897
|
+
const fieldValue = this.getFieldValue(event, match.field);
|
|
4898
|
+
if (fieldValue === void 0) return false;
|
|
4899
|
+
const strValue = String(fieldValue);
|
|
4900
|
+
let result = false;
|
|
4901
|
+
switch (match.operator) {
|
|
4902
|
+
case "contains":
|
|
4903
|
+
result = strValue.toLowerCase().includes(String(match.value).toLowerCase());
|
|
4904
|
+
break;
|
|
4905
|
+
case "regex":
|
|
4906
|
+
try {
|
|
4907
|
+
result = new RegExp(String(match.value), "i").test(strValue);
|
|
4908
|
+
} catch {
|
|
4909
|
+
result = false;
|
|
4910
|
+
}
|
|
4911
|
+
break;
|
|
4912
|
+
case "equals":
|
|
4913
|
+
result = strValue === String(match.value);
|
|
4914
|
+
break;
|
|
4915
|
+
case "starts_with":
|
|
4916
|
+
result = strValue.startsWith(String(match.value));
|
|
4917
|
+
break;
|
|
4918
|
+
case "ends_with":
|
|
4919
|
+
result = strValue.endsWith(String(match.value));
|
|
4920
|
+
break;
|
|
4921
|
+
}
|
|
4922
|
+
if (result && match.and) {
|
|
4923
|
+
result = match.and.every((m) => this.matchesCondition(event, m));
|
|
4924
|
+
}
|
|
4925
|
+
if (!result && match.or) {
|
|
4926
|
+
result = match.or.some((m) => this.matchesCondition(event, m));
|
|
4927
|
+
}
|
|
4928
|
+
return result;
|
|
4929
|
+
}
|
|
4930
|
+
getFieldValue(event, field) {
|
|
4931
|
+
switch (field) {
|
|
4932
|
+
case "message":
|
|
4933
|
+
return event.message;
|
|
4934
|
+
case "severity":
|
|
4935
|
+
return event.severity;
|
|
4936
|
+
case "module":
|
|
4937
|
+
return event.module;
|
|
4938
|
+
case "category":
|
|
4939
|
+
return event.category;
|
|
4940
|
+
case "source_ip":
|
|
4941
|
+
return event.source_ip;
|
|
4942
|
+
default:
|
|
4943
|
+
return event.details?.[field];
|
|
4944
|
+
}
|
|
4945
|
+
}
|
|
4946
|
+
// Periodic cleanup of stale windows
|
|
4947
|
+
cleanup() {
|
|
4948
|
+
const now = Date.now();
|
|
4949
|
+
for (const [key, window] of this.windows.entries()) {
|
|
4950
|
+
if (window.events.length === 0 && now - window.lastAlert > 36e5) {
|
|
4951
|
+
this.windows.delete(key);
|
|
4952
|
+
}
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
};
|
|
4956
|
+
|
|
4957
|
+
// src/daemon/rules/loader.ts
|
|
4958
|
+
var import_node_fs11 = require("fs");
|
|
4959
|
+
var import_node_path6 = require("path");
|
|
4960
|
+
|
|
4961
|
+
// src/daemon/rules/default-rules.ts
|
|
4962
|
+
var DEFAULT_RULES = [
|
|
4963
|
+
{
|
|
4964
|
+
id: "ssh-brute-force",
|
|
4965
|
+
title: "SSH Brute Force Detected",
|
|
4966
|
+
description: "Multiple failed SSH login attempts from the same source",
|
|
4967
|
+
version: "1.0.0",
|
|
4968
|
+
category: "auth",
|
|
4969
|
+
severity: "high",
|
|
4970
|
+
source_types: ["ssh-guard", "auth"],
|
|
4971
|
+
match: {
|
|
4972
|
+
field: "message",
|
|
4973
|
+
operator: "regex",
|
|
4974
|
+
value: "failed ssh login|invalid ssh user"
|
|
4975
|
+
},
|
|
4976
|
+
threshold: 5,
|
|
4977
|
+
window_seconds: 300,
|
|
4978
|
+
cooldown_seconds: 600,
|
|
4979
|
+
tags: ["ssh", "brute-force", "credential-stuffing"],
|
|
4980
|
+
remediation: {
|
|
4981
|
+
action: "block",
|
|
4982
|
+
ttl_seconds: 3600,
|
|
4983
|
+
description: "Block source IP for 1 hour"
|
|
4984
|
+
},
|
|
4985
|
+
enabled: true
|
|
3858
4986
|
},
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
4987
|
+
{
|
|
4988
|
+
id: "ssh-success-after-failures",
|
|
4989
|
+
title: "SSH Login After Failed Attempts",
|
|
4990
|
+
description: "Successful SSH login from an IP that had recent failures",
|
|
4991
|
+
version: "1.0.0",
|
|
4992
|
+
category: "auth",
|
|
4993
|
+
severity: "critical",
|
|
4994
|
+
source_types: ["ssh-guard", "auth"],
|
|
4995
|
+
match: {
|
|
4996
|
+
field: "message",
|
|
4997
|
+
operator: "contains",
|
|
4998
|
+
value: "SSH login accepted"
|
|
4999
|
+
},
|
|
5000
|
+
threshold: 1,
|
|
5001
|
+
window_seconds: 60,
|
|
5002
|
+
cooldown_seconds: 300,
|
|
5003
|
+
tags: ["ssh", "compromise-indicator"],
|
|
5004
|
+
enabled: true
|
|
3863
5005
|
},
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
5006
|
+
{
|
|
5007
|
+
id: "ssh-root-login",
|
|
5008
|
+
title: "Root SSH Login Attempt",
|
|
5009
|
+
description: "Direct root login via SSH detected",
|
|
5010
|
+
version: "1.0.0",
|
|
5011
|
+
category: "auth",
|
|
5012
|
+
severity: "high",
|
|
5013
|
+
source_types: ["ssh-guard", "auth"],
|
|
5014
|
+
match: {
|
|
5015
|
+
field: "message",
|
|
5016
|
+
operator: "regex",
|
|
5017
|
+
value: "(failed|accepted).*\\broot\\b"
|
|
5018
|
+
},
|
|
5019
|
+
threshold: 1,
|
|
5020
|
+
window_seconds: 60,
|
|
5021
|
+
cooldown_seconds: 300,
|
|
5022
|
+
tags: ["ssh", "root-access"],
|
|
5023
|
+
remediation: {
|
|
5024
|
+
action: "block",
|
|
5025
|
+
ttl_seconds: 7200,
|
|
5026
|
+
description: "Block source IP attempting root login"
|
|
5027
|
+
},
|
|
5028
|
+
enabled: true
|
|
5029
|
+
},
|
|
5030
|
+
{
|
|
5031
|
+
id: "ssh-user-enumeration",
|
|
5032
|
+
title: "SSH User Enumeration",
|
|
5033
|
+
description: "Multiple SSH attempts with different usernames from same source",
|
|
5034
|
+
version: "1.0.0",
|
|
5035
|
+
category: "auth",
|
|
5036
|
+
severity: "high",
|
|
5037
|
+
source_types: ["ssh-guard", "auth"],
|
|
5038
|
+
match: {
|
|
5039
|
+
field: "message",
|
|
5040
|
+
operator: "contains",
|
|
5041
|
+
value: "Invalid SSH user"
|
|
5042
|
+
},
|
|
5043
|
+
threshold: 3,
|
|
5044
|
+
window_seconds: 120,
|
|
5045
|
+
cooldown_seconds: 600,
|
|
5046
|
+
tags: ["ssh", "enumeration", "reconnaissance"],
|
|
5047
|
+
remediation: {
|
|
5048
|
+
action: "block",
|
|
5049
|
+
ttl_seconds: 3600,
|
|
5050
|
+
description: "Block source IP performing user enumeration"
|
|
5051
|
+
},
|
|
5052
|
+
enabled: true
|
|
5053
|
+
},
|
|
5054
|
+
{
|
|
5055
|
+
id: "sudo-abuse",
|
|
5056
|
+
title: "Sudo Authentication Failure",
|
|
5057
|
+
description: "Repeated sudo authentication failures",
|
|
5058
|
+
version: "1.0.0",
|
|
5059
|
+
category: "auth",
|
|
5060
|
+
severity: "high",
|
|
5061
|
+
source_types: ["user-journal", "system"],
|
|
5062
|
+
match: {
|
|
5063
|
+
field: "message",
|
|
5064
|
+
operator: "regex",
|
|
5065
|
+
value: "sudo.*authentication failure|sudo.*incorrect password|sudo.*FAILED"
|
|
5066
|
+
},
|
|
5067
|
+
threshold: 3,
|
|
5068
|
+
window_seconds: 300,
|
|
5069
|
+
cooldown_seconds: 600,
|
|
5070
|
+
tags: ["sudo", "privilege-escalation"],
|
|
5071
|
+
enabled: true
|
|
5072
|
+
},
|
|
5073
|
+
{
|
|
5074
|
+
id: "web-sqli-attack",
|
|
5075
|
+
title: "SQL Injection Attack Detected",
|
|
5076
|
+
description: "HTTP request with SQL injection patterns",
|
|
5077
|
+
version: "1.0.0",
|
|
5078
|
+
category: "web",
|
|
5079
|
+
severity: "critical",
|
|
5080
|
+
source_types: ["log-watcher", "web"],
|
|
5081
|
+
match: {
|
|
5082
|
+
field: "message",
|
|
5083
|
+
operator: "contains",
|
|
5084
|
+
value: "Attack detected [SQLI]"
|
|
5085
|
+
},
|
|
5086
|
+
threshold: 1,
|
|
5087
|
+
window_seconds: 60,
|
|
5088
|
+
cooldown_seconds: 300,
|
|
5089
|
+
tags: ["web", "sqli", "injection"],
|
|
5090
|
+
remediation: {
|
|
5091
|
+
action: "block",
|
|
5092
|
+
ttl_seconds: 3600,
|
|
5093
|
+
description: "Block source IP performing SQL injection"
|
|
5094
|
+
},
|
|
5095
|
+
enabled: true
|
|
5096
|
+
},
|
|
5097
|
+
{
|
|
5098
|
+
id: "web-path-traversal",
|
|
5099
|
+
title: "Path Traversal Attack Detected",
|
|
5100
|
+
description: "HTTP request with path traversal patterns",
|
|
5101
|
+
version: "1.0.0",
|
|
5102
|
+
category: "web",
|
|
5103
|
+
severity: "critical",
|
|
5104
|
+
source_types: ["log-watcher", "web"],
|
|
5105
|
+
match: {
|
|
5106
|
+
field: "message",
|
|
5107
|
+
operator: "contains",
|
|
5108
|
+
value: "Attack detected [PATH_TRAVERSAL]"
|
|
5109
|
+
},
|
|
5110
|
+
threshold: 1,
|
|
5111
|
+
window_seconds: 60,
|
|
5112
|
+
cooldown_seconds: 300,
|
|
5113
|
+
tags: ["web", "path-traversal", "lfi"],
|
|
5114
|
+
remediation: {
|
|
5115
|
+
action: "block",
|
|
5116
|
+
ttl_seconds: 3600,
|
|
5117
|
+
description: "Block source IP performing path traversal"
|
|
5118
|
+
},
|
|
5119
|
+
enabled: true
|
|
5120
|
+
},
|
|
5121
|
+
{
|
|
5122
|
+
id: "web-xss-attack",
|
|
5123
|
+
title: "XSS Attack Detected",
|
|
5124
|
+
description: "HTTP request with cross-site scripting patterns",
|
|
5125
|
+
version: "1.0.0",
|
|
5126
|
+
category: "web",
|
|
5127
|
+
severity: "high",
|
|
5128
|
+
source_types: ["log-watcher", "web"],
|
|
5129
|
+
match: {
|
|
5130
|
+
field: "message",
|
|
5131
|
+
operator: "regex",
|
|
5132
|
+
value: "Attack detected \\[XSS\\]"
|
|
5133
|
+
},
|
|
5134
|
+
threshold: 1,
|
|
5135
|
+
window_seconds: 60,
|
|
5136
|
+
cooldown_seconds: 300,
|
|
5137
|
+
tags: ["web", "xss", "injection"],
|
|
5138
|
+
remediation: {
|
|
5139
|
+
action: "block",
|
|
5140
|
+
ttl_seconds: 3600,
|
|
5141
|
+
description: "Block source IP performing XSS attack"
|
|
5142
|
+
},
|
|
5143
|
+
enabled: true
|
|
5144
|
+
},
|
|
5145
|
+
{
|
|
5146
|
+
id: "web-scanner-detection",
|
|
5147
|
+
title: "Web Vulnerability Scanner Detected",
|
|
5148
|
+
description: "High volume of 4xx errors suggesting automated scanning",
|
|
5149
|
+
version: "1.0.0",
|
|
5150
|
+
category: "web",
|
|
5151
|
+
severity: "medium",
|
|
5152
|
+
source_types: ["log-watcher", "web"],
|
|
5153
|
+
match: {
|
|
5154
|
+
field: "message",
|
|
5155
|
+
operator: "regex",
|
|
5156
|
+
value: "Client error 4\\d{2}:"
|
|
5157
|
+
},
|
|
5158
|
+
threshold: 20,
|
|
5159
|
+
window_seconds: 60,
|
|
5160
|
+
cooldown_seconds: 600,
|
|
5161
|
+
tags: ["web", "scanner", "reconnaissance"],
|
|
5162
|
+
remediation: {
|
|
5163
|
+
action: "block",
|
|
5164
|
+
ttl_seconds: 1800,
|
|
5165
|
+
description: "Block automated scanner"
|
|
5166
|
+
},
|
|
5167
|
+
enabled: true
|
|
5168
|
+
},
|
|
5169
|
+
{
|
|
5170
|
+
id: "port-scan-indicator",
|
|
5171
|
+
title: "Port Scan Indicators",
|
|
5172
|
+
description: "Connection attempts to many ports from a single source",
|
|
5173
|
+
version: "1.0.0",
|
|
5174
|
+
category: "network",
|
|
5175
|
+
severity: "medium",
|
|
5176
|
+
source_types: ["network-monitor", "network"],
|
|
5177
|
+
match: {
|
|
5178
|
+
field: "message",
|
|
5179
|
+
operator: "contains",
|
|
5180
|
+
value: "port scan"
|
|
5181
|
+
},
|
|
5182
|
+
threshold: 1,
|
|
5183
|
+
window_seconds: 60,
|
|
5184
|
+
cooldown_seconds: 300,
|
|
5185
|
+
tags: ["network", "port-scan", "reconnaissance"],
|
|
5186
|
+
remediation: {
|
|
5187
|
+
action: "block",
|
|
5188
|
+
ttl_seconds: 3600,
|
|
5189
|
+
description: "Block port scanner"
|
|
5190
|
+
},
|
|
5191
|
+
enabled: true
|
|
5192
|
+
},
|
|
5193
|
+
{
|
|
5194
|
+
id: "system-critical-error",
|
|
5195
|
+
title: "Critical System Error",
|
|
5196
|
+
description: "Critical or emergency level system log message",
|
|
5197
|
+
version: "1.0.0",
|
|
5198
|
+
category: "system",
|
|
5199
|
+
severity: "critical",
|
|
5200
|
+
source_types: ["user-journal", "system"],
|
|
5201
|
+
match: {
|
|
5202
|
+
field: "severity",
|
|
5203
|
+
operator: "equals",
|
|
5204
|
+
value: "critical"
|
|
5205
|
+
},
|
|
5206
|
+
threshold: 1,
|
|
5207
|
+
window_seconds: 60,
|
|
5208
|
+
cooldown_seconds: 300,
|
|
5209
|
+
tags: ["system", "critical"],
|
|
5210
|
+
enabled: true
|
|
5211
|
+
},
|
|
5212
|
+
{
|
|
5213
|
+
id: "exploit-probe-pattern",
|
|
5214
|
+
title: "Exploit Probe Pattern",
|
|
5215
|
+
description: "HTTP requests matching common exploit probe patterns",
|
|
5216
|
+
version: "1.0.0",
|
|
5217
|
+
category: "web",
|
|
5218
|
+
severity: "high",
|
|
5219
|
+
source_types: ["log-watcher", "web"],
|
|
5220
|
+
match: {
|
|
5221
|
+
field: "message",
|
|
5222
|
+
operator: "regex",
|
|
5223
|
+
value: "Attack detected \\[(CMD_INJECTION|RCE|SSRF|XXE)\\]"
|
|
5224
|
+
},
|
|
5225
|
+
threshold: 1,
|
|
5226
|
+
window_seconds: 60,
|
|
5227
|
+
cooldown_seconds: 300,
|
|
5228
|
+
tags: ["web", "exploit", "probe"],
|
|
5229
|
+
remediation: {
|
|
5230
|
+
action: "block",
|
|
5231
|
+
ttl_seconds: 7200,
|
|
5232
|
+
description: "Block source IP performing exploit probes"
|
|
5233
|
+
},
|
|
5234
|
+
enabled: true
|
|
5235
|
+
}
|
|
5236
|
+
];
|
|
5237
|
+
|
|
5238
|
+
// src/daemon/rules/loader.ts
|
|
5239
|
+
var RULES_DIR = "/etc/threatcrush/rules.d";
|
|
5240
|
+
function loadAllRules(customDir) {
|
|
5241
|
+
const rules = [...DEFAULT_RULES];
|
|
5242
|
+
const dir = customDir || RULES_DIR;
|
|
5243
|
+
if ((0, import_node_fs11.existsSync)(dir)) {
|
|
5244
|
+
const files = (0, import_node_fs11.readdirSync)(dir).filter((f) => f.endsWith(".json"));
|
|
5245
|
+
for (const file of files) {
|
|
5246
|
+
try {
|
|
5247
|
+
const raw = (0, import_node_fs11.readFileSync)((0, import_node_path6.join)(dir, file), "utf-8");
|
|
5248
|
+
const parsed = JSON.parse(raw);
|
|
5249
|
+
const customRules = Array.isArray(parsed) ? parsed : [parsed];
|
|
5250
|
+
for (const rule of customRules) {
|
|
5251
|
+
if (!rule.id || !rule.title || !rule.match) {
|
|
5252
|
+
console.warn(`[rules] skipping invalid rule in ${file}: missing required fields`);
|
|
5253
|
+
continue;
|
|
5254
|
+
}
|
|
5255
|
+
const existingIdx = rules.findIndex((r) => r.id === rule.id);
|
|
5256
|
+
if (existingIdx >= 0) {
|
|
5257
|
+
rules[existingIdx] = { ...rules[existingIdx], ...rule };
|
|
5258
|
+
} else {
|
|
5259
|
+
rules.push(rule);
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
} catch (err) {
|
|
5263
|
+
console.warn(`[rules] failed to load ${file}: ${err.message}`);
|
|
5264
|
+
}
|
|
5265
|
+
}
|
|
5266
|
+
}
|
|
5267
|
+
return rules;
|
|
5268
|
+
}
|
|
5269
|
+
|
|
5270
|
+
// src/daemon/firewall/adapters.ts
|
|
5271
|
+
var import_node_child_process3 = require("child_process");
|
|
5272
|
+
var NftablesAdapter = class {
|
|
5273
|
+
name = "nftables";
|
|
5274
|
+
table = "threatcrush";
|
|
5275
|
+
set = "blocklist";
|
|
5276
|
+
isAvailable() {
|
|
5277
|
+
const result = (0, import_node_child_process3.spawnSync)("nft", ["--version"], { stdio: "pipe" });
|
|
5278
|
+
return result.status === 0;
|
|
5279
|
+
}
|
|
5280
|
+
ensureSetup() {
|
|
5281
|
+
try {
|
|
5282
|
+
(0, import_node_child_process3.execSync)(`nft list table inet ${this.table} 2>/dev/null`, { stdio: "pipe" });
|
|
5283
|
+
} catch {
|
|
5284
|
+
(0, import_node_child_process3.execSync)(`nft add table inet ${this.table}`);
|
|
5285
|
+
(0, import_node_child_process3.execSync)(`nft add set inet ${this.table} ${this.set} '{ type ipv4_addr; flags timeout; }'`);
|
|
5286
|
+
(0, import_node_child_process3.execSync)(`nft add chain inet ${this.table} input '{ type filter hook input priority -1; policy accept; }'`);
|
|
5287
|
+
(0, import_node_child_process3.execSync)(`nft add rule inet ${this.table} input ip saddr @${this.set} drop`);
|
|
5288
|
+
}
|
|
5289
|
+
}
|
|
5290
|
+
async block(ip) {
|
|
5291
|
+
this.ensureSetup();
|
|
5292
|
+
(0, import_node_child_process3.execSync)(`nft add element inet ${this.table} ${this.set} '{ ${ip} }'`);
|
|
5293
|
+
}
|
|
5294
|
+
async unblock(ip) {
|
|
5295
|
+
try {
|
|
5296
|
+
(0, import_node_child_process3.execSync)(`nft delete element inet ${this.table} ${this.set} '{ ${ip} }'`);
|
|
5297
|
+
} catch {
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
async isBlocked(ip) {
|
|
5301
|
+
try {
|
|
5302
|
+
const output = (0, import_node_child_process3.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
|
|
5303
|
+
return output.includes(ip);
|
|
5304
|
+
} catch {
|
|
5305
|
+
return false;
|
|
5306
|
+
}
|
|
5307
|
+
}
|
|
5308
|
+
async listBlocked() {
|
|
5309
|
+
try {
|
|
5310
|
+
const output = (0, import_node_child_process3.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
|
|
5311
|
+
const match = output.match(/elements\s*=\s*\{([^}]*)\}/);
|
|
5312
|
+
if (!match) return [];
|
|
5313
|
+
return match[1].split(",").map((s) => s.trim().split(/\s/)[0]).filter(Boolean);
|
|
5314
|
+
} catch {
|
|
5315
|
+
return [];
|
|
5316
|
+
}
|
|
3870
5317
|
}
|
|
3871
5318
|
};
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
5319
|
+
var IptablesAdapter = class {
|
|
5320
|
+
name = "iptables";
|
|
5321
|
+
chain = "THREATCRUSH";
|
|
5322
|
+
isAvailable() {
|
|
5323
|
+
const result = (0, import_node_child_process3.spawnSync)("iptables", ["--version"], { stdio: "pipe" });
|
|
5324
|
+
return result.status === 0;
|
|
3876
5325
|
}
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
5326
|
+
ensureChain() {
|
|
5327
|
+
try {
|
|
5328
|
+
(0, import_node_child_process3.execSync)(`iptables -n -L ${this.chain} 2>/dev/null`, { stdio: "pipe" });
|
|
5329
|
+
} catch {
|
|
5330
|
+
(0, import_node_child_process3.execSync)(`iptables -N ${this.chain}`);
|
|
5331
|
+
(0, import_node_child_process3.execSync)(`iptables -I INPUT 1 -j ${this.chain}`);
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
async block(ip) {
|
|
5335
|
+
this.ensureChain();
|
|
5336
|
+
if (await this.isBlocked(ip)) return;
|
|
5337
|
+
(0, import_node_child_process3.execSync)(`iptables -A ${this.chain} -s ${ip} -j DROP`);
|
|
5338
|
+
}
|
|
5339
|
+
async unblock(ip) {
|
|
5340
|
+
try {
|
|
5341
|
+
(0, import_node_child_process3.execSync)(`iptables -D ${this.chain} -s ${ip} -j DROP`);
|
|
5342
|
+
} catch {
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
async isBlocked(ip) {
|
|
5346
|
+
try {
|
|
5347
|
+
const output = (0, import_node_child_process3.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
|
|
5348
|
+
return output.includes(ip);
|
|
5349
|
+
} catch {
|
|
5350
|
+
return false;
|
|
5351
|
+
}
|
|
5352
|
+
}
|
|
5353
|
+
async listBlocked() {
|
|
5354
|
+
try {
|
|
5355
|
+
const output = (0, import_node_child_process3.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
|
|
5356
|
+
const ips = [];
|
|
5357
|
+
for (const line of output.split("\n")) {
|
|
5358
|
+
const match = line.match(/DROP\s+all\s+--\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
5359
|
+
if (match) ips.push(match[1]);
|
|
5360
|
+
}
|
|
5361
|
+
return ips;
|
|
5362
|
+
} catch {
|
|
5363
|
+
return [];
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
};
|
|
5367
|
+
var DryRunAdapter = class {
|
|
5368
|
+
name = "dry-run";
|
|
5369
|
+
blocked = /* @__PURE__ */ new Set();
|
|
5370
|
+
isAvailable() {
|
|
5371
|
+
return true;
|
|
5372
|
+
}
|
|
5373
|
+
async block(ip) {
|
|
5374
|
+
this.blocked.add(ip);
|
|
5375
|
+
}
|
|
5376
|
+
async unblock(ip) {
|
|
5377
|
+
this.blocked.delete(ip);
|
|
3889
5378
|
}
|
|
5379
|
+
async isBlocked(ip) {
|
|
5380
|
+
return this.blocked.has(ip);
|
|
5381
|
+
}
|
|
5382
|
+
async listBlocked() {
|
|
5383
|
+
return [...this.blocked];
|
|
5384
|
+
}
|
|
5385
|
+
};
|
|
5386
|
+
function detectFirewallAdapter() {
|
|
5387
|
+
const nft = new NftablesAdapter();
|
|
5388
|
+
if (nft.isAvailable()) return nft;
|
|
5389
|
+
const ipt = new IptablesAdapter();
|
|
5390
|
+
if (ipt.isAvailable()) return ipt;
|
|
5391
|
+
return new DryRunAdapter();
|
|
3890
5392
|
}
|
|
3891
5393
|
|
|
5394
|
+
// src/daemon/firewall/remediation.ts
|
|
5395
|
+
var import_node_fs12 = require("fs");
|
|
5396
|
+
var DEFAULT_CONFIG2 = {
|
|
5397
|
+
enabled: true,
|
|
5398
|
+
dry_run: true,
|
|
5399
|
+
default_ttl_seconds: 3600,
|
|
5400
|
+
min_severity: "high",
|
|
5401
|
+
allowlist: ["127.0.0.1", "::1"]
|
|
5402
|
+
};
|
|
5403
|
+
var SEVERITY_RANK4 = {
|
|
5404
|
+
info: 0,
|
|
5405
|
+
low: 1,
|
|
5406
|
+
medium: 2,
|
|
5407
|
+
high: 3,
|
|
5408
|
+
critical: 4
|
|
5409
|
+
};
|
|
5410
|
+
var RemediationManager = class {
|
|
5411
|
+
constructor(adapter, bus2, config) {
|
|
5412
|
+
this.adapter = adapter;
|
|
5413
|
+
this.bus = bus2;
|
|
5414
|
+
this.config = { ...DEFAULT_CONFIG2, ...config };
|
|
5415
|
+
this.loadState();
|
|
5416
|
+
this.startExpiryWorker();
|
|
5417
|
+
}
|
|
5418
|
+
adapter;
|
|
5419
|
+
bus;
|
|
5420
|
+
config;
|
|
5421
|
+
blocklist = [];
|
|
5422
|
+
expiryTimer = null;
|
|
5423
|
+
async handleDetection(event) {
|
|
5424
|
+
if (!this.config.enabled) return;
|
|
5425
|
+
const eventRank = SEVERITY_RANK4[event.severity] ?? 0;
|
|
5426
|
+
const minRank = SEVERITY_RANK4[this.config.min_severity] ?? 3;
|
|
5427
|
+
if (eventRank < minRank) return;
|
|
5428
|
+
const ip = event.source_ip;
|
|
5429
|
+
if (!ip) return;
|
|
5430
|
+
if (this.isAllowlisted(ip)) return;
|
|
5431
|
+
if (this.blocklist.some((b) => b.ip === ip)) return;
|
|
5432
|
+
const ruleRemediation = event.details?.remediation;
|
|
5433
|
+
const ttl = ruleRemediation?.ttl_seconds || this.config.default_ttl_seconds;
|
|
5434
|
+
const ruleId = event.details?.rule_id;
|
|
5435
|
+
await this.blockIp(ip, event.message, ruleId, ttl);
|
|
5436
|
+
}
|
|
5437
|
+
async blockIp(ip, reason, ruleId, ttlSeconds) {
|
|
5438
|
+
if (this.isAllowlisted(ip)) return false;
|
|
5439
|
+
const entry = {
|
|
5440
|
+
ip,
|
|
5441
|
+
reason,
|
|
5442
|
+
rule_id: ruleId,
|
|
5443
|
+
blocked_at: Date.now(),
|
|
5444
|
+
expires_at: ttlSeconds ? Date.now() + ttlSeconds * 1e3 : void 0,
|
|
5445
|
+
dry_run: this.config.dry_run
|
|
5446
|
+
};
|
|
5447
|
+
if (!this.config.dry_run) {
|
|
5448
|
+
try {
|
|
5449
|
+
await this.adapter.block(ip);
|
|
5450
|
+
} catch (err) {
|
|
5451
|
+
this.logLine(`[firewall] EACCES or error blocking ${ip}: ${err.message}`);
|
|
5452
|
+
this.bus.publish({
|
|
5453
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
5454
|
+
module: "firewall-rules",
|
|
5455
|
+
category: "system",
|
|
5456
|
+
severity: "medium",
|
|
5457
|
+
message: `Failed to block ${ip}: ${err.message}. Ensure daemon has CAP_NET_ADMIN.`
|
|
5458
|
+
});
|
|
5459
|
+
return false;
|
|
5460
|
+
}
|
|
5461
|
+
}
|
|
5462
|
+
this.blocklist.push(entry);
|
|
5463
|
+
this.saveState();
|
|
5464
|
+
const mode = this.config.dry_run ? "[DRY-RUN] " : "";
|
|
5465
|
+
const expiryMsg = ttlSeconds ? ` (expires in ${ttlSeconds}s)` : " (permanent)";
|
|
5466
|
+
this.logLine(`[firewall] ${mode}Blocked ${ip}: ${reason}${expiryMsg}`);
|
|
5467
|
+
this.bus.publish({
|
|
5468
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
5469
|
+
module: "firewall-rules",
|
|
5470
|
+
category: "system",
|
|
5471
|
+
severity: "info",
|
|
5472
|
+
message: `${mode}Blocked ${ip}: ${reason}${expiryMsg}`,
|
|
5473
|
+
source_ip: ip,
|
|
5474
|
+
details: { action: "block", rule_id: ruleId, dry_run: this.config.dry_run, ttl_seconds: ttlSeconds }
|
|
5475
|
+
});
|
|
5476
|
+
return true;
|
|
5477
|
+
}
|
|
5478
|
+
async unblockIp(ip) {
|
|
5479
|
+
const idx = this.blocklist.findIndex((b) => b.ip === ip);
|
|
5480
|
+
if (idx < 0) return false;
|
|
5481
|
+
const entry = this.blocklist[idx];
|
|
5482
|
+
if (!entry.dry_run) {
|
|
5483
|
+
try {
|
|
5484
|
+
await this.adapter.unblock(ip);
|
|
5485
|
+
} catch (err) {
|
|
5486
|
+
this.logLine(`[firewall] Error unblocking ${ip}: ${err.message}`);
|
|
5487
|
+
return false;
|
|
5488
|
+
}
|
|
5489
|
+
}
|
|
5490
|
+
this.blocklist.splice(idx, 1);
|
|
5491
|
+
this.saveState();
|
|
5492
|
+
this.logLine(`[firewall] Unblocked ${ip}`);
|
|
5493
|
+
this.bus.publish({
|
|
5494
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
5495
|
+
module: "firewall-rules",
|
|
5496
|
+
category: "system",
|
|
5497
|
+
severity: "info",
|
|
5498
|
+
message: `Unblocked ${ip}`,
|
|
5499
|
+
source_ip: ip,
|
|
5500
|
+
details: { action: "unblock" }
|
|
5501
|
+
});
|
|
5502
|
+
return true;
|
|
5503
|
+
}
|
|
5504
|
+
isAllowlisted(ip) {
|
|
5505
|
+
return this.config.allowlist.includes(ip);
|
|
5506
|
+
}
|
|
5507
|
+
addToAllowlist(ip) {
|
|
5508
|
+
if (!this.config.allowlist.includes(ip)) {
|
|
5509
|
+
this.config.allowlist.push(ip);
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
5512
|
+
removeFromAllowlist(ip) {
|
|
5513
|
+
this.config.allowlist = this.config.allowlist.filter((a) => a !== ip);
|
|
5514
|
+
}
|
|
5515
|
+
getBlocklist() {
|
|
5516
|
+
return [...this.blocklist];
|
|
5517
|
+
}
|
|
5518
|
+
getAllowlist() {
|
|
5519
|
+
return [...this.config.allowlist];
|
|
5520
|
+
}
|
|
5521
|
+
stop() {
|
|
5522
|
+
if (this.expiryTimer) clearInterval(this.expiryTimer);
|
|
5523
|
+
this.expiryTimer = null;
|
|
5524
|
+
}
|
|
5525
|
+
startExpiryWorker() {
|
|
5526
|
+
this.expiryTimer = setInterval(() => void this.processExpiries(), 3e4);
|
|
5527
|
+
}
|
|
5528
|
+
async processExpiries() {
|
|
5529
|
+
const now = Date.now();
|
|
5530
|
+
const expired = this.blocklist.filter((b) => b.expires_at && b.expires_at <= now);
|
|
5531
|
+
for (const entry of expired) {
|
|
5532
|
+
await this.unblockIp(entry.ip);
|
|
5533
|
+
}
|
|
5534
|
+
}
|
|
5535
|
+
loadState() {
|
|
5536
|
+
try {
|
|
5537
|
+
const saved = getModuleState("firewall-rules", "blocklist");
|
|
5538
|
+
if (Array.isArray(saved)) this.blocklist = saved;
|
|
5539
|
+
} catch {
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
5542
|
+
saveState() {
|
|
5543
|
+
try {
|
|
5544
|
+
setModuleState("firewall-rules", "blocklist", this.blocklist);
|
|
5545
|
+
} catch {
|
|
5546
|
+
}
|
|
5547
|
+
}
|
|
5548
|
+
logLine(line) {
|
|
5549
|
+
try {
|
|
5550
|
+
(0, import_node_fs12.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
5551
|
+
`);
|
|
5552
|
+
} catch {
|
|
5553
|
+
}
|
|
5554
|
+
}
|
|
5555
|
+
};
|
|
5556
|
+
|
|
3892
5557
|
// src/core/telemetry.ts
|
|
3893
5558
|
var ready = false;
|
|
3894
5559
|
var sentry = null;
|
|
@@ -3938,7 +5603,7 @@ async function flushTelemetry(timeoutMs = 2e3) {
|
|
|
3938
5603
|
// src/daemon/index.ts
|
|
3939
5604
|
function readVersion() {
|
|
3940
5605
|
try {
|
|
3941
|
-
const pkg = JSON.parse((0,
|
|
5606
|
+
const pkg = JSON.parse((0, import_node_fs13.readFileSync)((0, import_node_path7.join)(__dirname, "..", "package.json"), "utf-8"));
|
|
3942
5607
|
return pkg.version || "0.0.0";
|
|
3943
5608
|
} catch {
|
|
3944
5609
|
return "0.0.0";
|
|
@@ -3946,7 +5611,7 @@ function readVersion() {
|
|
|
3946
5611
|
}
|
|
3947
5612
|
function logLine(line) {
|
|
3948
5613
|
try {
|
|
3949
|
-
(0,
|
|
5614
|
+
(0, import_node_fs13.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
3950
5615
|
`);
|
|
3951
5616
|
} catch {
|
|
3952
5617
|
}
|
|
@@ -3974,12 +5639,42 @@ async function runDaemon() {
|
|
|
3974
5639
|
} catch (err) {
|
|
3975
5640
|
logLine(`[daemon] state db unavailable: ${err.message}`);
|
|
3976
5641
|
}
|
|
3977
|
-
const config = loadConfig((0,
|
|
5642
|
+
const config = loadConfig((0, import_node_fs13.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
|
|
3978
5643
|
bus.on("event", (event) => {
|
|
3979
5644
|
logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
|
|
3980
5645
|
});
|
|
3981
5646
|
const moduleHost = new ModuleHost(bus);
|
|
3982
5647
|
await moduleHost.start();
|
|
5648
|
+
const ruleEngine = new RuleEngine((detection) => {
|
|
5649
|
+
const event = {
|
|
5650
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
5651
|
+
module: "rule-engine",
|
|
5652
|
+
category: detection.raw_metadata?.category || "system",
|
|
5653
|
+
severity: detection.severity,
|
|
5654
|
+
message: `[DETECTION] ${detection.title}`,
|
|
5655
|
+
source_ip: detection.source_ip,
|
|
5656
|
+
details: {
|
|
5657
|
+
rule_id: detection.rule_id,
|
|
5658
|
+
username: detection.username,
|
|
5659
|
+
...detection.raw_metadata
|
|
5660
|
+
}
|
|
5661
|
+
};
|
|
5662
|
+
bus.publish(event);
|
|
5663
|
+
});
|
|
5664
|
+
ruleEngine.loadRules(loadAllRules());
|
|
5665
|
+
bus.on("event", (event) => {
|
|
5666
|
+
if (event.module !== "rule-engine") ruleEngine.evaluate(event);
|
|
5667
|
+
});
|
|
5668
|
+
logLine(`[daemon] rule engine loaded ${ruleEngine.getRules().length} rules`);
|
|
5669
|
+
setInterval(() => ruleEngine.cleanup(), 3e5);
|
|
5670
|
+
const firewallAdapter = detectFirewallAdapter();
|
|
5671
|
+
const remediation = new RemediationManager(firewallAdapter, bus, config.remediation);
|
|
5672
|
+
bus.on("event", (event) => {
|
|
5673
|
+
if (event.module !== "firewall-rules") {
|
|
5674
|
+
void remediation.handleDetection(event);
|
|
5675
|
+
}
|
|
5676
|
+
});
|
|
5677
|
+
logLine(`[daemon] firewall remediation active (backend=${firewallAdapter.name}, dry_run=${config.remediation?.dry_run ?? true})`);
|
|
3983
5678
|
new AlertDispatcher(bus, config);
|
|
3984
5679
|
const runsWorker = new RunsWorker(bus);
|
|
3985
5680
|
try {
|
|
@@ -3992,6 +5687,10 @@ async function runDaemon() {
|
|
|
3992
5687
|
logLine(`[daemon] ipc listening on ${PATHS.socket}`);
|
|
3993
5688
|
const shutdown = async (signal) => {
|
|
3994
5689
|
logLine(`[daemon] received ${signal}, shutting down`);
|
|
5690
|
+
try {
|
|
5691
|
+
remediation.stop();
|
|
5692
|
+
} catch {
|
|
5693
|
+
}
|
|
3995
5694
|
try {
|
|
3996
5695
|
runsWorker.stop();
|
|
3997
5696
|
} catch {
|