@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 CHANGED
@@ -2022,27 +2022,18 @@ var require_toml = __commonJS({
2022
2022
  });
2023
2023
 
2024
2024
  // src/daemon/index.ts
2025
- var import_node_fs9 = require("fs");
2026
- var import_node_path6 = require("path");
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 canWriteSystemPaths() {
2033
- if (process.platform !== "linux") return false;
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 PATHS = systemMode ? {
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
- return false;
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
- db = new import_better_sqlite3.default(dbPath);
2141
- } catch {
2142
- db = new import_better_sqlite3.default(":memory:");
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 = db || initStateDB();
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 = db || initStateDB();
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 = db || initStateDB();
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 = db || initStateDB();
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 = db || initStateDB();
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
- require("fs").chmodSync(PATHS.socket, 432);
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 import_node_fs5 = require("fs");
2417
- var import_node_path2 = require("path");
2418
- var import_toml = __toESM(require_toml());
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/module-host.ts
2678
- var ModuleHost = class {
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
- modules = /* @__PURE__ */ new Map();
2688
- logWatcher = null;
2689
- async start() {
2690
- this.registerBuiltins();
2691
- this.discoverInstalled();
2692
- this.logWatcher = new LogWatcher(this.bus);
2693
- const watched = this.logWatcher.start();
2694
- for (const modName of this.logWatcher.activeModules()) {
2695
- const mod = this.modules.get(modName);
2696
- if (mod) {
2697
- mod.status = "running";
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
- summary() {
2711
- return [...this.modules.values()].map((m) => ({
2712
- name: m.name,
2713
- status: m.status,
2714
- events: m.events,
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
- registerBuiltins() {
2719
- const builtins = [
2720
- { name: "log-watcher", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
2721
- { name: "ssh-guard", version: "0.1.0", source: "builtin", status: "loaded", events: 0 }
2722
- ];
2723
- for (const m of builtins) this.modules.set(m.name, m);
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
- discoverInstalled() {
2726
- if (!(0, import_node_fs5.existsSync)(PATHS.moduleDir)) return;
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
- const manifest = import_toml.default.parse((0, import_node_fs5.readFileSync)(manifestPath, "utf-8"));
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
- // src/daemon/alerts/smtp.ts
2749
- var transporter = null;
2750
- var nodemailer = null;
2751
- var SEVERITY_RANK = {
2752
- info: 0,
2753
- low: 1,
2754
- medium: 2,
2755
- high: 3,
2756
- critical: 4
2757
- };
2758
- async function ensureTransporter(config) {
2759
- if (!config.host || !config.from) return null;
2760
- if (transporter) return transporter;
2761
- if (!nodemailer) {
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
- nodemailer = await import("nodemailer");
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
- transporter = nodemailer.createTransport({
2769
- host: config.host,
2770
- port: config.port ?? 587,
2771
- secure: config.secure ?? false,
2772
- auth: config.user && config.pass ? { user: config.user, pass: config.pass } : void 0
2773
- });
2774
- return transporter;
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 renderBody(event) {
2781
- const ts = event.timestamp.toISOString();
2782
- const ip = event.source_ip ? `
2783
- Source IP: ${event.source_ip}` : "";
2784
- const text = `[${event.severity.toUpperCase()}] ${event.module}
2785
-
2786
- ${event.message}${ip}
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 smtpChannel(config) {
2794
- return async (event) => {
2795
- if (!meetsSeverity(event, config.min_severity)) return;
2796
- const t = await ensureTransporter(config);
2797
- if (!t) return;
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/daemon/alerts/index.ts
2812
- var AlertDispatcher = class {
2813
- constructor(bus2, config) {
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
- config;
2823
- channels = [];
2824
- bindChannels() {
2825
- const alerts = this.config.alerts || {};
2826
- for (const [name, raw] of Object.entries(alerts)) {
2827
- const cfg = raw;
2828
- if (!cfg.enabled) continue;
2829
- if (name === "webhook" && typeof cfg.url === "string") {
2830
- this.channels.push(webhookChannel(cfg.url, cfg.secret));
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
- if (name === "slack" && typeof cfg.webhook_url === "string") {
2833
- this.channels.push(slackChannel(cfg.webhook_url));
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
- if (name === "email" && typeof cfg.host === "string" && typeof cfg.from === "string") {
2836
- this.channels.push(smtpChannel(cfg));
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
- async dispatch(event) {
2841
- await Promise.all(this.channels.map((ch) => ch(event).catch(() => {
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
- function webhookChannel(url, secret) {
2846
- return async (event) => {
2847
- const body = JSON.stringify({ event });
2848
- const headers = { "Content-Type": "application/json" };
2849
- if (secret) headers["X-Threatcrush-Signature"] = secret;
2850
- await fetch(url, { method: "POST", headers, body });
2851
- };
2852
- }
2853
- function slackChannel(webhookUrl) {
2854
- return async (event) => {
2855
- const emoji = event.severity === "critical" ? ":rotating_light:" : ":warning:";
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 import_node_fs6 = require("fs");
2867
- var import_node_path3 = require("path");
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, import_node_path3.join)((0, import_node_os2.homedir)(), ".threatcrush");
2870
- var CLI_CONFIG_PATH = (0, import_node_path3.join)(CLI_CONFIG_DIR, "config.json");
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, import_node_fs6.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
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 import_node_fs7 = require("fs");
2893
- var import_node_path4 = require("path");
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 SEVERITY_COLORS = {
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, import_node_fs7.readdirSync)(currentPath, { withFileTypes: true });
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, import_node_path4.join)(currentPath, entry.name);
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, import_node_path4.relative)(basePath, fullPath),
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, import_node_path4.extname)(entry.name).toLowerCase();
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, import_node_fs7.statSync)(fullPath);
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, import_node_fs7.readFileSync)(fullPath, "utf-8");
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, import_node_path4.relative)(basePath, fullPath),
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/core/config.ts
3847
- var import_node_fs8 = require("fs");
3848
- var import_node_path5 = require("path");
3849
- var import_toml2 = __toESM(require_toml());
3850
- var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
3851
- var DEFAULT_CONFDIR = "/etc/threatcrush/threatcrushd.conf.d";
3852
- var DEFAULT_CONFIG = {
3853
- daemon: {
3854
- pid_file: "/var/run/threatcrush/threatcrushd.pid",
3855
- log_level: "info",
3856
- log_file: "/var/log/threatcrush/threatcrushd.log",
3857
- state_db: "/var/lib/threatcrush/state.db"
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
- api: {
3860
- enabled: true,
3861
- bind: "127.0.0.1:9393",
3862
- tls: false
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
- alerts: {},
3865
- modules: {
3866
- auto_update: true,
3867
- update_interval: "24h",
3868
- module_dir: "/etc/threatcrush/modules",
3869
- config_dir: DEFAULT_CONFDIR
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
- function loadConfig(configPath) {
3873
- const path = configPath || DEFAULT_CONFIG_PATH;
3874
- if (!(0, import_node_fs8.existsSync)(path)) {
3875
- return { ...DEFAULT_CONFIG };
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
- try {
3878
- const raw = (0, import_node_fs8.readFileSync)(path, "utf-8");
3879
- const parsed = import_toml2.default.parse(raw);
3880
- return {
3881
- daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
3882
- api: { ...DEFAULT_CONFIG.api, ...parsed.api },
3883
- alerts: parsed.alerts || {},
3884
- modules: { ...DEFAULT_CONFIG.modules, ...parsed.modules },
3885
- license: parsed.license
3886
- };
3887
- } catch {
3888
- return { ...DEFAULT_CONFIG };
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, import_node_fs9.readFileSync)((0, import_node_path6.join)(__dirname, "..", "package.json"), "utf-8"));
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, import_node_fs9.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
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, import_node_fs9.existsSync)(PATHS.configFile) ? PATHS.configFile : void 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 {