@profullstack/threatcrush 0.2.1 → 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() {
@@ -2320,10 +2314,19 @@ var IpcServer = class {
2320
2314
  this.server = (0, import_node_net.createServer)((sock) => this.handleClient(sock));
2321
2315
  this.server.on("error", reject);
2322
2316
  this.server.listen(PATHS.socket, () => {
2317
+ const nodeFs = require("fs");
2323
2318
  try {
2324
- require("fs").chmodSync(PATHS.socket, 432);
2319
+ nodeFs.chmodSync(PATHS.socket, 432);
2325
2320
  } catch {
2326
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
+ }
2327
2330
  resolve();
2328
2331
  });
2329
2332
  });
@@ -2452,7 +2455,7 @@ var IpcServer = class {
2452
2455
  };
2453
2456
 
2454
2457
  // src/daemon/module-host.ts
2455
- var import_node_fs6 = require("fs");
2458
+ var import_node_fs8 = require("fs");
2456
2459
  var import_node_path3 = require("path");
2457
2460
  var import_node_url = require("url");
2458
2461
  var import_toml2 = __toESM(require_toml());
@@ -2725,8 +2728,16 @@ var JournalWatcher = class _JournalWatcher {
2725
2728
  buffer = "";
2726
2729
  moduleName = "user-journal";
2727
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"];
2738
+ }
2728
2739
  static isAvailable() {
2729
- const probe = (0, import_node_child_process.spawnSync)("journalctl", ["--user", "-n", "0", "--no-pager"], {
2740
+ const probe = (0, import_node_child_process.spawnSync)("journalctl", [...this.scopeArgs(), "-n", "0", "--no-pager"], {
2730
2741
  stdio: ["ignore", "ignore", "ignore"]
2731
2742
  });
2732
2743
  return probe.status === 0;
@@ -2735,7 +2746,7 @@ var JournalWatcher = class _JournalWatcher {
2735
2746
  if (!_JournalWatcher.isAvailable()) return false;
2736
2747
  const child = (0, import_node_child_process.spawn)(
2737
2748
  "journalctl",
2738
- ["--user", "-o", "json", "-f", "--since", "now"],
2749
+ [..._JournalWatcher.scopeArgs(), "-o", "json", "-f", "--since", "now"],
2739
2750
  { stdio: ["ignore", "pipe", "pipe"] }
2740
2751
  );
2741
2752
  if (!child.stdout) return false;
@@ -2825,8 +2836,395 @@ function realtimeToDate(rt) {
2825
2836
  return new Date(Math.floor(us / 1e3));
2826
2837
  }
2827
2838
 
2828
- // src/core/config.ts
2839
+ // src/modules/network-monitor/index.ts
2840
+ var import_node_child_process2 = require("child_process");
2829
2841
  var import_node_fs5 = require("fs");
2842
+ var NetworkMonitor = class {
2843
+ constructor(bus2) {
2844
+ this.bus = bus2;
2845
+ }
2846
+ bus;
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;
2908
+ }
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
+ }
2931
+ }
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);
2956
+ }
2957
+ }
2958
+ }
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.");
3014
+ }
3015
+ };
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");
2830
3228
  var import_node_path2 = require("path");
2831
3229
  var import_toml = __toESM(require_toml());
2832
3230
  var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
@@ -2853,11 +3251,11 @@ var DEFAULT_CONFIG = {
2853
3251
  };
2854
3252
  function loadConfig(configPath) {
2855
3253
  const path = configPath || DEFAULT_CONFIG_PATH;
2856
- if (!(0, import_node_fs5.existsSync)(path)) {
3254
+ if (!(0, import_node_fs7.existsSync)(path)) {
2857
3255
  return { ...DEFAULT_CONFIG };
2858
3256
  }
2859
3257
  try {
2860
- const raw = (0, import_node_fs5.readFileSync)(path, "utf-8");
3258
+ const raw = (0, import_node_fs7.readFileSync)(path, "utf-8");
2861
3259
  const parsed = import_toml.default.parse(raw);
2862
3260
  return {
2863
3261
  daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
@@ -2873,13 +3271,13 @@ function loadConfig(configPath) {
2873
3271
  function loadModuleConfigs(confDir) {
2874
3272
  const dir = confDir || DEFAULT_CONFDIR;
2875
3273
  const configs = /* @__PURE__ */ new Map();
2876
- if (!(0, import_node_fs5.existsSync)(dir)) {
3274
+ if (!(0, import_node_fs7.existsSync)(dir)) {
2877
3275
  return configs;
2878
3276
  }
2879
- const files = (0, import_node_fs5.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
3277
+ const files = (0, import_node_fs7.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
2880
3278
  for (const file of files) {
2881
3279
  try {
2882
- const raw = (0, import_node_fs5.readFileSync)((0, import_node_path2.join)(dir, file), "utf-8");
3280
+ const raw = (0, import_node_fs7.readFileSync)((0, import_node_path2.join)(dir, file), "utf-8");
2883
3281
  const parsed = import_toml.default.parse(raw);
2884
3282
  for (const [name, config] of Object.entries(parsed)) {
2885
3283
  configs.set(name, config);
@@ -2911,6 +3309,8 @@ var ModuleHost = class {
2911
3309
  modules = /* @__PURE__ */ new Map();
2912
3310
  logWatcher = null;
2913
3311
  journalWatcher = null;
3312
+ networkMonitor = null;
3313
+ dnsMonitor = null;
2914
3314
  async start() {
2915
3315
  this.registerBuiltins();
2916
3316
  await this.discoverAndStartInstalled();
@@ -2929,14 +3329,34 @@ var ModuleHost = class {
2929
3329
  const mod = this.modules.get("user-journal");
2930
3330
  if (mod) {
2931
3331
  mod.status = "running";
2932
- mod.detail = "tailing journalctl --user";
3332
+ mod.detail = `tailing ${JournalWatcher.scopeArgs().includes("--user") ? "user journal" : "system journal"}`;
2933
3333
  this.bus.announceModule("user-journal", "running", mod.detail);
2934
3334
  }
2935
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
+ }
2936
3354
  }
2937
3355
  async stop() {
2938
3356
  this.logWatcher?.stop();
2939
3357
  this.journalWatcher?.stop();
3358
+ this.networkMonitor?.stop();
3359
+ this.dnsMonitor?.stop();
2940
3360
  for (const mod of this.modules.values()) {
2941
3361
  try {
2942
3362
  if (mod.instance && mod.status === "running") {
@@ -2964,20 +3384,22 @@ var ModuleHost = class {
2964
3384
  const builtins = [
2965
3385
  { name: "log-watcher", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
2966
3386
  { name: "ssh-guard", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
2967
- { name: "user-journal", 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 }
2968
3390
  ];
2969
3391
  for (const m of builtins) this.modules.set(m.name, m);
2970
3392
  }
2971
3393
  async discoverAndStartInstalled() {
2972
- if (!(0, import_node_fs6.existsSync)(PATHS.moduleDir)) return;
3394
+ if (!(0, import_node_fs8.existsSync)(PATHS.moduleDir)) return;
2973
3395
  const configs = loadModuleConfigs(PATHS.confD);
2974
- const entries = (0, import_node_fs6.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
3396
+ const entries = (0, import_node_fs8.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
2975
3397
  for (const entry of entries) {
2976
3398
  if (!entry.isDirectory()) continue;
2977
3399
  const manifestPath = (0, import_node_path3.join)(PATHS.moduleDir, entry.name, "mod.toml");
2978
- if (!(0, import_node_fs6.existsSync)(manifestPath)) continue;
3400
+ if (!(0, import_node_fs8.existsSync)(manifestPath)) continue;
2979
3401
  try {
2980
- const manifest = import_toml2.default.parse((0, import_node_fs6.readFileSync)(manifestPath, "utf-8"));
3402
+ const manifest = import_toml2.default.parse((0, import_node_fs8.readFileSync)(manifestPath, "utf-8"));
2981
3403
  const name = manifest.module?.name || entry.name;
2982
3404
  const defaults = manifest.module?.config?.defaults || {};
2983
3405
  const config = {
@@ -3040,15 +3462,15 @@ var ModuleHost = class {
3040
3462
  installedEntrypoint(modulePath) {
3041
3463
  const packageJson = (0, import_node_path3.join)(modulePath, "package.json");
3042
3464
  const candidates = [];
3043
- if ((0, import_node_fs6.existsSync)(packageJson)) {
3465
+ if ((0, import_node_fs8.existsSync)(packageJson)) {
3044
3466
  try {
3045
- const pkg = JSON.parse((0, import_node_fs6.readFileSync)(packageJson, "utf-8"));
3467
+ const pkg = JSON.parse((0, import_node_fs8.readFileSync)(packageJson, "utf-8"));
3046
3468
  if (pkg.main) candidates.push((0, import_node_path3.join)(modulePath, pkg.main));
3047
3469
  } catch {
3048
3470
  }
3049
3471
  }
3050
3472
  candidates.push((0, import_node_path3.join)(modulePath, "dist", "index.js"), (0, import_node_path3.join)(modulePath, "index.js"));
3051
- return candidates.find((candidate) => (0, import_node_fs6.existsSync)(candidate)) || null;
3473
+ return candidates.find((candidate) => (0, import_node_fs8.existsSync)(candidate)) || null;
3052
3474
  }
3053
3475
  isThreatCrushModule(value) {
3054
3476
  return Boolean(
@@ -3152,6 +3574,98 @@ function smtpChannel(config) {
3152
3574
  };
3153
3575
  }
3154
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
+
3155
3669
  // src/daemon/alerts/index.ts
3156
3670
  var AlertDispatcher = class {
3157
3671
  constructor(bus2, config) {
@@ -3165,6 +3679,7 @@ var AlertDispatcher = class {
3165
3679
  bus;
3166
3680
  config;
3167
3681
  channels = [];
3682
+ rateLimits = /* @__PURE__ */ new Map();
3168
3683
  bindChannels() {
3169
3684
  const alerts = this.config.alerts || {};
3170
3685
  for (const [name, raw] of Object.entries(alerts)) {
@@ -3179,11 +3694,31 @@ var AlertDispatcher = class {
3179
3694
  if (name === "email" && typeof cfg.host === "string" && typeof cfg.from === "string") {
3180
3695
  this.channels.push(smtpChannel(cfg));
3181
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
+ }
3182
3703
  }
3183
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
+ }
3184
3716
  async dispatch(event) {
3185
- await Promise.all(this.channels.map((ch) => ch(event).catch(() => {
3186
- })));
3717
+ await Promise.all(this.channels.map((ch, idx) => {
3718
+ if (!this.checkRateLimit(idx)) return Promise.resolve();
3719
+ return ch(event).catch(() => {
3720
+ });
3721
+ }));
3187
3722
  }
3188
3723
  };
3189
3724
  function webhookChannel(url, secret) {
@@ -3207,14 +3742,14 @@ function slackChannel(webhookUrl) {
3207
3742
  }
3208
3743
 
3209
3744
  // src/core/cli-config.ts
3210
- var import_node_fs7 = require("fs");
3745
+ var import_node_fs9 = require("fs");
3211
3746
  var import_node_path4 = require("path");
3212
3747
  var import_node_os2 = require("os");
3213
3748
  var CLI_CONFIG_DIR = (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".threatcrush");
3214
3749
  var CLI_CONFIG_PATH = (0, import_node_path4.join)(CLI_CONFIG_DIR, "config.json");
3215
3750
  function readCliConfig() {
3216
3751
  try {
3217
- return JSON.parse((0, import_node_fs7.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
3752
+ return JSON.parse((0, import_node_fs9.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
3218
3753
  } catch {
3219
3754
  return {};
3220
3755
  }
@@ -3233,7 +3768,7 @@ function authHeaders() {
3233
3768
  }
3234
3769
 
3235
3770
  // src/commands/scan.ts
3236
- var import_node_fs8 = require("fs");
3771
+ var import_node_fs10 = require("fs");
3237
3772
  var import_node_path5 = require("path");
3238
3773
 
3239
3774
  // ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
@@ -3732,7 +4267,7 @@ var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
3732
4267
  var source_default = chalk;
3733
4268
 
3734
4269
  // src/core/logger.ts
3735
- var SEVERITY_COLORS = {
4270
+ var SEVERITY_COLORS2 = {
3736
4271
  info: source_default.green,
3737
4272
  low: source_default.cyan,
3738
4273
  medium: source_default.yellow,
@@ -3833,6 +4368,8 @@ async function runScan(targetPath) {
3833
4368
  try {
3834
4369
  scanDirectory(targetPath, targetPath, findings, () => {
3835
4370
  });
4371
+ const depFindings = await scanDependencies(targetPath);
4372
+ findings.push(...depFindings);
3836
4373
  } catch (err) {
3837
4374
  return {
3838
4375
  type: "scan",
@@ -3862,7 +4399,7 @@ async function runScan(targetPath) {
3862
4399
  function scanDirectory(basePath, currentPath, findings, onFile) {
3863
4400
  let entries;
3864
4401
  try {
3865
- entries = (0, import_node_fs8.readdirSync)(currentPath, { withFileTypes: true });
4402
+ entries = (0, import_node_fs10.readdirSync)(currentPath, { withFileTypes: true });
3866
4403
  } catch {
3867
4404
  return;
3868
4405
  }
@@ -3889,7 +4426,7 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
3889
4426
  const ext = (0, import_node_path5.extname)(entry.name).toLowerCase();
3890
4427
  if (!SCAN_EXTENSIONS.has(ext) && !entry.name.startsWith(".env")) continue;
3891
4428
  try {
3892
- const stat = (0, import_node_fs8.statSync)(fullPath);
4429
+ const stat = (0, import_node_fs10.statSync)(fullPath);
3893
4430
  if (stat.size > 1024 * 1024) continue;
3894
4431
  } catch {
3895
4432
  continue;
@@ -3897,7 +4434,7 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
3897
4434
  onFile();
3898
4435
  let content;
3899
4436
  try {
3900
- content = (0, import_node_fs8.readFileSync)(fullPath, "utf-8");
4437
+ content = (0, import_node_fs10.readFileSync)(fullPath, "utf-8");
3901
4438
  } catch {
3902
4439
  continue;
3903
4440
  }
@@ -3921,6 +4458,92 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
3921
4458
  }
3922
4459
  }
3923
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
+ }
3924
4547
 
3925
4548
  // src/commands/pentest.ts
3926
4549
  var PENTEST_CHECKS = [
@@ -3965,6 +4588,39 @@ var PENTEST_CHECKS = [
3965
4588
  test: (html) => /stack trace|traceback|exception|at\s+\w+\.\w+\(/i.test(html),
3966
4589
  severity: "medium",
3967
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"
3968
4624
  }
3969
4625
  ];
3970
4626
  async function runPentest(rawUrl) {
@@ -4187,6 +4843,717 @@ var RunsWorker = class {
4187
4843
  }
4188
4844
  };
4189
4845
 
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
4986
+ },
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
5005
+ },
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
+ }
5317
+ }
5318
+ };
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;
5325
+ }
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);
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();
5392
+ }
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
+
4190
5557
  // src/core/telemetry.ts
4191
5558
  var ready = false;
4192
5559
  var sentry = null;
@@ -4236,7 +5603,7 @@ async function flushTelemetry(timeoutMs = 2e3) {
4236
5603
  // src/daemon/index.ts
4237
5604
  function readVersion() {
4238
5605
  try {
4239
- 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"));
4240
5607
  return pkg.version || "0.0.0";
4241
5608
  } catch {
4242
5609
  return "0.0.0";
@@ -4244,7 +5611,7 @@ function readVersion() {
4244
5611
  }
4245
5612
  function logLine(line) {
4246
5613
  try {
4247
- (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}
4248
5615
  `);
4249
5616
  } catch {
4250
5617
  }
@@ -4272,12 +5639,42 @@ async function runDaemon() {
4272
5639
  } catch (err) {
4273
5640
  logLine(`[daemon] state db unavailable: ${err.message}`);
4274
5641
  }
4275
- 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);
4276
5643
  bus.on("event", (event) => {
4277
5644
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
4278
5645
  });
4279
5646
  const moduleHost = new ModuleHost(bus);
4280
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})`);
4281
5678
  new AlertDispatcher(bus, config);
4282
5679
  const runsWorker = new RunsWorker(bus);
4283
5680
  try {
@@ -4290,6 +5687,10 @@ async function runDaemon() {
4290
5687
  logLine(`[daemon] ipc listening on ${PATHS.socket}`);
4291
5688
  const shutdown = async (signal) => {
4292
5689
  logLine(`[daemon] received ${signal}, shutting down`);
5690
+ try {
5691
+ remediation.stop();
5692
+ } catch {
5693
+ }
4293
5694
  try {
4294
5695
  runsWorker.stop();
4295
5696
  } catch {