@profullstack/threatcrush 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/daemon.js CHANGED
@@ -1639,20 +1639,20 @@ var require_parse_async = __commonJS({
1639
1639
  const index = 0;
1640
1640
  const blocksize = opts.blocksize || 40960;
1641
1641
  const parser = new TOMLParser();
1642
- return new Promise((resolve, reject) => {
1643
- setImmediate(parseAsyncNext, index, blocksize, resolve, reject);
1642
+ return new Promise((resolve3, reject) => {
1643
+ setImmediate(parseAsyncNext, index, blocksize, resolve3, reject);
1644
1644
  });
1645
- function parseAsyncNext(index2, blocksize2, resolve, reject) {
1645
+ function parseAsyncNext(index2, blocksize2, resolve3, reject) {
1646
1646
  if (index2 >= str.length) {
1647
1647
  try {
1648
- return resolve(parser.finish());
1648
+ return resolve3(parser.finish());
1649
1649
  } catch (err) {
1650
1650
  return reject(prettyError(err, str));
1651
1651
  }
1652
1652
  }
1653
1653
  try {
1654
1654
  parser.parse(str.slice(index2, index2 + blocksize2));
1655
- setImmediate(parseAsyncNext, index2 + blocksize2, blocksize2, resolve, reject);
1655
+ setImmediate(parseAsyncNext, index2 + blocksize2, blocksize2, resolve3, reject);
1656
1656
  } catch (err) {
1657
1657
  reject(prettyError(err, str));
1658
1658
  }
@@ -1678,7 +1678,7 @@ var require_parse_stream = __commonJS({
1678
1678
  function parseReadable(stm) {
1679
1679
  const parser = new TOMLParser();
1680
1680
  stm.setEncoding("utf8");
1681
- return new Promise((resolve, reject) => {
1681
+ return new Promise((resolve3, reject) => {
1682
1682
  let readable;
1683
1683
  let ended = false;
1684
1684
  let errored = false;
@@ -1686,7 +1686,7 @@ var require_parse_stream = __commonJS({
1686
1686
  ended = true;
1687
1687
  if (readable) return;
1688
1688
  try {
1689
- resolve(parser.finish());
1689
+ resolve3(parser.finish());
1690
1690
  } catch (err) {
1691
1691
  reject(err);
1692
1692
  }
@@ -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_fs15 = require("fs");
2026
+ var import_node_path10 = 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() {
@@ -2316,15 +2310,24 @@ var IpcServer = class {
2316
2310
  } catch {
2317
2311
  }
2318
2312
  }
2319
- return new Promise((resolve, reject) => {
2313
+ return new Promise((resolve3, reject) => {
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
  }
2327
- resolve();
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
+ }
2330
+ resolve3();
2328
2331
  });
2329
2332
  });
2330
2333
  }
@@ -2336,20 +2339,20 @@ var IpcServer = class {
2336
2339
  }
2337
2340
  }
2338
2341
  this.clients.clear();
2339
- return new Promise((resolve) => {
2342
+ return new Promise((resolve3) => {
2340
2343
  if (!this.server) {
2341
2344
  try {
2342
2345
  if ((0, import_node_fs3.existsSync)(PATHS.socket)) (0, import_node_fs3.unlinkSync)(PATHS.socket);
2343
2346
  } catch {
2344
2347
  }
2345
- return resolve();
2348
+ return resolve3();
2346
2349
  }
2347
2350
  this.server.close(() => {
2348
2351
  try {
2349
2352
  if ((0, import_node_fs3.existsSync)(PATHS.socket)) (0, import_node_fs3.unlinkSync)(PATHS.socket);
2350
2353
  } catch {
2351
2354
  }
2352
- resolve();
2355
+ resolve3();
2353
2356
  });
2354
2357
  });
2355
2358
  }
@@ -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());
@@ -2466,6 +2469,7 @@ var NGINX_REGEX = /^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d{3}) (\d+) "
2466
2469
  var AUTH_REGEX = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)/;
2467
2470
  var SYSLOG_REGEX = /^(\w+\s+\d+\s+[\d:]+)\s+\S+\s+(\S+?)(?:\[\d+\])?:\s+(.*)/;
2468
2471
  var IP_REGEX = /(?:from|FROM)\s+(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/;
2472
+ var INVALID_USER_REGEX = /(?:for\s+invalid\s+user)\s+(\S+?)(?:\s+from|\s*$)/;
2469
2473
  var USER_REGEX = /(?:for|user)\s+(\S+?)(?:\s+from|\s*$)/;
2470
2474
  var ATTACK_PATTERNS = {
2471
2475
  sqli: [
@@ -2519,7 +2523,7 @@ function parseAuthLog(line) {
2519
2523
  const match = line.match(AUTH_REGEX);
2520
2524
  if (!match) return null;
2521
2525
  const ipMatch = match[3].match(IP_REGEX);
2522
- const userMatch = match[3].match(USER_REGEX);
2526
+ const userMatch = match[3].match(INVALID_USER_REGEX) || match[3].match(USER_REGEX);
2523
2527
  return {
2524
2528
  timestamp: parseSyslogTimestamp(match[1]),
2525
2529
  raw: line,
@@ -2547,10 +2551,25 @@ function parseSyslog(line) {
2547
2551
  };
2548
2552
  }
2549
2553
  function detectAttackPattern(path) {
2554
+ const candidates = /* @__PURE__ */ new Set([path]);
2555
+ let current = path;
2556
+ for (let i = 0; i < 2; i++) {
2557
+ let decoded = null;
2558
+ try {
2559
+ decoded = decodeURIComponent(current);
2560
+ } catch {
2561
+ decoded = null;
2562
+ }
2563
+ if (decoded === null || decoded === current) break;
2564
+ candidates.add(decoded);
2565
+ current = decoded;
2566
+ }
2550
2567
  for (const [type, patterns] of Object.entries(ATTACK_PATTERNS)) {
2551
2568
  for (const pattern of patterns) {
2552
- if (pattern.test(path)) {
2553
- return type;
2569
+ for (const candidate of candidates) {
2570
+ if (pattern.test(candidate)) {
2571
+ return type;
2572
+ }
2554
2573
  }
2555
2574
  }
2556
2575
  }
@@ -2564,20 +2583,19 @@ function autoDetectParser(line) {
2564
2583
  return parseSyslog(line);
2565
2584
  }
2566
2585
  function parseNginxTimestamp(s) {
2567
- try {
2568
- const cleaned = s.replace(/(\d{2})\/(\w{3})\/(\d{4}):/, "$2 $1, $3 ");
2569
- return new Date(cleaned);
2570
- } catch {
2571
- return /* @__PURE__ */ new Date();
2572
- }
2586
+ const cleaned = s.replace(/(\d{2})\/(\w{3})\/(\d{4}):/, "$2 $1, $3 ");
2587
+ const d = new Date(cleaned);
2588
+ return Number.isNaN(d.getTime()) ? /* @__PURE__ */ new Date() : d;
2573
2589
  }
2574
2590
  function parseSyslogTimestamp(s) {
2575
- try {
2576
- const withYear = `${s} ${(/* @__PURE__ */ new Date()).getFullYear()}`;
2577
- return new Date(withYear);
2578
- } catch {
2579
- return /* @__PURE__ */ new Date();
2580
- }
2591
+ const now = /* @__PURE__ */ new Date();
2592
+ let d = /* @__PURE__ */ new Date(`${s} ${now.getFullYear()}`);
2593
+ if (Number.isNaN(d.getTime())) return now;
2594
+ if (d.getTime() > now.getTime()) {
2595
+ const prev = /* @__PURE__ */ new Date(`${s} ${now.getFullYear() - 1}`);
2596
+ if (!Number.isNaN(prev.getTime())) d = prev;
2597
+ }
2598
+ return d;
2581
2599
  }
2582
2600
 
2583
2601
  // src/daemon/watchers/log-watcher.ts
@@ -2725,8 +2743,16 @@ var JournalWatcher = class _JournalWatcher {
2725
2743
  buffer = "";
2726
2744
  moduleName = "user-journal";
2727
2745
  active = false;
2746
+ // When the daemon runs as root (system mode), tail the SYSTEM journal so
2747
+ // we pick up sshd / sudo / kernel / UFW events. Falling back to --user
2748
+ // would give us root's mostly-empty per-user journal. Otherwise we use
2749
+ // --user so the daemon can run unprivileged on a workstation.
2750
+ static scopeArgs() {
2751
+ const isRoot2 = process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
2752
+ return isRoot2 ? [] : ["--user"];
2753
+ }
2728
2754
  static isAvailable() {
2729
- const probe = (0, import_node_child_process.spawnSync)("journalctl", ["--user", "-n", "0", "--no-pager"], {
2755
+ const probe = (0, import_node_child_process.spawnSync)("journalctl", [...this.scopeArgs(), "-n", "0", "--no-pager"], {
2730
2756
  stdio: ["ignore", "ignore", "ignore"]
2731
2757
  });
2732
2758
  return probe.status === 0;
@@ -2735,7 +2761,7 @@ var JournalWatcher = class _JournalWatcher {
2735
2761
  if (!_JournalWatcher.isAvailable()) return false;
2736
2762
  const child = (0, import_node_child_process.spawn)(
2737
2763
  "journalctl",
2738
- ["--user", "-o", "json", "-f", "--since", "now"],
2764
+ [..._JournalWatcher.scopeArgs(), "-o", "json", "-f", "--since", "now"],
2739
2765
  { stdio: ["ignore", "pipe", "pipe"] }
2740
2766
  );
2741
2767
  if (!child.stdout) return false;
@@ -2825,8 +2851,395 @@ function realtimeToDate(rt) {
2825
2851
  return new Date(Math.floor(us / 1e3));
2826
2852
  }
2827
2853
 
2828
- // src/core/config.ts
2854
+ // src/modules/network-monitor/index.ts
2855
+ var import_node_child_process2 = require("child_process");
2829
2856
  var import_node_fs5 = require("fs");
2857
+ var NetworkMonitor = class {
2858
+ constructor(bus2) {
2859
+ this.bus = bus2;
2860
+ }
2861
+ bus;
2862
+ active = false;
2863
+ pollTimer = null;
2864
+ scanTrackers = /* @__PURE__ */ new Map();
2865
+ halfOpenTrackers = /* @__PURE__ */ new Map();
2866
+ lastConnections = /* @__PURE__ */ new Set();
2867
+ // Config
2868
+ pollIntervalMs = 5e3;
2869
+ portScanThreshold = 10;
2870
+ // unique ports in window
2871
+ portScanWindowMs = 3e4;
2872
+ synFloodThreshold = 50;
2873
+ // half-open connections
2874
+ synFloodWindowMs = 1e4;
2875
+ start() {
2876
+ if (!this.hasConntrackOrSs()) {
2877
+ return false;
2878
+ }
2879
+ this.active = true;
2880
+ this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
2881
+ return true;
2882
+ }
2883
+ stop() {
2884
+ if (this.pollTimer) clearInterval(this.pollTimer);
2885
+ this.pollTimer = null;
2886
+ this.active = false;
2887
+ }
2888
+ isActive() {
2889
+ return this.active;
2890
+ }
2891
+ hasConntrackOrSs() {
2892
+ const ss = (0, import_node_child_process2.spawnSync)("ss", ["--version"], { stdio: "pipe" });
2893
+ if (ss.status === 0) return true;
2894
+ return (0, import_node_fs5.existsSync)("/proc/net/tcp");
2895
+ }
2896
+ poll() {
2897
+ try {
2898
+ const connections = this.getConnections();
2899
+ this.analyzePortScans(connections);
2900
+ this.analyzeSynFlood(connections);
2901
+ this.cleanupTrackers();
2902
+ } catch {
2903
+ }
2904
+ }
2905
+ getConnections() {
2906
+ const records = [];
2907
+ const now = Date.now();
2908
+ try {
2909
+ const ct = (0, import_node_child_process2.spawnSync)("conntrack", ["-L", "-p", "tcp", "-o", "extended"], {
2910
+ encoding: "utf-8",
2911
+ stdio: ["pipe", "pipe", "pipe"],
2912
+ timeout: 3e3
2913
+ });
2914
+ if (ct.status === 0 && ct.stdout) {
2915
+ for (const line of ct.stdout.split("\n")) {
2916
+ const srcMatch = line.match(/src=(\d+\.\d+\.\d+\.\d+)/);
2917
+ const dportMatch = line.match(/dport=(\d+)/);
2918
+ if (srcMatch && dportMatch) {
2919
+ records.push({ source_ip: srcMatch[1], dest_port: parseInt(dportMatch[1]), timestamp: now });
2920
+ }
2921
+ }
2922
+ if (records.length > 0) return records;
2923
+ }
2924
+ } catch {
2925
+ }
2926
+ try {
2927
+ const ss = (0, import_node_child_process2.spawnSync)("ss", ["-tnp", "-H"], {
2928
+ encoding: "utf-8",
2929
+ stdio: ["pipe", "pipe", "pipe"],
2930
+ timeout: 3e3
2931
+ });
2932
+ if (ss.status === 0 && ss.stdout) {
2933
+ for (const line of ss.stdout.split("\n")) {
2934
+ const parts = line.trim().split(/\s+/);
2935
+ if (parts.length < 5) continue;
2936
+ const peerParts = parts[4].split(":");
2937
+ const localParts = parts[3].split(":");
2938
+ if (peerParts.length >= 2 && localParts.length >= 2) {
2939
+ const sourceIp = peerParts.slice(0, -1).join(":");
2940
+ const destPort = parseInt(localParts[localParts.length - 1]);
2941
+ if (sourceIp && !isNaN(destPort) && !this.isLocalIp(sourceIp)) {
2942
+ records.push({ source_ip: sourceIp, dest_port: destPort, timestamp: now });
2943
+ }
2944
+ }
2945
+ }
2946
+ }
2947
+ } catch {
2948
+ }
2949
+ return records;
2950
+ }
2951
+ analyzePortScans(connections) {
2952
+ const now = Date.now();
2953
+ for (const conn of connections) {
2954
+ const key = conn.source_ip;
2955
+ let tracker = this.scanTrackers.get(key);
2956
+ if (!tracker) {
2957
+ tracker = { ports: /* @__PURE__ */ new Set(), firstSeen: now, lastSeen: now, count: 0 };
2958
+ this.scanTrackers.set(key, tracker);
2959
+ }
2960
+ tracker.ports.add(conn.dest_port);
2961
+ tracker.lastSeen = now;
2962
+ tracker.count++;
2963
+ if (tracker.ports.size >= this.portScanThreshold && now - tracker.firstSeen <= this.portScanWindowMs) {
2964
+ this.emitEvent(
2965
+ "high",
2966
+ `Port scan detected: ${conn.source_ip} probed ${tracker.ports.size} ports in ${Math.round((now - tracker.firstSeen) / 1e3)}s`,
2967
+ conn.source_ip,
2968
+ { ports_scanned: tracker.ports.size, window_seconds: Math.round((now - tracker.firstSeen) / 1e3) }
2969
+ );
2970
+ this.scanTrackers.delete(key);
2971
+ }
2972
+ }
2973
+ }
2974
+ analyzeSynFlood(connections) {
2975
+ try {
2976
+ const ss = (0, import_node_child_process2.spawnSync)("ss", ["-tn", "state", "syn-recv", "-H"], {
2977
+ encoding: "utf-8",
2978
+ stdio: ["pipe", "pipe", "pipe"],
2979
+ timeout: 3e3
2980
+ });
2981
+ if (ss.status !== 0 || !ss.stdout) return;
2982
+ const perSource = /* @__PURE__ */ new Map();
2983
+ for (const line of ss.stdout.split("\n")) {
2984
+ const parts = line.trim().split(/\s+/);
2985
+ if (parts.length < 5) continue;
2986
+ const peer = parts[4].split(":");
2987
+ const ip = peer.slice(0, -1).join(":");
2988
+ if (ip) perSource.set(ip, (perSource.get(ip) || 0) + 1);
2989
+ }
2990
+ for (const [ip, count] of perSource) {
2991
+ if (count >= this.synFloodThreshold) {
2992
+ this.emitEvent(
2993
+ "critical",
2994
+ `SYN flood indicators: ${count} half-open connections from ${ip}`,
2995
+ ip,
2996
+ { half_open_count: count }
2997
+ );
2998
+ }
2999
+ }
3000
+ } catch {
3001
+ }
3002
+ }
3003
+ emitEvent(severity, message, sourceIp, details) {
3004
+ const event = {
3005
+ timestamp: /* @__PURE__ */ new Date(),
3006
+ module: "network-monitor",
3007
+ category: "network",
3008
+ severity,
3009
+ message,
3010
+ source_ip: sourceIp,
3011
+ details
3012
+ };
3013
+ try {
3014
+ insertEvent(event);
3015
+ } catch {
3016
+ }
3017
+ this.bus.publish(event);
3018
+ }
3019
+ cleanupTrackers() {
3020
+ const now = Date.now();
3021
+ for (const [key, tracker] of this.scanTrackers) {
3022
+ if (now - tracker.lastSeen > this.portScanWindowMs * 2) {
3023
+ this.scanTrackers.delete(key);
3024
+ }
3025
+ }
3026
+ }
3027
+ isLocalIp(ip) {
3028
+ return ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0" || ip.startsWith("::ffff:127.");
3029
+ }
3030
+ };
3031
+
3032
+ // src/modules/dns-monitor/index.ts
3033
+ var import_node_fs6 = require("fs");
3034
+ var import_node_readline2 = require("readline");
3035
+ var DNS_LOG_SOURCES = [
3036
+ "/var/log/syslog",
3037
+ // systemd-resolved logs here
3038
+ "/var/log/dnsmasq.log",
3039
+ // dnsmasq
3040
+ "/var/log/named/queries.log",
3041
+ // bind9
3042
+ "/var/log/pihole.log"
3043
+ // Pi-hole
3044
+ ];
3045
+ var DnsMonitor = class {
3046
+ // Shannon entropy threshold for DGA
3047
+ constructor(bus2) {
3048
+ this.bus = bus2;
3049
+ }
3050
+ bus;
3051
+ active = false;
3052
+ timers = /* @__PURE__ */ new Map();
3053
+ positions = /* @__PURE__ */ new Map();
3054
+ // Tracking windows
3055
+ txtQueryCounts = /* @__PURE__ */ new Map();
3056
+ domainBuffer = [];
3057
+ // Config
3058
+ txtRateThreshold = 20;
3059
+ // TXT queries per source per window
3060
+ txtWindowMs = 6e4;
3061
+ dgaBurstThreshold = 15;
3062
+ // unique high-entropy domains per window
3063
+ dgaWindowMs = 6e4;
3064
+ entropyThreshold = 3.5;
3065
+ start() {
3066
+ const sources = DNS_LOG_SOURCES.filter((p) => {
3067
+ if (!(0, import_node_fs6.existsSync)(p)) return false;
3068
+ try {
3069
+ (0, import_node_fs6.accessSync)(p, import_node_fs6.constants.R_OK);
3070
+ return true;
3071
+ } catch {
3072
+ return false;
3073
+ }
3074
+ });
3075
+ if (sources.length === 0) return false;
3076
+ this.active = true;
3077
+ for (const src of sources) {
3078
+ this.tailLog(src);
3079
+ }
3080
+ setInterval(() => this.analyzeBuffer(), 1e4);
3081
+ return true;
3082
+ }
3083
+ stop() {
3084
+ for (const t of this.timers.values()) clearInterval(t);
3085
+ this.timers.clear();
3086
+ this.active = false;
3087
+ }
3088
+ isActive() {
3089
+ return this.active;
3090
+ }
3091
+ tailLog(path) {
3092
+ try {
3093
+ this.positions.set(path, (0, import_node_fs6.statSync)(path).size);
3094
+ } catch {
3095
+ this.positions.set(path, 0);
3096
+ }
3097
+ const timer = setInterval(() => this.pollLog(path), 2e3);
3098
+ this.timers.set(path, timer);
3099
+ }
3100
+ pollLog(path) {
3101
+ let stat;
3102
+ try {
3103
+ stat = (0, import_node_fs6.statSync)(path);
3104
+ } catch {
3105
+ return;
3106
+ }
3107
+ const prev = this.positions.get(path) ?? 0;
3108
+ if (stat.size < prev) {
3109
+ this.positions.set(path, 0);
3110
+ return;
3111
+ }
3112
+ if (stat.size === prev) return;
3113
+ const stream = (0, import_node_fs6.createReadStream)(path, { start: prev, encoding: "utf-8" });
3114
+ stream.on("error", () => this.positions.set(path, stat.size));
3115
+ const rl = (0, import_node_readline2.createInterface)({ input: stream });
3116
+ rl.on("line", (line) => this.parseDnsLine(line));
3117
+ rl.on("close", () => this.positions.set(path, stat.size));
3118
+ }
3119
+ parseDnsLine(line) {
3120
+ const resolvedMatch = line.match(/query\[(\w+)\]\s+(\S+)\s+from\s+(\S+)/i);
3121
+ if (resolvedMatch) {
3122
+ this.domainBuffer.push({
3123
+ type: resolvedMatch[1],
3124
+ domain: resolvedMatch[2],
3125
+ source_ip: resolvedMatch[3],
3126
+ timestamp: Date.now()
3127
+ });
3128
+ return;
3129
+ }
3130
+ const dnsmasqMatch = line.match(/query\[(\w+)\]\s+(\S+)\s+from\s+(\S+)/i);
3131
+ if (dnsmasqMatch) {
3132
+ this.domainBuffer.push({
3133
+ type: dnsmasqMatch[1],
3134
+ domain: dnsmasqMatch[2],
3135
+ source_ip: dnsmasqMatch[3],
3136
+ timestamp: Date.now()
3137
+ });
3138
+ return;
3139
+ }
3140
+ const genericMatch = line.match(/(?:query|lookup|resolve)[:\s]+(\S+)/i);
3141
+ if (genericMatch) {
3142
+ const typeMatch = line.match(/type[:\s]+(\w+)/i);
3143
+ this.domainBuffer.push({
3144
+ type: typeMatch?.[1] || "A",
3145
+ domain: genericMatch[1],
3146
+ timestamp: Date.now()
3147
+ });
3148
+ }
3149
+ }
3150
+ analyzeBuffer() {
3151
+ const now = Date.now();
3152
+ const cutoff = now - this.txtWindowMs;
3153
+ this.domainBuffer = this.domainBuffer.filter((q) => q.timestamp > cutoff);
3154
+ this.detectTunneling();
3155
+ this.detectDga();
3156
+ }
3157
+ detectTunneling() {
3158
+ const txtBySource = /* @__PURE__ */ new Map();
3159
+ const longLabelDomains = [];
3160
+ for (const q of this.domainBuffer) {
3161
+ if (q.type === "TXT") {
3162
+ const key = q.source_ip || "unknown";
3163
+ txtBySource.set(key, (txtBySource.get(key) || 0) + 1);
3164
+ }
3165
+ const labels = q.domain.split(".");
3166
+ const maxLabel = Math.max(...labels.map((l) => l.length));
3167
+ if (maxLabel > 50) {
3168
+ longLabelDomains.push(q.domain);
3169
+ }
3170
+ }
3171
+ for (const [source, count] of txtBySource) {
3172
+ if (count >= this.txtRateThreshold) {
3173
+ this.emitEvent(
3174
+ "high",
3175
+ `DNS tunneling indicators: ${count} TXT queries from ${source} in ${this.txtWindowMs / 1e3}s`,
3176
+ source !== "unknown" ? source : void 0,
3177
+ { txt_query_count: count, type: "tunneling" }
3178
+ );
3179
+ }
3180
+ }
3181
+ if (longLabelDomains.length >= 5) {
3182
+ this.emitEvent(
3183
+ "high",
3184
+ `DNS tunneling: ${longLabelDomains.length} queries with abnormally long labels detected`,
3185
+ void 0,
3186
+ { domains: longLabelDomains.slice(0, 5), type: "tunneling-labels" }
3187
+ );
3188
+ }
3189
+ }
3190
+ detectDga() {
3191
+ const highEntropyDomains = [];
3192
+ for (const q of this.domainBuffer) {
3193
+ const domain = q.domain.toLowerCase();
3194
+ const parts = domain.split(".");
3195
+ if (parts.length < 2) continue;
3196
+ const sld = parts[parts.length - 2];
3197
+ if (sld.length >= 8 && this.shannonEntropy(sld) >= this.entropyThreshold) {
3198
+ highEntropyDomains.push(domain);
3199
+ }
3200
+ }
3201
+ const unique = [...new Set(highEntropyDomains)];
3202
+ if (unique.length >= this.dgaBurstThreshold) {
3203
+ this.emitEvent(
3204
+ "critical",
3205
+ `DGA-like domain burst: ${unique.length} unique high-entropy domains detected`,
3206
+ void 0,
3207
+ { sample_domains: unique.slice(0, 10), type: "dga", unique_count: unique.length }
3208
+ );
3209
+ }
3210
+ }
3211
+ shannonEntropy(str) {
3212
+ const freq = /* @__PURE__ */ new Map();
3213
+ for (const ch of str) {
3214
+ freq.set(ch, (freq.get(ch) || 0) + 1);
3215
+ }
3216
+ let entropy = 0;
3217
+ for (const count of freq.values()) {
3218
+ const p = count / str.length;
3219
+ if (p > 0) entropy -= p * Math.log2(p);
3220
+ }
3221
+ return entropy;
3222
+ }
3223
+ emitEvent(severity, message, sourceIp, details) {
3224
+ const event = {
3225
+ timestamp: /* @__PURE__ */ new Date(),
3226
+ module: "dns-monitor",
3227
+ category: "network",
3228
+ severity,
3229
+ message,
3230
+ source_ip: sourceIp,
3231
+ details
3232
+ };
3233
+ try {
3234
+ insertEvent(event);
3235
+ } catch {
3236
+ }
3237
+ this.bus.publish(event);
3238
+ }
3239
+ };
3240
+
3241
+ // src/core/config.ts
3242
+ var import_node_fs7 = require("fs");
2830
3243
  var import_node_path2 = require("path");
2831
3244
  var import_toml = __toESM(require_toml());
2832
3245
  var DEFAULT_CONFIG_PATH = "/etc/threatcrush/threatcrushd.conf";
@@ -2853,11 +3266,11 @@ var DEFAULT_CONFIG = {
2853
3266
  };
2854
3267
  function loadConfig(configPath) {
2855
3268
  const path = configPath || DEFAULT_CONFIG_PATH;
2856
- if (!(0, import_node_fs5.existsSync)(path)) {
3269
+ if (!(0, import_node_fs7.existsSync)(path)) {
2857
3270
  return { ...DEFAULT_CONFIG };
2858
3271
  }
2859
3272
  try {
2860
- const raw = (0, import_node_fs5.readFileSync)(path, "utf-8");
3273
+ const raw = (0, import_node_fs7.readFileSync)(path, "utf-8");
2861
3274
  const parsed = import_toml.default.parse(raw);
2862
3275
  return {
2863
3276
  daemon: { ...DEFAULT_CONFIG.daemon, ...parsed.daemon },
@@ -2873,13 +3286,13 @@ function loadConfig(configPath) {
2873
3286
  function loadModuleConfigs(confDir) {
2874
3287
  const dir = confDir || DEFAULT_CONFDIR;
2875
3288
  const configs = /* @__PURE__ */ new Map();
2876
- if (!(0, import_node_fs5.existsSync)(dir)) {
3289
+ if (!(0, import_node_fs7.existsSync)(dir)) {
2877
3290
  return configs;
2878
3291
  }
2879
- const files = (0, import_node_fs5.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
3292
+ const files = (0, import_node_fs7.readdirSync)(dir).filter((f) => f.endsWith(".conf"));
2880
3293
  for (const file of files) {
2881
3294
  try {
2882
- const raw = (0, import_node_fs5.readFileSync)((0, import_node_path2.join)(dir, file), "utf-8");
3295
+ const raw = (0, import_node_fs7.readFileSync)((0, import_node_path2.join)(dir, file), "utf-8");
2883
3296
  const parsed = import_toml.default.parse(raw);
2884
3297
  for (const [name, config] of Object.entries(parsed)) {
2885
3298
  configs.set(name, config);
@@ -2911,6 +3324,8 @@ var ModuleHost = class {
2911
3324
  modules = /* @__PURE__ */ new Map();
2912
3325
  logWatcher = null;
2913
3326
  journalWatcher = null;
3327
+ networkMonitor = null;
3328
+ dnsMonitor = null;
2914
3329
  async start() {
2915
3330
  this.registerBuiltins();
2916
3331
  await this.discoverAndStartInstalled();
@@ -2929,14 +3344,34 @@ var ModuleHost = class {
2929
3344
  const mod = this.modules.get("user-journal");
2930
3345
  if (mod) {
2931
3346
  mod.status = "running";
2932
- mod.detail = "tailing journalctl --user";
3347
+ mod.detail = `tailing ${JournalWatcher.scopeArgs().includes("--user") ? "user journal" : "system journal"}`;
2933
3348
  this.bus.announceModule("user-journal", "running", mod.detail);
2934
3349
  }
2935
3350
  }
3351
+ this.networkMonitor = new NetworkMonitor(this.bus);
3352
+ if (this.networkMonitor.start()) {
3353
+ const nmod = this.modules.get("network-monitor");
3354
+ if (nmod) {
3355
+ nmod.status = "running";
3356
+ nmod.detail = "monitoring connections via conntrack/ss";
3357
+ this.bus.announceModule("network-monitor", "running", nmod.detail);
3358
+ }
3359
+ }
3360
+ this.dnsMonitor = new DnsMonitor(this.bus);
3361
+ if (this.dnsMonitor.start()) {
3362
+ const dmod = this.modules.get("dns-monitor");
3363
+ if (dmod) {
3364
+ dmod.status = "running";
3365
+ dmod.detail = "monitoring DNS queries";
3366
+ this.bus.announceModule("dns-monitor", "running", dmod.detail);
3367
+ }
3368
+ }
2936
3369
  }
2937
3370
  async stop() {
2938
3371
  this.logWatcher?.stop();
2939
3372
  this.journalWatcher?.stop();
3373
+ this.networkMonitor?.stop();
3374
+ this.dnsMonitor?.stop();
2940
3375
  for (const mod of this.modules.values()) {
2941
3376
  try {
2942
3377
  if (mod.instance && mod.status === "running") {
@@ -2964,20 +3399,22 @@ var ModuleHost = class {
2964
3399
  const builtins = [
2965
3400
  { name: "log-watcher", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
2966
3401
  { 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 }
3402
+ { name: "user-journal", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
3403
+ { name: "network-monitor", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
3404
+ { name: "dns-monitor", version: "0.1.0", source: "builtin", status: "loaded", events: 0 }
2968
3405
  ];
2969
3406
  for (const m of builtins) this.modules.set(m.name, m);
2970
3407
  }
2971
3408
  async discoverAndStartInstalled() {
2972
- if (!(0, import_node_fs6.existsSync)(PATHS.moduleDir)) return;
3409
+ if (!(0, import_node_fs8.existsSync)(PATHS.moduleDir)) return;
2973
3410
  const configs = loadModuleConfigs(PATHS.confD);
2974
- const entries = (0, import_node_fs6.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
3411
+ const entries = (0, import_node_fs8.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
2975
3412
  for (const entry of entries) {
2976
3413
  if (!entry.isDirectory()) continue;
2977
3414
  const manifestPath = (0, import_node_path3.join)(PATHS.moduleDir, entry.name, "mod.toml");
2978
- if (!(0, import_node_fs6.existsSync)(manifestPath)) continue;
3415
+ if (!(0, import_node_fs8.existsSync)(manifestPath)) continue;
2979
3416
  try {
2980
- const manifest = import_toml2.default.parse((0, import_node_fs6.readFileSync)(manifestPath, "utf-8"));
3417
+ const manifest = import_toml2.default.parse((0, import_node_fs8.readFileSync)(manifestPath, "utf-8"));
2981
3418
  const name = manifest.module?.name || entry.name;
2982
3419
  const defaults = manifest.module?.config?.defaults || {};
2983
3420
  const config = {
@@ -3040,15 +3477,15 @@ var ModuleHost = class {
3040
3477
  installedEntrypoint(modulePath) {
3041
3478
  const packageJson = (0, import_node_path3.join)(modulePath, "package.json");
3042
3479
  const candidates = [];
3043
- if ((0, import_node_fs6.existsSync)(packageJson)) {
3480
+ if ((0, import_node_fs8.existsSync)(packageJson)) {
3044
3481
  try {
3045
- const pkg = JSON.parse((0, import_node_fs6.readFileSync)(packageJson, "utf-8"));
3482
+ const pkg = JSON.parse((0, import_node_fs8.readFileSync)(packageJson, "utf-8"));
3046
3483
  if (pkg.main) candidates.push((0, import_node_path3.join)(modulePath, pkg.main));
3047
3484
  } catch {
3048
3485
  }
3049
3486
  }
3050
3487
  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;
3488
+ return candidates.find((candidate) => (0, import_node_fs8.existsSync)(candidate)) || null;
3052
3489
  }
3053
3490
  isThreatCrushModule(value) {
3054
3491
  return Boolean(
@@ -3152,6 +3589,98 @@ function smtpChannel(config) {
3152
3589
  };
3153
3590
  }
3154
3591
 
3592
+ // src/daemon/alerts/discord.ts
3593
+ var SEVERITY_RANK2 = {
3594
+ info: 0,
3595
+ low: 1,
3596
+ medium: 2,
3597
+ high: 3,
3598
+ critical: 4
3599
+ };
3600
+ var SEVERITY_COLORS = {
3601
+ info: 3066993,
3602
+ // green
3603
+ low: 3447003,
3604
+ // blue
3605
+ medium: 15965202,
3606
+ // orange
3607
+ high: 15158332,
3608
+ // red
3609
+ critical: 10181046
3610
+ // purple
3611
+ };
3612
+ function discordChannel(config) {
3613
+ return async (event) => {
3614
+ if (config.min_severity) {
3615
+ const eventRank = SEVERITY_RANK2[event.severity] ?? 0;
3616
+ const minRank = SEVERITY_RANK2[config.min_severity] ?? 0;
3617
+ if (eventRank < minRank) return;
3618
+ }
3619
+ const embed = {
3620
+ title: `${event.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F"} [${event.severity.toUpperCase()}] ${event.module}`,
3621
+ description: event.message,
3622
+ color: SEVERITY_COLORS[event.severity] ?? 16777215,
3623
+ fields: [
3624
+ ...event.source_ip ? [{ name: "Source IP", value: `\`${event.source_ip}\``, inline: true }] : [],
3625
+ { name: "Category", value: event.category, inline: true },
3626
+ { name: "Time", value: event.timestamp.toISOString(), inline: true }
3627
+ ],
3628
+ footer: { text: "ThreatCrush Security Alert" }
3629
+ };
3630
+ await fetch(config.webhook_url, {
3631
+ method: "POST",
3632
+ headers: { "Content-Type": "application/json" },
3633
+ body: JSON.stringify({ embeds: [embed] })
3634
+ });
3635
+ };
3636
+ }
3637
+
3638
+ // src/daemon/alerts/pagerduty.ts
3639
+ var SEVERITY_RANK3 = {
3640
+ info: 0,
3641
+ low: 1,
3642
+ medium: 2,
3643
+ high: 3,
3644
+ critical: 4
3645
+ };
3646
+ var PD_SEVERITY = {
3647
+ info: "info",
3648
+ low: "info",
3649
+ medium: "warning",
3650
+ high: "error",
3651
+ critical: "critical"
3652
+ };
3653
+ function pagerdutyChannel(config) {
3654
+ return async (event) => {
3655
+ if (config.min_severity) {
3656
+ const eventRank = SEVERITY_RANK3[event.severity] ?? 0;
3657
+ const minRank = SEVERITY_RANK3[config.min_severity] ?? 0;
3658
+ if (eventRank < minRank) return;
3659
+ }
3660
+ const payload = {
3661
+ routing_key: config.routing_key,
3662
+ event_action: "trigger",
3663
+ payload: {
3664
+ summary: `[${event.severity.toUpperCase()}] ${event.module}: ${event.message}`,
3665
+ source: "threatcrush",
3666
+ severity: PD_SEVERITY[event.severity] || "warning",
3667
+ timestamp: event.timestamp.toISOString(),
3668
+ custom_details: {
3669
+ module: event.module,
3670
+ category: event.category,
3671
+ source_ip: event.source_ip,
3672
+ details: event.details
3673
+ }
3674
+ }
3675
+ };
3676
+ await fetch("https://events.pagerduty.com/v2/enqueue", {
3677
+ method: "POST",
3678
+ headers: { "Content-Type": "application/json" },
3679
+ body: JSON.stringify(payload)
3680
+ });
3681
+ };
3682
+ }
3683
+
3155
3684
  // src/daemon/alerts/index.ts
3156
3685
  var AlertDispatcher = class {
3157
3686
  constructor(bus2, config) {
@@ -3165,6 +3694,7 @@ var AlertDispatcher = class {
3165
3694
  bus;
3166
3695
  config;
3167
3696
  channels = [];
3697
+ rateLimits = /* @__PURE__ */ new Map();
3168
3698
  bindChannels() {
3169
3699
  const alerts = this.config.alerts || {};
3170
3700
  for (const [name, raw] of Object.entries(alerts)) {
@@ -3179,11 +3709,31 @@ var AlertDispatcher = class {
3179
3709
  if (name === "email" && typeof cfg.host === "string" && typeof cfg.from === "string") {
3180
3710
  this.channels.push(smtpChannel(cfg));
3181
3711
  }
3712
+ if (name === "discord" && typeof cfg.webhook_url === "string") {
3713
+ this.channels.push(discordChannel(cfg));
3714
+ }
3715
+ if (name === "pagerduty" && typeof cfg.routing_key === "string") {
3716
+ this.channels.push(pagerdutyChannel(cfg));
3717
+ }
3182
3718
  }
3183
3719
  }
3720
+ checkRateLimit(channelIdx, maxPerHour = 60) {
3721
+ const key = String(channelIdx);
3722
+ const now = Date.now();
3723
+ const hour = 36e5;
3724
+ let timestamps = this.rateLimits.get(key) || [];
3725
+ timestamps = timestamps.filter((t) => t > now - hour);
3726
+ if (timestamps.length >= maxPerHour) return false;
3727
+ timestamps.push(now);
3728
+ this.rateLimits.set(key, timestamps);
3729
+ return true;
3730
+ }
3184
3731
  async dispatch(event) {
3185
- await Promise.all(this.channels.map((ch) => ch(event).catch(() => {
3186
- })));
3732
+ await Promise.all(this.channels.map((ch, idx) => {
3733
+ if (!this.checkRateLimit(idx)) return Promise.resolve();
3734
+ return ch(event).catch(() => {
3735
+ });
3736
+ }));
3187
3737
  }
3188
3738
  };
3189
3739
  function webhookChannel(url, secret) {
@@ -3207,14 +3757,14 @@ function slackChannel(webhookUrl) {
3207
3757
  }
3208
3758
 
3209
3759
  // src/core/cli-config.ts
3210
- var import_node_fs7 = require("fs");
3760
+ var import_node_fs9 = require("fs");
3211
3761
  var import_node_path4 = require("path");
3212
3762
  var import_node_os2 = require("os");
3213
3763
  var CLI_CONFIG_DIR = (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".threatcrush");
3214
3764
  var CLI_CONFIG_PATH = (0, import_node_path4.join)(CLI_CONFIG_DIR, "config.json");
3215
3765
  function readCliConfig() {
3216
3766
  try {
3217
- return JSON.parse((0, import_node_fs7.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
3767
+ return JSON.parse((0, import_node_fs9.readFileSync)(CLI_CONFIG_PATH, "utf-8"));
3218
3768
  } catch {
3219
3769
  return {};
3220
3770
  }
@@ -3233,8 +3783,8 @@ function authHeaders() {
3233
3783
  }
3234
3784
 
3235
3785
  // src/commands/scan.ts
3236
- var import_node_fs8 = require("fs");
3237
- var import_node_path5 = require("path");
3786
+ var import_node_fs12 = require("fs");
3787
+ var import_node_path8 = require("path");
3238
3788
 
3239
3789
  // ../../node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
3240
3790
  var ANSI_BACKGROUND_OFFSET = 10;
@@ -3732,7 +4282,7 @@ var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
3732
4282
  var source_default = chalk;
3733
4283
 
3734
4284
  // src/core/logger.ts
3735
- var SEVERITY_COLORS = {
4285
+ var SEVERITY_COLORS2 = {
3736
4286
  info: source_default.green,
3737
4287
  low: source_default.cyan,
3738
4288
  medium: source_default.yellow,
@@ -3760,59 +4310,1114 @@ function workerId() {
3760
4310
  return `${import_node_os4.default.hostname()}/${process.pid}`;
3761
4311
  }
3762
4312
 
3763
- // src/commands/scan.ts
3764
- var SECRET_PATTERNS = [
3765
- { name: "AWS Access Key", pattern: /(?:AKIA[0-9A-Z]{16})/g, severity: "critical" },
3766
- { name: "AWS Secret Key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/gi, severity: "critical" },
3767
- { name: "GitHub Token", pattern: /(?:ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82})/g, severity: "critical" },
3768
- { name: "Generic API Key", pattern: /(?:api[_-]?key|apikey)\s*[=:]\s*['"]([A-Za-z0-9\-_]{20,})['"]?/gi, severity: "high" },
3769
- { name: "Generic Secret", pattern: /(?:secret|password|passwd|pwd)\s*[=:]\s*['"]([^'"]{8,})['"]?/gi, severity: "high" },
3770
- { name: "Private Key", pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g, severity: "critical" },
3771
- { name: "JWT Token", pattern: /eyJ[A-Za-z0-9-_]+\.eyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*/g, severity: "high" },
3772
- { name: "Slack Token", pattern: /xox[bpors]-[A-Za-z0-9-]{10,}/g, severity: "critical" },
3773
- { name: "Stripe Key", pattern: /(?:sk_live_|pk_live_|sk_test_|pk_test_)[A-Za-z0-9]{20,}/g, severity: "critical" },
3774
- { name: "Database URL", pattern: /(?:postgres|mysql|mongodb|redis):\/\/[^\s'"]+/gi, severity: "high" },
3775
- { name: "Bearer Token", pattern: /Bearer\s+[A-Za-z0-9\-_\.]{20,}/g, severity: "medium" },
3776
- { name: "Hex Token (32+)", pattern: /(?:token|key|secret|auth)\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/gi, severity: "medium" }
3777
- ];
3778
- var MISCONFIG_FILES = [
3779
- { pattern: ".env", message: ".env file found \u2014 may contain secrets" },
3780
- { pattern: ".env.local", message: ".env.local file found \u2014 may contain secrets" },
3781
- { pattern: ".env.production", message: ".env.production file found \u2014 may contain secrets" },
3782
- { pattern: "id_rsa", message: "Private SSH key found" },
3783
- { pattern: "id_ed25519", message: "Private SSH key found" },
3784
- { pattern: ".pem", message: "PEM certificate/key file found" },
3785
- { pattern: ".p12", message: "PKCS#12 keystore found" },
3786
- { pattern: ".keystore", message: "Keystore file found" }
4313
+ // src/scan/dependencies.ts
4314
+ var import_node_fs10 = require("fs");
4315
+ var import_node_path5 = require("path");
4316
+ var LOCKFILES = [
4317
+ { file: "package-lock.json", ecosystem: "npm" },
4318
+ { file: "pnpm-lock.yaml", ecosystem: "npm" },
4319
+ { file: "yarn.lock", ecosystem: "npm" },
4320
+ { file: "requirements.txt", ecosystem: "PyPI" },
4321
+ { file: "Pipfile.lock", ecosystem: "PyPI" }
3787
4322
  ];
3788
- var SKIP_DIRS = /* @__PURE__ */ new Set([
3789
- "node_modules",
3790
- ".git",
3791
- ".next",
3792
- "dist",
3793
- "build",
3794
- "__pycache__",
3795
- ".venv",
3796
- "vendor",
3797
- ".terraform",
3798
- "coverage",
3799
- ".cache"
3800
- ]);
3801
- var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
3802
- ".ts",
3803
- ".js",
3804
- ".tsx",
3805
- ".jsx",
3806
- ".py",
3807
- ".rb",
3808
- ".go",
3809
- ".java",
3810
- ".php",
3811
- ".rs",
3812
- ".c",
3813
- ".cpp",
3814
- ".h",
3815
- ".yml",
4323
+ var MAX_DEPS_PER_LOCKFILE = 50;
4324
+ async function scanDependencies(targetPath) {
4325
+ const findings = [];
4326
+ for (const { file, ecosystem } of LOCKFILES) {
4327
+ const lockPath = (0, import_node_path5.join)(targetPath, file);
4328
+ if (!(0, import_node_fs10.existsSync)(lockPath)) continue;
4329
+ let deps;
4330
+ try {
4331
+ deps = parseDependencies(lockPath, file);
4332
+ } catch {
4333
+ continue;
4334
+ }
4335
+ for (const dep of deps.slice(0, MAX_DEPS_PER_LOCKFILE)) {
4336
+ let vulns;
4337
+ try {
4338
+ vulns = await queryOsv(dep.name, dep.version, ecosystem);
4339
+ } catch {
4340
+ continue;
4341
+ }
4342
+ for (const vuln of vulns) {
4343
+ const cvss = vuln.severity?.find((entry) => entry.type === "CVSS_V3")?.score;
4344
+ findings.push({
4345
+ ruleId: "dependency-known-vulnerability",
4346
+ title: "Dependency CVE",
4347
+ file,
4348
+ line: 1,
4349
+ severity: severityFromCvss(cvss),
4350
+ confidence: "evidence",
4351
+ message: `${dep.name}@${dep.version}: ${vuln.summary ?? vuln.id}`,
4352
+ consequence: "A published advisory exists for the exact version resolved in this lockfile.",
4353
+ excerpt: `${vuln.id}${cvss ? ` (CVSS: ${cvss})` : ""}`,
4354
+ category: "dependency"
4355
+ });
4356
+ }
4357
+ }
4358
+ }
4359
+ return findings;
4360
+ }
4361
+ function severityFromCvss(score) {
4362
+ if (!score) return "medium";
4363
+ const value = Number.parseFloat(score);
4364
+ if (Number.isNaN(value)) return "medium";
4365
+ if (value >= 9) return "critical";
4366
+ if (value >= 7) return "high";
4367
+ if (value >= 4) return "medium";
4368
+ return "low";
4369
+ }
4370
+ function parseDependencies(lockPath, filename) {
4371
+ const deps = [];
4372
+ if (filename === "package-lock.json") {
4373
+ const lock = JSON.parse((0, import_node_fs10.readFileSync)(lockPath, "utf-8"));
4374
+ const packages = lock.packages ?? lock.dependencies ?? {};
4375
+ for (const [key, value] of Object.entries(packages)) {
4376
+ const name = key.replace(/^node_modules\//, "");
4377
+ const version = value?.version;
4378
+ if (name && version && !name.startsWith(".")) deps.push({ name, version });
4379
+ }
4380
+ return deps;
4381
+ }
4382
+ if (filename === "requirements.txt") {
4383
+ for (const line of (0, import_node_fs10.readFileSync)(lockPath, "utf-8").split("\n")) {
4384
+ const match = /^([a-zA-Z0-9_.-]+)==([0-9.]+)/.exec(line);
4385
+ if (match?.[1] && match[2]) deps.push({ name: match[1], version: match[2] });
4386
+ }
4387
+ }
4388
+ return deps;
4389
+ }
4390
+ function isValidPackageName(name) {
4391
+ return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
4392
+ }
4393
+ function isValidVersion(version) {
4394
+ return /^[0-9a-zA-Z._\-+]{1,50}$/.test(version);
4395
+ }
4396
+ async function queryOsv(name, version, ecosystem) {
4397
+ if (!isValidPackageName(name) || !isValidVersion(version)) return [];
4398
+ try {
4399
+ const response = await fetch("https://api.osv.dev/v1/query", {
4400
+ method: "POST",
4401
+ headers: { "Content-Type": "application/json" },
4402
+ body: JSON.stringify({ package: { name, ecosystem }, version }),
4403
+ signal: AbortSignal.timeout(5e3)
4404
+ });
4405
+ if (!response.ok) return [];
4406
+ const data = await response.json();
4407
+ return data.vulns ?? [];
4408
+ } catch {
4409
+ return [];
4410
+ }
4411
+ }
4412
+
4413
+ // src/scan/engine.ts
4414
+ var import_node_fs11 = require("fs");
4415
+ var import_node_path6 = require("path");
4416
+
4417
+ // src/scan/types.ts
4418
+ var SEVERITY_ORDER = ["info", "low", "medium", "high", "critical"];
4419
+ function severityRank(severity) {
4420
+ const index = SEVERITY_ORDER.indexOf(severity);
4421
+ return index === -1 ? 0 : index;
4422
+ }
4423
+ function severityFor(declared, confidence) {
4424
+ if (confidence !== "pattern") return declared;
4425
+ return severityRank(declared) > severityRank("medium") ? "medium" : declared;
4426
+ }
4427
+
4428
+ // src/scan/code-rules.ts
4429
+ var UNTRUSTED_JS = /\b(?:req|request|ctx|context)\s*\.\s*(?:body|query|params|param|headers|cookies|url|files)\b|\bprocess\.argv\b|\bwindow\.location\b|\bdocument\.location\b|\blocation\.(?:search|hash|href)\b|\bsearchParams\b|\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetInputStream\s*\(/;
4430
+ var UNTRUSTED_PY = /\brequest\b|\bparams\b|\bflask\b|\bsys\.argv\b|\bos\.environ\b\s*\[/;
4431
+ var UNTRUSTED_RB = /\bparams\s*\[|\brequest\b|\bcookies\s*\[/;
4432
+ var UNTRUSTED_GO = /\br\s*\.\s*(?:URL|Form|Body|Header|PostForm)\b|\bFormValue\s*\(|\bQuery\s*\(\s*\)\s*\.\s*Get\s*\(|\bmux\.Vars\s*\(/;
4433
+ var UNTRUSTED_JAVA = /\bgetParameter\s*\(|\bgetQueryString\s*\(|\bgetHeader\s*\(|\bgetInputStream\s*\(|\bgetCookies\s*\(|\b@RequestParam\b|\b@PathVariable\b/;
4434
+ function untrustedPatternFor(language) {
4435
+ switch (language) {
4436
+ case "python":
4437
+ return UNTRUSTED_PY;
4438
+ case "ruby":
4439
+ return UNTRUSTED_RB;
4440
+ case "go":
4441
+ return UNTRUSTED_GO;
4442
+ case "java":
4443
+ return UNTRUSTED_JAVA;
4444
+ default:
4445
+ return UNTRUSTED_JS;
4446
+ }
4447
+ }
4448
+ var GENERIC_GUARD = /\ballow(?:ed|list|_list|ed_hosts)?\b|\bwhitelist\b|\bescape(?:Html|Html4|Xml|Sql)?\s*\(|\bhtml_escape\b|\bhtmlspecialchars\s*\(|\bsanitiz\w*\b|\bencoded\b|\brealpath\b|\bcommonpath\b|\bresolve\(\)\.startsWith\b|\bprocess\.env\b|\bos\.environ\b|\bgetenv\b|\bENV\s*\[|setObjectInputFilter|ObjectInputFilter/i;
4449
+ var XXE_GUARD = /FEATURE_SECURE_PROCESSING|setExpandEntityReferences|disallow-doctype-decl|external-general-entities|external-parameter-entities|setXIncludeAware\s*\(\s*false/;
4450
+ var CODE_SINK = /\bglobalThis\s*\[|\bconstructor\b|\beval\b|\bFunction\b|\brun\s*\(|\bvm\s*\.\s*run/;
4451
+ var EXFIL_SINK = /\bconsole\s*\.\s*(?:log|debug|info|warn|error)\s*\(|\bfetch\s*\(|\baxios\b|\brequest\s*\(|\.\s*send\s*\(/;
4452
+ var SQL_KEYWORDS = "SELECT|INSERT\\s+INTO|INSERT|UPDATE|DELETE\\s+FROM|DELETE|DROP|UNION\\s+SELECT";
4453
+ var SQL_IN_DOUBLE = `"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*"`;
4454
+ var SQL_IN_SINGLE = `'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*'`;
4455
+ var SQL_STRING = `(?:${SQL_IN_DOUBLE}|${SQL_IN_SINGLE})`;
4456
+ var CODE_RULES = [
4457
+ // ── Injection: SQL ───────────────────────────────────────────────────────
4458
+ {
4459
+ id: "sql-string-concatenation",
4460
+ title: "SQL assembled by concatenation or interpolation",
4461
+ consequence: "A quote in the interpolated value changes the query\u2019s meaning \u2014 the query runs as the attacker wrote it, not as you wrote it.",
4462
+ cwe: "CWE-89",
4463
+ severity: "critical",
4464
+ // The tail is what distinguishes assembly from parameterisation. A bound
4465
+ // query leaves a comma after the closing quote (`"… = $1", [id]`) and
4466
+ // matches none of these.
4467
+ pattern: new RegExp(
4468
+ `${SQL_STRING}\\s*\\+|${SQL_STRING}\\s*%\\s*[\\w(]|${SQL_STRING}\\s*\\.\\s*format\\s*\\(|\\+\\s*(?:"[^"\\n]*|'[^'\\n]*)(?:WHERE|ORDER\\s+BY|VALUES|SET)\\b`,
4469
+ "i"
4470
+ )
4471
+ },
4472
+ {
4473
+ id: "sql-template-interpolation",
4474
+ title: "SQL built from a template literal or f-string",
4475
+ consequence: "Template interpolation is string concatenation with nicer syntax \u2014 it binds nothing and escapes nothing.",
4476
+ cwe: "CWE-89",
4477
+ severity: "critical",
4478
+ pattern: new RegExp(
4479
+ `\`[^\`\\n]*(?:${SQL_KEYWORDS})\\b[^\`\\n]*\\$\\{|\\bf"[^"\\n]*(?:${SQL_KEYWORDS})\\b[^"\\n]*\\{|\\bf'[^'\\n]*(?:${SQL_KEYWORDS})\\b[^'\\n]*\\{`,
4480
+ "i"
4481
+ )
4482
+ },
4483
+ {
4484
+ id: "sql-format-call",
4485
+ title: "SQL text produced by a format helper",
4486
+ consequence: "`Sprintf`/`String.format` substitute without quoting; the resulting string is concatenated SQL by another name.",
4487
+ cwe: "CWE-89",
4488
+ severity: "critical",
4489
+ languages: ["go", "java"],
4490
+ pattern: new RegExp(
4491
+ `\\b(?:fmt\\.Sprintf|String\\.format)\\s*\\(\\s*"[^"\\n]*(?:${SQL_KEYWORDS})\\b`,
4492
+ "i"
4493
+ )
4494
+ },
4495
+ {
4496
+ id: "rb-sql-interpolation",
4497
+ title: "ActiveRecord query built by string interpolation",
4498
+ consequence: '`where("\u2026 #{value}")` interpolates before the adapter sees it, so no binding ever happens.',
4499
+ cwe: "CWE-89",
4500
+ severity: "critical",
4501
+ languages: ["ruby"],
4502
+ pattern: /\b(?:where|find_by_sql|execute|select_all|select_values|order|group|pluck)\s*[( ]\s*(?:"[^"\n]*|'[^'\n]*)#\{/
4503
+ },
4504
+ // ── Injection: OS command ────────────────────────────────────────────────
4505
+ {
4506
+ id: "js-shell-exec-interpolation",
4507
+ title: "shell execution with an interpolated string",
4508
+ consequence: "A `;` or `$(\u2026)` in the interpolated value runs as the server user.",
4509
+ cwe: "CWE-78",
4510
+ severity: "critical",
4511
+ languages: ["javascript", "typescript"],
4512
+ pattern: /\b(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|[a-zA-Z_$][\w$]*\s*\+)/
4513
+ },
4514
+ {
4515
+ id: "py-shell-command-string",
4516
+ title: "shell command built from a string",
4517
+ consequence: "`os.system` and `shell=True` hand the string to `/bin/sh`, which happily interprets metacharacters.",
4518
+ cwe: "CWE-78",
4519
+ severity: "critical",
4520
+ languages: ["python"],
4521
+ pattern: /\bos\.(?:system|popen)\s*\(\s*(?:f?['"][^'"]*['"]\s*(?:\+|%|\.\s*format)|f['"]|[a-zA-Z_]\w*\s*[,)])|\bsubprocess\.(?:run|call|check_call|check_output|Popen)\s*\([^)]*\bshell\s*=\s*True/
4522
+ },
4523
+ {
4524
+ id: "go-shell-exec-command",
4525
+ title: "exec.Command invoking a shell",
4526
+ consequence: "Passing `sh -c` re-introduces the shell that `exec.Command`\u2019s argv interface exists to avoid.",
4527
+ cwe: "CWE-78",
4528
+ severity: "critical",
4529
+ languages: ["go"],
4530
+ pattern: /\bexec\.Command(?:Context)?\s*\(\s*(?:ctx\s*,\s*)?"(?:\/bin\/)?(?:sh|bash|zsh|cmd|powershell)"\s*,\s*"(?:-c|\/c)"/
4531
+ },
4532
+ {
4533
+ id: "rb-backtick-interpolation",
4534
+ title: "backtick command with interpolation",
4535
+ consequence: "Ruby backticks are a shell invocation; `#{}` inside one is command injection.",
4536
+ cwe: "CWE-78",
4537
+ severity: "critical",
4538
+ languages: ["ruby"],
4539
+ pattern: /`[^`\n]*#\{|\bsystem\s*\(\s*["'][^"'\n]*#\{|%x\[[^\]]*#\{/
4540
+ },
4541
+ // ── Injection: dynamic code ──────────────────────────────────────────────
4542
+ {
4543
+ id: "js-dynamic-code-execution",
4544
+ title: "dynamic code execution",
4545
+ consequence: "Any string reaching this call executes as code with the process\u2019 privileges.",
4546
+ cwe: "CWE-95",
4547
+ severity: "critical",
4548
+ languages: ["javascript", "typescript"],
4549
+ pattern: /\beval\s*\(|\bnew\s+Function\s*\(|\bvm\s*\.\s*run(?:InThisContext|InNewContext|InContext)\s*\(|\bset(?:Timeout|Interval)\s*\(\s*(?:['"`]|(?:req|request|ctx|params|query|body)\b)/
4550
+ },
4551
+ {
4552
+ id: "js-indirect-code-sink",
4553
+ title: "code sink reached indirectly",
4554
+ consequence: "Resolving `eval`/`Function` through `globalThis[\u2026]` or `.constructor` hides the sink from literal matching. Legitimate code has no reason to.",
4555
+ cwe: "CWE-506",
4556
+ severity: "high",
4557
+ languages: ["javascript", "typescript"],
4558
+ pattern: /\bglobalThis\s*\[\s*[a-zA-Z_$][\w$]*\s*\]|\(\s*function\s*\(\s*\)\s*\{\s*\}\s*\)\s*\.\s*constructor/
4559
+ },
4560
+ {
4561
+ id: "js-encoded-payload-execution",
4562
+ title: "encoded blob decoded next to a code sink",
4563
+ consequence: "A base64 literal that is decoded and executed is the standard shape of a planted backdoor; the encoding exists to defeat review.",
4564
+ cwe: "CWE-506",
4565
+ severity: "critical",
4566
+ languages: ["javascript", "typescript"],
4567
+ pattern: /\bBuffer\.from\s*\(\s*[\w.$]+\s*,\s*['"]base64['"]\s*\)|\batob\s*\(\s*[\w.$]+\s*\)/,
4568
+ requires: CODE_SINK,
4569
+ guardBack: 6,
4570
+ guardForward: 3
4571
+ },
4572
+ {
4573
+ id: "py-dynamic-code-execution",
4574
+ title: "dynamic code execution",
4575
+ consequence: "Any string reaching this call executes as Python with the process\u2019 privileges.",
4576
+ cwe: "CWE-95",
4577
+ severity: "critical",
4578
+ languages: ["python"],
4579
+ pattern: /\b(?:eval|exec)\s*\(\s*(?!['"]\s*\))[a-zA-Z_(f'"]/,
4580
+ needsContext: true
4581
+ },
4582
+ {
4583
+ id: "rb-dynamic-dispatch",
4584
+ title: "dynamic code execution or unrestricted #send",
4585
+ consequence: "`eval` runs arbitrary Ruby; unrestricted `#send` lets the caller invoke any method on the receiver, including private ones.",
4586
+ cwe: "CWE-95",
4587
+ severity: "critical",
4588
+ languages: ["ruby"],
4589
+ pattern: /\beval\s*\(|\binstance_eval\s*\(|\bclass_eval\s*\(|\.\s*send\s*\(\s*(?:params|request|args)\b/
4590
+ },
4591
+ // ── Cross-site scripting ─────────────────────────────────────────────────
4592
+ {
4593
+ id: "js-unescaped-html-sink",
4594
+ title: "unescaped HTML rendering",
4595
+ consequence: "A script tag in the value executes in the victim\u2019s session \u2014 stored or reflected XSS.",
4596
+ cwe: "CWE-79",
4597
+ severity: "high",
4598
+ languages: ["javascript", "typescript"],
4599
+ pattern: /\bdangerouslySetInnerHTML\s*=|\.\s*(?:innerHTML|outerHTML)\s*=\s*(?!\s*['"`]\s*['"`]\s*;?\s*$)|\bdocument\s*\.\s*write(?:ln)?\s*\(|\.\s*insertAdjacentHTML\s*\(/
4600
+ },
4601
+ {
4602
+ id: "java-html-writer-concatenation",
4603
+ title: "HTML written to the response by concatenation",
4604
+ consequence: "The servlet writer performs no encoding; a value concatenated into markup is rendered as markup.",
4605
+ cwe: "CWE-79",
4606
+ severity: "high",
4607
+ languages: ["java"],
4608
+ pattern: /\b(?:println|print|write)\s*\(\s*"[^"\n]*<[^"\n]*"\s*\+/
4609
+ },
4610
+ {
4611
+ id: "rb-unescaped-output",
4612
+ title: "Rails output escaping bypassed",
4613
+ consequence: "`html_safe` and `raw` tell Rails the string is already safe. If it came from a parameter, it is not.",
4614
+ cwe: "CWE-79",
4615
+ severity: "high",
4616
+ languages: ["ruby"],
4617
+ pattern: /\.\s*html_safe\b|\braw\s*\(\s*(?:params|request|@)|\blink_to\s+[^,\n]+,\s*params\s*\[/
4618
+ },
4619
+ {
4620
+ id: "py-template-autoescape-off",
4621
+ title: "template rendering with escaping disabled",
4622
+ consequence: "With autoescape off \u2014 or a `|safe` filter \u2014 every interpolated value is rendered as markup.",
4623
+ cwe: "CWE-79",
4624
+ severity: "high",
4625
+ languages: ["python"],
4626
+ pattern: /\bEnvironment\s*\([^)]*\bautoescape\s*=\s*False|\|\s*safe\b|\bMarkup\s*\(\s*(?!['"])/
4627
+ },
4628
+ {
4629
+ id: "py-template-from-input",
4630
+ title: "template compiled from a non-literal source",
4631
+ consequence: "Server-side template injection. In Jinja2 the sandbox is escapable, so this escalates from XSS to remote code execution.",
4632
+ cwe: "CWE-1336",
4633
+ severity: "critical",
4634
+ languages: ["python"],
4635
+ pattern: /\bTemplate\s*\(\s*(?!['"])[a-zA-Z_]/,
4636
+ needsContext: true
4637
+ },
4638
+ // ── Server-side request forgery ──────────────────────────────────────────
4639
+ {
4640
+ id: "js-ssrf-outbound-request",
4641
+ title: "outbound request to a non-constant URL",
4642
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
4643
+ cwe: "CWE-918",
4644
+ severity: "high",
4645
+ languages: ["javascript", "typescript"],
4646
+ pattern: /\bfetch\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]|\bhttps?\s*\.\s*(?:get|request)\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]|\baxios\s*\.\s*get\s*\(\s*[a-zA-Z_$][\w$]*\s*[,)]/,
4647
+ needsContext: true
4648
+ },
4649
+ {
4650
+ id: "py-ssrf-outbound-request",
4651
+ title: "outbound request to a non-constant URL",
4652
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
4653
+ cwe: "CWE-918",
4654
+ severity: "high",
4655
+ languages: ["python"],
4656
+ pattern: /\brequests\.(?:get|request|head)\s*\(\s*[a-zA-Z_]\w*\s*[,)]|\burlopen\s*\(\s*[a-zA-Z_]\w*\s*[,)]|\bhttpx\.get\s*\(\s*[a-zA-Z_]\w*\s*[,)]/,
4657
+ needsContext: true
4658
+ },
4659
+ {
4660
+ id: "go-ssrf-outbound-request",
4661
+ title: "outbound request to a non-constant URL",
4662
+ consequence: "An attacker who controls the URL reaches the cloud metadata endpoint, localhost, and every service on the private network.",
4663
+ cwe: "CWE-918",
4664
+ severity: "high",
4665
+ languages: ["go"],
4666
+ pattern: /\bhttp\.(?:Get|Post|Head)\s*\(\s*(?:[a-zA-Z_]\w*\s*[,)]|"[^"]*"\s*\+)/,
4667
+ needsContext: true
4668
+ },
4669
+ // ── Open redirect ────────────────────────────────────────────────────────
4670
+ {
4671
+ id: "js-open-redirect",
4672
+ title: "redirect to a non-constant destination",
4673
+ consequence: "Your domain becomes the credible first hop of a phishing chain; the victim sees your hostname in the link they clicked.",
4674
+ cwe: "CWE-601",
4675
+ severity: "medium",
4676
+ languages: ["javascript", "typescript"],
4677
+ pattern: /\b(?:res|response)\s*\.\s*redirect\s*\(\s*[a-zA-Z_$][\w$]*\s*\)|\bwindow\s*\.\s*location(?:\s*\.\s*(?:href|replace))?\s*(?:=\s*[a-zA-Z_$]|\(\s*[a-zA-Z_$][\w$]*\s*\))/,
4678
+ needsContext: true
4679
+ },
4680
+ // ── Deserialisation ──────────────────────────────────────────────────────
4681
+ {
4682
+ id: "py-unsafe-deserialization",
4683
+ title: "deserialisation of untrusted data",
4684
+ consequence: "`pickle` and `yaml.load` instantiate arbitrary types during parsing \u2014 a crafted payload is remote code execution, not a parse error.",
4685
+ cwe: "CWE-502",
4686
+ severity: "critical",
4687
+ languages: ["python"],
4688
+ pattern: /\bpickle\.loads?\s*\(|\bcPickle\.loads?\s*\(|\bmarshal\.loads\s*\(|\byaml\.load\s*\(|\bjsonpickle\.decode\s*\(/
4689
+ },
4690
+ {
4691
+ id: "java-unsafe-deserialization",
4692
+ title: "Java deserialisation without a class filter",
4693
+ consequence: "A gadget chain in the classpath turns `readObject()` on attacker bytes into remote code execution.",
4694
+ cwe: "CWE-502",
4695
+ severity: "critical",
4696
+ languages: ["java"],
4697
+ pattern: /\breadObject\s*\(\s*\)|\bnew\s+ObjectInputStream\s*\(/,
4698
+ guardBack: 8,
4699
+ // The stream is constructed, *then* filtered. Without a forward window the
4700
+ // guarded case matches on its constructor line and reports a correct
4701
+ // implementation as a finding.
4702
+ guardForward: 6
4703
+ },
4704
+ {
4705
+ id: "js-unsafe-yaml-load",
4706
+ title: "YAML parsed with type resolution enabled",
4707
+ consequence: "A crafted document can instantiate arbitrary types during parsing.",
4708
+ cwe: "CWE-502",
4709
+ severity: "high",
4710
+ languages: ["javascript", "typescript"],
4711
+ pattern: /\byaml\s*\.\s*load\s*\((?![^)]*safe)|\bloadAll\s*\([^)]*unsafe/i
4712
+ },
4713
+ // ── XML external entities ────────────────────────────────────────────────
4714
+ {
4715
+ id: "java-xxe-parser-defaults",
4716
+ title: "XML parser left on its insecure defaults",
4717
+ consequence: "External entity expansion reads local files and makes outbound requests on the parser\u2019s behalf \u2014 file disclosure and SSRF from a document.",
4718
+ cwe: "CWE-611",
4719
+ severity: "high",
4720
+ languages: ["java"],
4721
+ pattern: /\b(?:DocumentBuilderFactory|SAXParserFactory|XMLInputFactory|TransformerFactory|SchemaFactory)\s*\.\s*newInstance\s*\(\s*\)/,
4722
+ guard: XXE_GUARD,
4723
+ guardBack: 4,
4724
+ guardForward: 8
4725
+ },
4726
+ {
4727
+ id: "java-xxe-parse-call",
4728
+ title: "XML parsed by a builder that was never hardened",
4729
+ consequence: "The expansion happens at `parse()`. Flagging only the factory misses the line where the document is actually read.",
4730
+ cwe: "CWE-611",
4731
+ severity: "high",
4732
+ languages: ["java"],
4733
+ // Receiver-qualified so `LocalDate.parse(s)` and friends stay out of it.
4734
+ pattern: /\b\w*(?:[Bb]uilder|[Pp]arser|[Rr]eader)\s*\.\s*parse\s*\(/,
4735
+ guard: XXE_GUARD,
4736
+ guardBack: 6,
4737
+ guardForward: 4
4738
+ },
4739
+ // ── Path traversal ───────────────────────────────────────────────────────
4740
+ {
4741
+ id: "py-path-traversal",
4742
+ title: "file opened at a path built from input",
4743
+ consequence: "A `../` sequence \u2014 or an absolute path \u2014 reads or writes outside the intended directory.",
4744
+ cwe: "CWE-22",
4745
+ severity: "high",
4746
+ languages: ["python"],
4747
+ pattern: /\bopen\s*\(\s*(?:os\.path\.join\s*\(|[a-zA-Z_]\w*\s*\+|f['"])/,
4748
+ needsContext: true
4749
+ },
4750
+ {
4751
+ id: "js-path-traversal",
4752
+ title: "file path built from a variable",
4753
+ consequence: "A `../` sequence in the value reads or writes outside the intended directory.",
4754
+ cwe: "CWE-22",
4755
+ severity: "medium",
4756
+ languages: ["javascript", "typescript"],
4757
+ pattern: /\b(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|sendFile)\s*\(\s*(?:`[^`]*\$\{|[a-zA-Z_$][\w$]*\s*\+|path\.join\s*\([^)]*(?:req|request)\b)/,
4758
+ needsContext: true
4759
+ },
4760
+ // ── Cryptography, tokens, randomness ─────────────────────────────────────
4761
+ {
4762
+ id: "js-jwt-decode-without-verify",
4763
+ title: "JWT decoded without verifying the signature",
4764
+ consequence: "`decode` parses the claims and checks nothing. Anyone can mint a token with any `sub` and any `role`.",
4765
+ cwe: "CWE-347",
4766
+ severity: "critical",
4767
+ languages: ["javascript", "typescript"],
4768
+ pattern: /\bjwt\s*\.\s*decode\s*\(|\bjsonwebtoken\s*\.\s*decode\s*\(|\bdecodeJwt\s*\(/
4769
+ },
4770
+ {
4771
+ id: "tls-verification-disabled",
4772
+ title: "TLS certificate verification disabled",
4773
+ consequence: "Every connection made this way is trivially interceptable; the encryption is decorative.",
4774
+ cwe: "CWE-295",
4775
+ severity: "high",
4776
+ pattern: /rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*[=:]\s*['"]?0|strictSSL\s*:\s*false|\bverify\s*=\s*False\b|InsecureSkipVerify\s*:\s*true/
4777
+ },
4778
+ {
4779
+ id: "weak-hash-on-credential",
4780
+ title: "broken hash used on a credential",
4781
+ consequence: "MD5 and SHA-1 are fast and collision-prone; hashed passwords are recoverable.",
4782
+ cwe: "CWE-327",
4783
+ severity: "high",
4784
+ pattern: /(?:createHash|hashlib|MessageDigest\.getInstance|Digest::)\s*[.(]?\s*['"]?(?:md5|MD5|sha1|SHA-?1)['"]?\s*\)?[\s\S]{0,80}(?:password|passwd|secret|token|credential)/i
4785
+ },
4786
+ {
4787
+ id: "insecure-randomness-for-secret",
4788
+ title: "predictable randomness used for a security value",
4789
+ consequence: "`Math.random`/`random.random` are predictable; tokens, session ids and reset codes built from them are guessable.",
4790
+ cwe: "CWE-338",
4791
+ severity: "high",
4792
+ pattern: /(?:token|secret|password|salt|nonce|session|otp|reset|apikey|api_key)[\w]*\s*[:=][^;\n]{0,60}(?:Math\s*\.\s*random\s*\(|\brandom\s*\.\s*(?:random|randint|choice)\s*\(|\brand\s*\()/i
4793
+ },
4794
+ {
4795
+ id: "redos-nested-quantifier",
4796
+ title: "regex with nested unbounded quantifiers",
4797
+ consequence: "Catastrophic backtracking: a crafted input of a few dozen characters pins a CPU core for minutes.",
4798
+ cwe: "CWE-1333",
4799
+ severity: "medium",
4800
+ pattern: /\([^)\n]*[+*]\s*\)\s*[+*]|\([^)\n]*\{\d+,\}\s*\)\s*[+*{]/
4801
+ },
4802
+ // ── Temporary files ──────────────────────────────────────────────────────
4803
+ {
4804
+ id: "insecure-temp-file",
4805
+ title: "predictable temporary file path",
4806
+ consequence: "A predictable name in a world-writable directory is a symlink attack: an attacker pre-creates the path and your process writes through it.",
4807
+ cwe: "CWE-377",
4808
+ severity: "medium",
4809
+ // A hardcoded path under /tmp is the finding whether or not it is
4810
+ // formatted: `"/tmp/application.log.tmp"` is worse than the PID-based one,
4811
+ // because every process on the host can predict it exactly.
4812
+ pattern: /\btempfile\.mktemp\s*\(|\bos\.tmpnam\s*\(|['"]\/tmp\/[^'"\n]+['"]|['"]\/tmp\/[^'"\n]*\{|\bFile\.createTempFile\s*\(/
4813
+ },
4814
+ // ── Information exposure ─────────────────────────────────────────────────
4815
+ {
4816
+ id: "py-stack-trace-returned",
4817
+ title: "stack trace returned to the caller",
4818
+ consequence: "Tracebacks leak absolute paths, dependency versions and source fragments \u2014 the reconnaissance an attacker would otherwise have to guess at.",
4819
+ cwe: "CWE-209",
4820
+ severity: "medium",
4821
+ languages: ["python"],
4822
+ pattern: /\breturn\b[^\n]*\btraceback\.(?:format_exc|format_exception|print_exc)\s*\(|\breturn\b[^\n]*\bstr\s*\(\s*e\s*\)/
4823
+ },
4824
+ {
4825
+ id: "js-environment-exfiltration",
4826
+ title: "process environment serialised into a payload",
4827
+ consequence: "The environment is where every secret lives. Serialising it whole into a request body is credential exfiltration regardless of the endpoint.",
4828
+ cwe: "CWE-532",
4829
+ severity: "critical",
4830
+ languages: ["javascript", "typescript"],
4831
+ pattern: /\bJSON\.stringify\s*\(\s*\{?[^)]*\bprocess\.env\b(?!\s*\.)/,
4832
+ guard: false
4833
+ },
4834
+ {
4835
+ id: "js-credential-logged",
4836
+ title: "credential read from the environment into a log sink",
4837
+ consequence: "CI retains job logs, and on public forks it publishes them. A token printed once is a token leaked permanently.",
4838
+ cwe: "CWE-532",
4839
+ severity: "high",
4840
+ languages: ["javascript", "typescript"],
4841
+ pattern: /\b(?:token|apiKey|api_key|secret|password|credential|auth)\w*\s*:\s*process\.env\.\w+/i,
4842
+ requires: EXFIL_SINK,
4843
+ guard: false,
4844
+ guardBack: 4,
4845
+ guardForward: 1
4846
+ },
4847
+ // ── Prototype pollution ──────────────────────────────────────────────────
4848
+ {
4849
+ id: "js-prototype-pollution",
4850
+ title: "write to a prototype-reachable key",
4851
+ consequence: "An attacker-supplied `__proto__` key changes behaviour for every object in the process, including ones it never touched.",
4852
+ cwe: "CWE-1321",
4853
+ severity: "high",
4854
+ languages: ["javascript", "typescript"],
4855
+ pattern: /\[\s*['"]__proto__['"]\s*\]|\bObject\s*\.\s*assign\s*\(\s*[\w.$]*\.prototype\b|\.\s*__proto__\s*=/
4856
+ }
4857
+ ];
4858
+ var COMMENT_PREFIX = /^\s*(?:\/\/|\/\*|\*|#|--|<!--)/;
4859
+ var DEFINITION_PREFIX = /^\s*(?:(?:export|public|private|protected|static|final|async|abstract)\s+)*(?:def|function|func|class|module|interface|struct|impl|fn|sub)\b/;
4860
+ function isComment(line) {
4861
+ return COMMENT_PREFIX.test(line);
4862
+ }
4863
+ function proseLines(lines) {
4864
+ const inside = /* @__PURE__ */ new Set();
4865
+ let delimiter = null;
4866
+ lines.forEach((line, index) => {
4867
+ if (delimiter) {
4868
+ inside.add(index);
4869
+ if (line.includes(delimiter)) delimiter = null;
4870
+ return;
4871
+ }
4872
+ for (const candidate of ['"""', "'''"]) {
4873
+ const start = line.indexOf(candidate);
4874
+ if (start === -1) continue;
4875
+ if (line.indexOf(candidate, start + candidate.length) !== -1) return;
4876
+ delimiter = candidate;
4877
+ inside.add(index);
4878
+ return;
4879
+ }
4880
+ });
4881
+ return inside;
4882
+ }
4883
+ function skippable(line, index, prose) {
4884
+ return isComment(line) || DEFINITION_PREFIX.test(line) || (prose?.has(index) ?? false);
4885
+ }
4886
+ function windowText(lines, index, back, forward, prose) {
4887
+ const from = Math.max(0, index - back);
4888
+ const to = Math.min(lines.length - 1, index + forward);
4889
+ const collected = [];
4890
+ for (let i = from; i <= to; i += 1) {
4891
+ const line = lines[i] ?? "";
4892
+ if (i !== index && skippable(line, i, prose)) continue;
4893
+ collected.push(line);
4894
+ }
4895
+ return collected.join("\n");
4896
+ }
4897
+ function evaluateRule(rule, ctx) {
4898
+ if (rule.languages && !rule.languages.includes(ctx.language)) return null;
4899
+ const line = ctx.lines[ctx.index] ?? "";
4900
+ if (isComment(line) || ctx.prose?.has(ctx.index)) return null;
4901
+ if (!rule.pattern.test(line)) return null;
4902
+ const back = rule.guardBack ?? 8;
4903
+ const forward = rule.guardForward ?? 0;
4904
+ const context = windowText(ctx.lines, ctx.index, back, forward, ctx.prose);
4905
+ if (rule.requires && !rule.requires.test(context)) return null;
4906
+ const guard = rule.guard === void 0 ? GENERIC_GUARD : rule.guard;
4907
+ if (guard && (guard.test(line) || guard.test(context))) return null;
4908
+ const untrusted = untrustedPatternFor(ctx.language);
4909
+ const contextual = untrusted.test(line) || untrusted.test(context);
4910
+ if (rule.needsContext && !contextual) return null;
4911
+ const confidence = contextual ? "contextual" : "pattern";
4912
+ return { rule, confidence, severity: severityFor(rule.severity, confidence) };
4913
+ }
4914
+
4915
+ // src/scan/manifest-rules.ts
4916
+ var POPULAR_NPM = [
4917
+ "react",
4918
+ "react-dom",
4919
+ "lodash",
4920
+ "express",
4921
+ "axios",
4922
+ "chalk",
4923
+ "commander",
4924
+ "debug",
4925
+ "moment",
4926
+ "dayjs",
4927
+ "uuid",
4928
+ "dotenv",
4929
+ "typescript",
4930
+ "webpack",
4931
+ "vite",
4932
+ "rollup",
4933
+ "eslint",
4934
+ "prettier",
4935
+ "jest",
4936
+ "vitest",
4937
+ "mocha",
4938
+ "chai",
4939
+ "sinon",
4940
+ "request",
4941
+ "node-fetch",
4942
+ "cross-env",
4943
+ "rimraf",
4944
+ "glob",
4945
+ "minimist",
4946
+ "yargs",
4947
+ "inquirer",
4948
+ "colors",
4949
+ "ora",
4950
+ "semver",
4951
+ "ws",
4952
+ "socket.io",
4953
+ "mongoose",
4954
+ "sequelize",
4955
+ "knex",
4956
+ "pg",
4957
+ "mysql",
4958
+ "mysql2",
4959
+ "redis",
4960
+ "ioredis",
4961
+ "jsonwebtoken",
4962
+ "bcrypt",
4963
+ "passport",
4964
+ "cors",
4965
+ "helmet",
4966
+ "morgan",
4967
+ "body-parser",
4968
+ "multer",
4969
+ "nodemailer",
4970
+ "puppeteer",
4971
+ "playwright",
4972
+ "cheerio",
4973
+ "sharp",
4974
+ "canvas",
4975
+ "esbuild",
4976
+ "babel",
4977
+ "postcss",
4978
+ "tailwindcss",
4979
+ "next",
4980
+ "nuxt",
4981
+ "vue",
4982
+ "svelte",
4983
+ "angular",
4984
+ "rxjs",
4985
+ "zod"
4986
+ ];
4987
+ var POPULAR_PYPI = [
4988
+ "requests",
4989
+ "urllib3",
4990
+ "numpy",
4991
+ "pandas",
4992
+ "scipy",
4993
+ "flask",
4994
+ "django",
4995
+ "fastapi",
4996
+ "sqlalchemy",
4997
+ "pydantic",
4998
+ "click",
4999
+ "jinja2",
5000
+ "pyyaml",
5001
+ "boto3",
5002
+ "botocore",
5003
+ "setuptools",
5004
+ "wheel",
5005
+ "pip",
5006
+ "six",
5007
+ "certifi",
5008
+ "idna",
5009
+ "chardet",
5010
+ "attrs",
5011
+ "python-dateutil",
5012
+ "pytz",
5013
+ "pytest",
5014
+ "tox",
5015
+ "black",
5016
+ "flake8",
5017
+ "mypy",
5018
+ "isort",
5019
+ "beautifulsoup4",
5020
+ "lxml",
5021
+ "pillow",
5022
+ "matplotlib",
5023
+ "seaborn",
5024
+ "scikit-learn",
5025
+ "tensorflow",
5026
+ "torch",
5027
+ "transformers",
5028
+ "openai",
5029
+ "anthropic",
5030
+ "httpx",
5031
+ "aiohttp",
5032
+ "celery",
5033
+ "redis",
5034
+ "psycopg2",
5035
+ "pymongo",
5036
+ "cryptography",
5037
+ "paramiko",
5038
+ "colorama"
5039
+ ];
5040
+ var INTERNAL_MARKER = /(?:^|[-_/@])(?:internal|private|corp|intranet|inhouse|confidential)(?:$|[-_/])/i;
5041
+ function normalizeName(name) {
5042
+ return name.toLowerCase().replace(/^@/, "").replace(/[-_.\s]/g, "");
5043
+ }
5044
+ function editDistance(a, b, cap = 3) {
5045
+ if (a === b) return 0;
5046
+ if (Math.abs(a.length - b.length) > cap) return cap + 1;
5047
+ const rows = [];
5048
+ for (let i = 0; i <= a.length; i += 1) {
5049
+ rows.push(new Array(b.length + 1).fill(0));
5050
+ rows[i][0] = i;
5051
+ }
5052
+ for (let j = 0; j <= b.length; j += 1) rows[0][j] = j;
5053
+ for (let i = 1; i <= a.length; i += 1) {
5054
+ for (let j = 1; j <= b.length; j += 1) {
5055
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
5056
+ let best = Math.min(
5057
+ rows[i - 1][j] + 1,
5058
+ rows[i][j - 1] + 1,
5059
+ rows[i - 1][j - 1] + cost
5060
+ );
5061
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
5062
+ best = Math.min(best, rows[i - 2][j - 2] + 1);
5063
+ }
5064
+ rows[i][j] = best;
5065
+ }
5066
+ }
5067
+ return rows[a.length][b.length];
5068
+ }
5069
+ function detectTyposquat(name, ecosystem) {
5070
+ const popular = ecosystem === "npm" ? POPULAR_NPM : POPULAR_PYPI;
5071
+ const lower = name.toLowerCase().replace(/^@[^/]+\//, "");
5072
+ if (popular.includes(lower)) return null;
5073
+ if (lower.length < 4) return null;
5074
+ const normalized = normalizeName(lower);
5075
+ for (const candidate of popular) {
5076
+ const candidateNormalized = normalizeName(candidate);
5077
+ if (normalized === candidateNormalized) return { impersonates: candidate, kind: "separator" };
5078
+ if (editDistance(normalized, candidateNormalized, 1) === 1) {
5079
+ return { impersonates: candidate, kind: "edit" };
5080
+ }
5081
+ }
5082
+ return null;
5083
+ }
5084
+ var LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall", "prepare", "prepublish"];
5085
+ function scanPackageJson(text) {
5086
+ const findings = [];
5087
+ const lines = text.split("\n");
5088
+ let parsed;
5089
+ try {
5090
+ parsed = JSON.parse(text);
5091
+ } catch {
5092
+ return findings;
5093
+ }
5094
+ const lineOf = (needle) => {
5095
+ const index = lines.findIndex((line) => line.includes(`"${needle}"`));
5096
+ return index === -1 ? 1 : index + 1;
5097
+ };
5098
+ const depBuckets = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
5099
+ for (const bucket of depBuckets) {
5100
+ const deps = parsed[bucket];
5101
+ if (!deps || typeof deps !== "object") continue;
5102
+ for (const name of Object.keys(deps)) {
5103
+ const line = lineOf(name);
5104
+ if (INTERNAL_MARKER.test(name)) {
5105
+ findings.push({
5106
+ ruleId: "manifest-dependency-confusion",
5107
+ title: "internal-looking package resolved from a public registry",
5108
+ line,
5109
+ severity: "critical",
5110
+ cwe: "CWE-1357",
5111
+ message: `"${name}" names itself as internal but carries no registry pin`,
5112
+ consequence: "Whoever registers this name publicly first wins the resolution, and their code runs in your build.",
5113
+ excerpt: (lines[line - 1] ?? "").trim()
5114
+ });
5115
+ continue;
5116
+ }
5117
+ const squat = detectTyposquat(name, "npm");
5118
+ if (squat) {
5119
+ findings.push({
5120
+ ruleId: "manifest-typosquat",
5121
+ title: "dependency name close to a popular package",
5122
+ line,
5123
+ severity: "high",
5124
+ cwe: "CWE-1357",
5125
+ message: squat.kind === "separator" ? `"${name}" differs from "${squat.impersonates}" only in punctuation` : `"${name}" is one edit from "${squat.impersonates}"`,
5126
+ consequence: "A reviewer scanning a diff reads the name they expected. The package that installs is the one that was written.",
5127
+ excerpt: (lines[line - 1] ?? "").trim()
5128
+ });
5129
+ }
5130
+ }
5131
+ }
5132
+ const scripts = parsed.scripts;
5133
+ if (scripts && typeof scripts === "object") {
5134
+ for (const [name, body] of Object.entries(scripts)) {
5135
+ if (!LIFECYCLE_SCRIPTS.includes(name)) continue;
5136
+ findings.push({
5137
+ ruleId: "manifest-install-lifecycle-script",
5138
+ title: "install-time lifecycle script",
5139
+ line: lineOf(name),
5140
+ severity: "medium",
5141
+ cwe: "CWE-506",
5142
+ message: `"${name}" runs automatically on install: ${String(body).slice(0, 120)}`,
5143
+ consequence: "Lifecycle scripts run with the installing user\u2019s privileges and network access, before any code is reviewed. It is the execution vector every notable npm compromise has used.",
5144
+ excerpt: (lines[lineOf(name) - 1] ?? "").trim()
5145
+ });
5146
+ }
5147
+ }
5148
+ return findings;
5149
+ }
5150
+ function scanRequirementsTxt(text) {
5151
+ const findings = [];
5152
+ const lines = text.split("\n");
5153
+ lines.forEach((raw, index) => {
5154
+ const line = raw.trim();
5155
+ if (!line || line.startsWith("#") || line.startsWith("-")) return;
5156
+ const match = /^([A-Za-z0-9_.-]+)\s*(?:[=<>!~]=|@|$)/.exec(line);
5157
+ const name = match?.[1];
5158
+ if (!name) return;
5159
+ if (INTERNAL_MARKER.test(name)) {
5160
+ findings.push({
5161
+ ruleId: "manifest-dependency-confusion",
5162
+ title: "internal-looking package resolved from a public index",
5163
+ line: index + 1,
5164
+ severity: "critical",
5165
+ cwe: "CWE-1357",
5166
+ message: `"${name}" names itself as internal but carries no index pin`,
5167
+ consequence: "pip resolves the highest version across every configured index, so a public package of the same name shadows the private one.",
5168
+ excerpt: line
5169
+ });
5170
+ return;
5171
+ }
5172
+ const squat = detectTyposquat(name, "pypi");
5173
+ if (squat) {
5174
+ findings.push({
5175
+ ruleId: "manifest-typosquat",
5176
+ title: "dependency name close to a popular package",
5177
+ line: index + 1,
5178
+ severity: "high",
5179
+ cwe: "CWE-1357",
5180
+ message: squat.kind === "separator" ? `"${name}" differs from "${squat.impersonates}" only in punctuation` : `"${name}" is one edit from "${squat.impersonates}"`,
5181
+ consequence: "A reviewer scanning a diff reads the name they expected. The package that installs is the one that was written.",
5182
+ excerpt: line
5183
+ });
5184
+ }
5185
+ });
5186
+ return findings;
5187
+ }
5188
+
5189
+ // src/scan/secret-rules.ts
5190
+ var SECRET_RULES = [
5191
+ {
5192
+ id: "secret-aws-access-key",
5193
+ name: "AWS Access Key",
5194
+ pattern: /\b(?:AKIA|ASIA|ABIA|ACCA)[0-9A-Z]{16}\b/,
5195
+ severity: "critical",
5196
+ cwe: "CWE-798",
5197
+ consequence: "Paired with a secret key, grants the API access of whatever IAM principal issued it."
5198
+ },
5199
+ {
5200
+ id: "secret-aws-secret-key",
5201
+ name: "AWS Secret Access Key",
5202
+ pattern: /(?:aws_secret_access_key|AWS_SECRET(?:_ACCESS_KEY)?)\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})['"]?/i,
5203
+ severity: "critical",
5204
+ cwe: "CWE-798",
5205
+ consequence: "The other half of an AWS credential pair; on its own it is still the hard half to guess."
5206
+ },
5207
+ {
5208
+ id: "secret-github-token",
5209
+ name: "GitHub Token",
5210
+ pattern: /\b(?:ghp_[A-Za-z0-9]{36}|gho_[A-Za-z0-9]{36}|ghu_[A-Za-z0-9]{36}|ghs_[A-Za-z0-9]{36}|ghr_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{60,})\b/,
5211
+ severity: "critical",
5212
+ cwe: "CWE-798",
5213
+ consequence: "Repository read or write as the issuing account, including the ability to push workflow changes."
5214
+ },
5215
+ {
5216
+ id: "secret-npm-token",
5217
+ name: "npm Token",
5218
+ pattern: /\bnpm_[A-Za-z0-9]{36}\b/,
5219
+ severity: "critical",
5220
+ cwe: "CWE-798",
5221
+ consequence: "Publish rights to every package the account owns \u2014 a supply-chain compromise in one command."
5222
+ },
5223
+ {
5224
+ id: "secret-private-key",
5225
+ name: "Private Key",
5226
+ pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/,
5227
+ severity: "critical",
5228
+ cwe: "CWE-798",
5229
+ consequence: "Key material, committed. Rotation is the only remediation."
5230
+ },
5231
+ {
5232
+ id: "secret-slack-token",
5233
+ name: "Slack Token",
5234
+ pattern: /\bxox[bpoasr]-[A-Za-z0-9-]{10,}/,
5235
+ severity: "critical",
5236
+ cwe: "CWE-798",
5237
+ consequence: "Read and post access to the workspace as the installing app."
5238
+ },
5239
+ {
5240
+ id: "secret-slack-webhook",
5241
+ name: "Slack Webhook URL",
5242
+ pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_+\/-]{6,}/,
5243
+ severity: "high",
5244
+ cwe: "CWE-798",
5245
+ consequence: "The URL *is* the credential \u2014 anyone holding it can post to that channel."
5246
+ },
5247
+ {
5248
+ id: "secret-stripe-key",
5249
+ name: "Stripe Key",
5250
+ pattern: /\b(?:sk_live_|rk_live_|sk_test_|rk_test_)[A-Za-z0-9]{20,}\b/,
5251
+ severity: "critical",
5252
+ cwe: "CWE-798",
5253
+ consequence: "Charge, refund and customer-data access against the account."
5254
+ },
5255
+ {
5256
+ id: "secret-sendgrid-key",
5257
+ name: "SendGrid API Key",
5258
+ pattern: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/,
5259
+ severity: "critical",
5260
+ cwe: "CWE-798",
5261
+ consequence: "Send mail as the domain \u2014 the credential behind most convincing phishing from a real sender."
5262
+ },
5263
+ {
5264
+ id: "secret-google-api-key",
5265
+ name: "Google API Key",
5266
+ pattern: /\bAIza[0-9A-Za-z_-]{35}\b/,
5267
+ severity: "high",
5268
+ cwe: "CWE-798",
5269
+ consequence: "Quota theft at minimum; API access to whatever the key was scoped to at worst."
5270
+ },
5271
+ {
5272
+ id: "secret-openai-key",
5273
+ name: "OpenAI API Key",
5274
+ pattern: /\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b/,
5275
+ severity: "critical",
5276
+ cwe: "CWE-798",
5277
+ consequence: "Billed inference against the owner\u2019s account, with no per-key spend limit by default."
5278
+ },
5279
+ {
5280
+ id: "secret-anthropic-key",
5281
+ name: "Anthropic API Key",
5282
+ pattern: /\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{32,}\b/,
5283
+ severity: "critical",
5284
+ cwe: "CWE-798",
5285
+ consequence: "Billed inference against the owner\u2019s account."
5286
+ },
5287
+ {
5288
+ id: "secret-database-url",
5289
+ name: "Database URL with credentials",
5290
+ // Requires a credential segment before the `@` — `postgres://localhost/db`
5291
+ // is a hostname, not a secret.
5292
+ pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp|clickhouse):\/\/[^\s'"@\/]*:[^\s'"@\/]*@[^\s'"]+/i,
5293
+ severity: "high",
5294
+ cwe: "CWE-798",
5295
+ consequence: "Direct database access, usually bypassing every application-level authorisation check."
5296
+ },
5297
+ {
5298
+ id: "secret-jwt",
5299
+ name: "JSON Web Token",
5300
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_.+/=-]*/,
5301
+ severity: "medium",
5302
+ cwe: "CWE-798",
5303
+ consequence: "Session or service identity until it expires \u2014 and committed tokens are usually long-lived."
5304
+ },
5305
+ {
5306
+ id: "secret-generic-api-key",
5307
+ name: "Generic API Key",
5308
+ // Quoted assignment only. An unquoted value in a `.env` is covered by the
5309
+ // vendor-prefixed rules above; matching it here is what starts flagging
5310
+ // ARNs and parameter-store paths.
5311
+ pattern: /(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token)\s*[=:]\s*['"]([A-Za-z0-9\-_.]{20,})['"]/i,
5312
+ severity: "high",
5313
+ cwe: "CWE-798",
5314
+ consequence: "Whatever the third-party service lets the key do, for as long as it stays valid."
5315
+ },
5316
+ {
5317
+ id: "secret-generic-credential",
5318
+ name: "Hardcoded Credential",
5319
+ pattern: /(?:secret|password|passwd|pwd|token)\s*[=:]\s*['"]([^'"\s]{8,})['"]/i,
5320
+ severity: "high",
5321
+ cwe: "CWE-798",
5322
+ consequence: "A password in source is a password in every clone, fork and CI cache of that source."
5323
+ },
5324
+ {
5325
+ id: "secret-hex-token",
5326
+ name: "High-entropy Hex Token",
5327
+ pattern: /(?:token|key|secret|auth|signing)\w*\s*[=:]\s*['"]?([0-9a-f]{32,})['"]?/i,
5328
+ severity: "medium",
5329
+ cwe: "CWE-798",
5330
+ consequence: "Signing secrets and session keys are usually hex; a leaked one forges anything they sign."
5331
+ }
5332
+ ];
5333
+ var KNOWN_PLACEHOLDERS = [
5334
+ // Deliberately NOT here: AWS's published documentation key/secret pair
5335
+ // (`AKIAIOSFODNN7EXAMPLE`, `wJalrXUtnFEMI/…`). GitHub allow-lists them, and
5336
+ // the argument for following suit is that they authenticate nothing. The
5337
+ // argument against is stronger: they appear in a repository because someone
5338
+ // pasted a credentials template and left it there, and the remediation —
5339
+ // move this to the secret manager — is identical to the one for a live key.
5340
+ // Exempting them means the scanner goes quiet on the file most likely to
5341
+ // acquire a real key next.
5342
+ /\bEXAMPLE_?KEY\b/i,
5343
+ /\bYOUR_[A-Z_]*(?:KEY|TOKEN|SECRET|PASSWORD)\b/,
5344
+ /\b(?:xxx+|X{4,}|\*{4,}|<[a-z-]+>)\b/,
5345
+ /\bchangeme\b/i
5346
+ ];
5347
+ function isKnownPlaceholder(text) {
5348
+ return KNOWN_PLACEHOLDERS.some((pattern) => pattern.test(text));
5349
+ }
5350
+ function redactSecret(line) {
5351
+ return line.replace(/([A-Za-z0-9+/=_.-]{12,})/g, (match) => {
5352
+ if (match.length <= 12) return match;
5353
+ return `${match.slice(0, 3)}${"*".repeat(Math.min(16, match.length - 3))}`;
5354
+ });
5355
+ }
5356
+ var SENSITIVE_FILES = [
5357
+ { pattern: ".env", message: "Environment file committed \u2014 the usual home of every runtime credential", severity: "high" },
5358
+ { pattern: ".env.local", message: "Local environment file committed", severity: "high" },
5359
+ { pattern: ".env.production", message: "Production environment file committed", severity: "critical" },
5360
+ { pattern: "id_rsa", message: "Private SSH key committed", severity: "critical" },
5361
+ { pattern: "id_ed25519", message: "Private SSH key committed", severity: "critical" },
5362
+ { pattern: "id_ecdsa", message: "Private SSH key committed", severity: "critical" },
5363
+ { pattern: ".pem", message: "PEM certificate or key file committed", severity: "high" },
5364
+ { pattern: ".p12", message: "PKCS#12 keystore committed", severity: "high" },
5365
+ { pattern: ".pfx", message: "PKCS#12 keystore committed", severity: "high" },
5366
+ { pattern: ".keystore", message: "Java keystore committed", severity: "high" }
5367
+ // Deliberately not `.npmrc`. Its presence is normal; only an `_authToken`
5368
+ // line in it is a credential, and that is a content match, not a filename
5369
+ // match. Reporting the file itself trades a real finding for a chore.
5370
+ ];
5371
+
5372
+ // src/scan/engine.ts
5373
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
5374
+ "node_modules",
5375
+ ".git",
5376
+ ".next",
5377
+ ".nuxt",
5378
+ "dist",
5379
+ "build",
5380
+ "out",
5381
+ "__pycache__",
5382
+ ".venv",
5383
+ "venv",
5384
+ "vendor",
5385
+ ".terraform",
5386
+ "coverage",
5387
+ ".cache",
5388
+ ".pnpm-store",
5389
+ "target",
5390
+ ".gradle",
5391
+ ".idea",
5392
+ ".vscode",
5393
+ "bower_components",
5394
+ ".svelte-kit"
5395
+ ]);
5396
+ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
5397
+ ".ts",
5398
+ ".js",
5399
+ ".tsx",
5400
+ ".jsx",
5401
+ ".mjs",
5402
+ ".cjs",
5403
+ ".mts",
5404
+ ".cts",
5405
+ ".py",
5406
+ ".rb",
5407
+ ".go",
5408
+ ".java",
5409
+ ".kt",
5410
+ ".scala",
5411
+ ".php",
5412
+ ".rs",
5413
+ ".c",
5414
+ ".cc",
5415
+ ".cpp",
5416
+ ".h",
5417
+ ".hpp",
5418
+ ".cs",
5419
+ ".swift",
5420
+ ".yml",
3816
5421
  ".yaml",
3817
5422
  ".json",
3818
5423
  ".toml",
@@ -3822,103 +5427,318 @@ var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
3822
5427
  ".env",
3823
5428
  ".sh",
3824
5429
  ".bash",
5430
+ ".zsh",
3825
5431
  ".tf",
3826
5432
  ".hcl",
3827
5433
  ".xml",
3828
5434
  ".properties",
3829
- ".gradle"
5435
+ ".gradle",
5436
+ ".txt",
5437
+ ".md",
5438
+ ".sql",
5439
+ ".erb",
5440
+ ".ejs",
5441
+ ".vue",
5442
+ ".svelte"
3830
5443
  ]);
3831
- async function runScan(targetPath) {
5444
+ var LANGUAGE_BY_EXTENSION = {
5445
+ ".js": "javascript",
5446
+ ".jsx": "javascript",
5447
+ ".mjs": "javascript",
5448
+ ".cjs": "javascript",
5449
+ ".ts": "typescript",
5450
+ ".tsx": "typescript",
5451
+ ".mts": "typescript",
5452
+ ".cts": "typescript",
5453
+ ".vue": "javascript",
5454
+ ".svelte": "javascript",
5455
+ ".ejs": "javascript",
5456
+ ".py": "python",
5457
+ ".rb": "ruby",
5458
+ ".erb": "ruby",
5459
+ ".go": "go",
5460
+ ".java": "java",
5461
+ ".kt": "java",
5462
+ ".scala": "java",
5463
+ ".php": "php",
5464
+ ".sh": "shell",
5465
+ ".bash": "shell",
5466
+ ".zsh": "shell",
5467
+ ".yml": "config",
5468
+ ".yaml": "config",
5469
+ ".json": "config",
5470
+ ".toml": "config",
5471
+ ".ini": "config",
5472
+ ".cfg": "config",
5473
+ ".conf": "config",
5474
+ ".env": "config",
5475
+ ".tf": "config",
5476
+ ".hcl": "config",
5477
+ ".properties": "config"
5478
+ };
5479
+ function languageOf(filename) {
5480
+ if (filename.startsWith(".env") || filename.endsWith(".env")) return "config";
5481
+ return LANGUAGE_BY_EXTENSION[(0, import_node_path6.extname)(filename).toLowerCase()] ?? "other";
5482
+ }
5483
+ var SUPPRESS_NEXT = /threatcrush-disable-next-line(?:\s+([\w-]+))?/;
5484
+ var SUPPRESS_LINE = /threatcrush-disable-line(?:\s+([\w-]+))?/;
5485
+ function collectSuppressions(lines) {
5486
+ const byLine = /* @__PURE__ */ new Map();
5487
+ let count = 0;
5488
+ const add = (index, ruleId) => {
5489
+ const existing = byLine.get(index) ?? /* @__PURE__ */ new Set();
5490
+ existing.add(ruleId ?? "*");
5491
+ byLine.set(index, existing);
5492
+ count += 1;
5493
+ };
5494
+ lines.forEach((line, index) => {
5495
+ const next = SUPPRESS_NEXT.exec(line);
5496
+ if (next) add(index + 1, next[1]);
5497
+ const same = SUPPRESS_LINE.exec(line);
5498
+ if (same && !next) add(index, same[1]);
5499
+ });
5500
+ return { byLine, count };
5501
+ }
5502
+ function isSuppressed(suppressions, index, ruleId) {
5503
+ const rules = suppressions.byLine.get(index);
5504
+ if (!rules) return false;
5505
+ return rules.has("*") || rules.has(ruleId);
5506
+ }
5507
+ function scanText(relativePath, text, language = languageOf(relativePath)) {
3832
5508
  const findings = [];
3833
- try {
3834
- scanDirectory(targetPath, targetPath, findings, () => {
5509
+ const lines = text.split("\n");
5510
+ const suppressions = collectSuppressions(lines);
5511
+ lines.forEach((line, index) => {
5512
+ for (const rule of SECRET_RULES) {
5513
+ const match = rule.pattern.exec(line);
5514
+ if (!match) continue;
5515
+ if (isKnownPlaceholder(match[0])) continue;
5516
+ if (isSuppressed(suppressions, index, rule.id)) continue;
5517
+ findings.push({
5518
+ ruleId: rule.id,
5519
+ title: rule.name,
5520
+ file: relativePath,
5521
+ line: index + 1,
5522
+ severity: rule.severity,
5523
+ // A matched credential format is the finding, not a proxy for one.
5524
+ confidence: "evidence",
5525
+ message: `Possible ${rule.name} detected`,
5526
+ consequence: rule.consequence,
5527
+ cwe: rule.cwe,
5528
+ excerpt: redactSecret(line.trim()).slice(0, 200),
5529
+ sensitive: true,
5530
+ category: "secret"
5531
+ });
5532
+ }
5533
+ });
5534
+ const prose = proseLines(lines);
5535
+ lines.forEach((_line, index) => {
5536
+ for (const rule of CODE_RULES) {
5537
+ if (isSuppressed(suppressions, index, rule.id)) continue;
5538
+ const match = evaluateRule(rule, { lines, index, language, prose });
5539
+ if (!match) continue;
5540
+ findings.push({
5541
+ ruleId: rule.id,
5542
+ title: rule.title,
5543
+ file: relativePath,
5544
+ line: index + 1,
5545
+ severity: match.severity,
5546
+ confidence: match.confidence,
5547
+ message: `${rule.title} (${rule.cwe})`,
5548
+ consequence: rule.consequence,
5549
+ cwe: rule.cwe,
5550
+ excerpt: (lines[index] ?? "").trim().slice(0, 200),
5551
+ category: "code"
5552
+ });
5553
+ }
5554
+ });
5555
+ return findings;
5556
+ }
5557
+ function scanManifest(relativePath, filename, text) {
5558
+ const manifestFindings = filename === "package.json" ? scanPackageJson(text) : filename === "requirements.txt" ? scanRequirementsTxt(text) : [];
5559
+ return manifestFindings.map((finding) => ({
5560
+ ruleId: finding.ruleId,
5561
+ title: finding.title,
5562
+ file: relativePath,
5563
+ line: finding.line,
5564
+ severity: finding.severity,
5565
+ confidence: "evidence",
5566
+ message: finding.message,
5567
+ consequence: finding.consequence,
5568
+ cwe: finding.cwe,
5569
+ excerpt: finding.excerpt.slice(0, 200),
5570
+ category: "manifest"
5571
+ }));
5572
+ }
5573
+ function scanPath(targetPath, options = {}) {
5574
+ const maxFileBytes = options.maxFileBytes ?? 1024 * 1024;
5575
+ const allowed = options.categories ? new Set(options.categories) : null;
5576
+ const findings = [];
5577
+ const unreadable = [];
5578
+ let filesScanned = 0;
5579
+ let suppressed = 0;
5580
+ const rootIsDirectory = (() => {
5581
+ try {
5582
+ return (0, import_node_fs11.statSync)(targetPath).isDirectory();
5583
+ } catch {
5584
+ return true;
5585
+ }
5586
+ })();
5587
+ const walkRoot = rootIsDirectory ? targetPath : (0, import_node_path6.dirname)(targetPath);
5588
+ const scanFile = (fullPath, filename) => {
5589
+ const relativePath = toRelative(walkRoot, fullPath);
5590
+ const extension = (0, import_node_path6.extname)(filename).toLowerCase();
5591
+ const isManifest = filename === "package.json" || filename === "requirements.txt";
5592
+ const scannable = SCAN_EXTENSIONS.has(extension) || filename.startsWith(".env");
5593
+ if (!scannable && !isManifest) {
5594
+ recordSensitiveFile(filename, relativePath, findings, []);
5595
+ return;
5596
+ }
5597
+ let text;
5598
+ let handle;
5599
+ try {
5600
+ handle = (0, import_node_fs11.openSync)(fullPath, "r");
5601
+ } catch {
5602
+ unreadable.push(relativePath);
5603
+ return;
5604
+ }
5605
+ try {
5606
+ if ((0, import_node_fs11.fstatSync)(handle).size > maxFileBytes) return;
5607
+ text = (0, import_node_fs11.readFileSync)(handle, "utf-8");
5608
+ } catch {
5609
+ unreadable.push(relativePath);
5610
+ return;
5611
+ } finally {
5612
+ try {
5613
+ (0, import_node_fs11.closeSync)(handle);
5614
+ } catch {
5615
+ }
5616
+ }
5617
+ filesScanned += 1;
5618
+ options.onFile?.(relativePath);
5619
+ suppressed += collectSuppressions(text.split("\n")).count;
5620
+ const fileFindings = [
5621
+ ...scanText(relativePath, text, languageOf(filename)),
5622
+ ...isManifest ? scanManifest(relativePath, filename, text) : []
5623
+ ];
5624
+ findings.push(...fileFindings);
5625
+ recordSensitiveFile(filename, relativePath, findings, fileFindings);
5626
+ };
5627
+ const walk = (currentPath) => {
5628
+ let entries;
5629
+ try {
5630
+ entries = (0, import_node_fs11.readdirSync)(currentPath, { withFileTypes: true });
5631
+ } catch {
5632
+ unreadable.push(toRelative(walkRoot, currentPath));
5633
+ return;
5634
+ }
5635
+ for (const entry of entries) {
5636
+ const fullPath = (0, import_node_path6.join)(currentPath, entry.name);
5637
+ if (entry.isDirectory()) {
5638
+ if (SKIP_DIRS.has(entry.name)) continue;
5639
+ walk(fullPath);
5640
+ continue;
5641
+ }
5642
+ if (!entry.isFile()) continue;
5643
+ scanFile(fullPath, entry.name);
5644
+ }
5645
+ };
5646
+ if (rootIsDirectory) {
5647
+ walk(targetPath);
5648
+ } else {
5649
+ scanFile(targetPath, (0, import_node_path6.basename)(targetPath));
5650
+ }
5651
+ const filtered = allowed ? findings.filter((f) => allowed.has(f.category)) : findings;
5652
+ filtered.sort(
5653
+ (a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line
5654
+ );
5655
+ return { findings: filtered, filesScanned, unreadable, suppressed, root: walkRoot };
5656
+ }
5657
+ function recordSensitiveFile(filename, relativePath, sink, fileFindings) {
5658
+ if (fileFindings.length > 0) return;
5659
+ for (const sensitive of SENSITIVE_FILES) {
5660
+ const matches = filename === sensitive.pattern || filename.endsWith(sensitive.pattern);
5661
+ if (!matches) continue;
5662
+ sink.push({
5663
+ ruleId: "sensitive-file-committed",
5664
+ title: "Sensitive file",
5665
+ file: relativePath,
5666
+ line: 1,
5667
+ severity: sensitive.severity,
5668
+ confidence: "evidence",
5669
+ message: sensitive.message,
5670
+ consequence: "Anything in this file is in every clone, fork and CI cache of the repository.",
5671
+ cwe: "CWE-538",
5672
+ excerpt: "",
5673
+ sensitive: true,
5674
+ category: "file"
3835
5675
  });
3836
- } catch (err) {
3837
- return {
3838
- type: "scan",
3839
- target: targetPath,
3840
- findings: [],
3841
- severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
3842
- summary: `Scan failed: ${err.message}`,
3843
- error: err.message
3844
- };
5676
+ return;
5677
+ }
5678
+ }
5679
+ function toRelative(base, target) {
5680
+ const rel = (0, import_node_path6.relative)(base, target);
5681
+ return (rel === "" ? target : rel).split(import_node_path6.sep).join("/");
5682
+ }
5683
+
5684
+ // src/scan/sarif.ts
5685
+ var import_node_path7 = require("path");
5686
+
5687
+ // src/commands/scan.ts
5688
+ function readVersion() {
5689
+ for (const candidate of [
5690
+ (0, import_node_path8.join)(__dirname, "..", "package.json"),
5691
+ (0, import_node_path8.join)(__dirname, "..", "..", "package.json")
5692
+ ]) {
5693
+ try {
5694
+ return JSON.parse((0, import_node_fs12.readFileSync)(candidate, "utf-8")).version ?? "0.0.0";
5695
+ } catch {
5696
+ }
3845
5697
  }
3846
- const structured = findings.map((f) => ({
3847
- type: f.type,
3848
- severity: f.severity,
3849
- message: f.message,
3850
- location: `${f.file}:${f.line}`,
3851
- details: { file: f.file, line: f.line, snippet: f.snippet }
5698
+ return "0.0.0";
5699
+ }
5700
+ var PKG_VERSION = readVersion();
5701
+ function toRunResult(targetPath, findings, filesScanned) {
5702
+ const structured = findings.map((finding) => ({
5703
+ type: finding.title,
5704
+ severity: finding.severity,
5705
+ message: finding.message,
5706
+ location: `${finding.file}:${finding.line}`,
5707
+ details: {
5708
+ file: finding.file,
5709
+ line: finding.line,
5710
+ snippet: finding.excerpt,
5711
+ ruleId: finding.ruleId,
5712
+ confidence: finding.confidence,
5713
+ ...finding.cwe ? { cwe: finding.cwe } : {}
5714
+ }
3852
5715
  }));
3853
- const summary = summarize(structured);
5716
+ const counts = summarize(structured);
3854
5717
  return {
3855
5718
  type: "scan",
3856
5719
  target: targetPath,
3857
5720
  findings: structured,
3858
- severity_summary: summary,
3859
- summary: findings.length === 0 ? "No security issues found" : `${findings.length} issue(s): ${summary.critical}C ${summary.high}H ${summary.medium}M ${summary.low}L`
5721
+ severity_summary: counts,
5722
+ summary: findings.length === 0 ? `No issues found across ${filesScanned} files` : `${findings.length} issue(s): ${counts.critical}C ${counts.high}H ${counts.medium}M ${counts.low}L`
3860
5723
  };
3861
5724
  }
3862
- function scanDirectory(basePath, currentPath, findings, onFile) {
3863
- let entries;
5725
+ function failedResult(targetPath, message) {
5726
+ return {
5727
+ type: "scan",
5728
+ target: targetPath,
5729
+ findings: [],
5730
+ severity_summary: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
5731
+ summary: `Scan failed: ${message}`,
5732
+ error: message
5733
+ };
5734
+ }
5735
+ async function runScan(targetPath) {
3864
5736
  try {
3865
- entries = (0, import_node_fs8.readdirSync)(currentPath, { withFileTypes: true });
3866
- } catch {
3867
- return;
3868
- }
3869
- for (const entry of entries) {
3870
- const fullPath = (0, import_node_path5.join)(currentPath, entry.name);
3871
- if (entry.isDirectory()) {
3872
- if (SKIP_DIRS.has(entry.name)) continue;
3873
- scanDirectory(basePath, fullPath, findings, onFile);
3874
- continue;
3875
- }
3876
- if (!entry.isFile()) continue;
3877
- for (const mc of MISCONFIG_FILES) {
3878
- if (entry.name === mc.pattern || entry.name.endsWith(mc.pattern)) {
3879
- findings.push({
3880
- file: (0, import_node_path5.relative)(basePath, fullPath),
3881
- line: 0,
3882
- type: "Sensitive File",
3883
- severity: "high",
3884
- message: mc.message,
3885
- snippet: ""
3886
- });
3887
- }
3888
- }
3889
- const ext = (0, import_node_path5.extname)(entry.name).toLowerCase();
3890
- if (!SCAN_EXTENSIONS.has(ext) && !entry.name.startsWith(".env")) continue;
3891
- try {
3892
- const stat = (0, import_node_fs8.statSync)(fullPath);
3893
- if (stat.size > 1024 * 1024) continue;
3894
- } catch {
3895
- continue;
3896
- }
3897
- onFile();
3898
- let content;
3899
- try {
3900
- content = (0, import_node_fs8.readFileSync)(fullPath, "utf-8");
3901
- } catch {
3902
- continue;
3903
- }
3904
- const lines = content.split("\n");
3905
- for (let i = 0; i < lines.length; i++) {
3906
- const line = lines[i];
3907
- if (line.trim().startsWith("//") && !line.includes("password") && !line.includes("secret")) continue;
3908
- for (const pattern of SECRET_PATTERNS) {
3909
- pattern.pattern.lastIndex = 0;
3910
- if (pattern.pattern.test(line)) {
3911
- findings.push({
3912
- file: (0, import_node_path5.relative)(basePath, fullPath),
3913
- line: i + 1,
3914
- type: pattern.name,
3915
- severity: pattern.severity,
3916
- message: `Possible ${pattern.name} detected`,
3917
- snippet: line.length > 120 ? line.slice(0, 120) + "..." : line
3918
- });
3919
- }
3920
- }
3921
- }
5737
+ const report = scanPath(targetPath);
5738
+ const findings = [...report.findings, ...await scanDependencies(targetPath)];
5739
+ return toRunResult(targetPath, findings, report.filesScanned);
5740
+ } catch (err) {
5741
+ return failedResult(targetPath, err.message);
3922
5742
  }
3923
5743
  }
3924
5744
 
@@ -3965,6 +5785,39 @@ var PENTEST_CHECKS = [
3965
5785
  test: (html) => /stack trace|traceback|exception|at\s+\w+\.\w+\(/i.test(html),
3966
5786
  severity: "medium",
3967
5787
  message: "Error page reveals internal information"
5788
+ },
5789
+ // PRD 07: Additional checks
5790
+ {
5791
+ name: "CORS Misconfiguration",
5792
+ test: (_url, _body, headers) => {
5793
+ const acao = headers["access-control-allow-origin"];
5794
+ return acao === "*" || acao === "null";
5795
+ },
5796
+ severity: "medium",
5797
+ message: "CORS allows any origin (Access-Control-Allow-Origin: *)"
5798
+ },
5799
+ {
5800
+ name: "Cookie Security",
5801
+ test: (_url, _body, headers) => {
5802
+ const setCookie = headers["set-cookie"] || "";
5803
+ return setCookie.length > 0 && (!setCookie.includes("HttpOnly") || !setCookie.includes("Secure"));
5804
+ },
5805
+ severity: "medium",
5806
+ message: "Cookies missing HttpOnly or Secure flags"
5807
+ },
5808
+ {
5809
+ name: "Content Security Policy",
5810
+ test: (_url, _body, headers) => {
5811
+ return !headers["content-security-policy"];
5812
+ },
5813
+ severity: "low",
5814
+ message: "No Content-Security-Policy header set"
5815
+ },
5816
+ {
5817
+ name: "Sensitive Path Exposure",
5818
+ test: (html) => /\.env|wp-admin|phpinfo|\.git\/config|server-status/i.test(html),
5819
+ severity: "high",
5820
+ message: "Response references sensitive paths or admin endpoints"
3968
5821
  }
3969
5822
  ];
3970
5823
  async function runPentest(rawUrl) {
@@ -4187,6 +6040,732 @@ var RunsWorker = class {
4187
6040
  }
4188
6041
  };
4189
6042
 
6043
+ // src/daemon/rules/engine.ts
6044
+ var RuleEngine = class {
6045
+ constructor(onDetection) {
6046
+ this.onDetection = onDetection;
6047
+ }
6048
+ onDetection;
6049
+ rules = [];
6050
+ windows = /* @__PURE__ */ new Map();
6051
+ loadRules(rules) {
6052
+ this.rules = rules.filter((r) => r.enabled !== false);
6053
+ }
6054
+ getRules() {
6055
+ return [...this.rules];
6056
+ }
6057
+ evaluate(event) {
6058
+ const now = Date.now();
6059
+ for (const rule of this.rules) {
6060
+ if (rule.source_types.length > 0 && !rule.source_types.includes(event.module) && !rule.source_types.includes(event.category)) {
6061
+ continue;
6062
+ }
6063
+ if (!this.matchesCondition(event, rule.match)) continue;
6064
+ const windowKey = `${rule.id}:${event.source_ip || "global"}`;
6065
+ let window = this.windows.get(windowKey);
6066
+ if (!window) {
6067
+ window = { events: [], lastAlert: 0 };
6068
+ this.windows.set(windowKey, window);
6069
+ }
6070
+ window.events.push({ timestamp: now, event });
6071
+ const cutoff = now - rule.window_seconds * 1e3;
6072
+ window.events = window.events.filter((e) => e.timestamp >= cutoff);
6073
+ if (window.events.length < rule.threshold) continue;
6074
+ if (window.lastAlert > 0 && now - window.lastAlert < rule.cooldown_seconds * 1e3) continue;
6075
+ window.lastAlert = now;
6076
+ window.events = [];
6077
+ this.onDetection({
6078
+ rule_id: rule.id,
6079
+ severity: rule.severity,
6080
+ title: rule.title,
6081
+ description: `${rule.description} (${rule.threshold} events in ${rule.window_seconds}s)`,
6082
+ source_ip: event.source_ip,
6083
+ username: event.details?.user || void 0,
6084
+ raw_metadata: {
6085
+ rule_version: rule.version,
6086
+ tags: rule.tags,
6087
+ category: rule.category,
6088
+ remediation: rule.remediation
6089
+ }
6090
+ });
6091
+ }
6092
+ }
6093
+ matchesCondition(event, match) {
6094
+ const fieldValue = this.getFieldValue(event, match.field);
6095
+ if (fieldValue === void 0) return false;
6096
+ const strValue = String(fieldValue);
6097
+ let result = false;
6098
+ switch (match.operator) {
6099
+ case "contains":
6100
+ result = strValue.toLowerCase().includes(String(match.value).toLowerCase());
6101
+ break;
6102
+ case "regex":
6103
+ try {
6104
+ result = new RegExp(String(match.value), "i").test(strValue);
6105
+ } catch {
6106
+ result = false;
6107
+ }
6108
+ break;
6109
+ case "equals":
6110
+ result = strValue === String(match.value);
6111
+ break;
6112
+ case "starts_with":
6113
+ result = strValue.startsWith(String(match.value));
6114
+ break;
6115
+ case "ends_with":
6116
+ result = strValue.endsWith(String(match.value));
6117
+ break;
6118
+ }
6119
+ if (result && match.and) {
6120
+ result = match.and.every((m) => this.matchesCondition(event, m));
6121
+ }
6122
+ if (!result && match.or) {
6123
+ result = match.or.some((m) => this.matchesCondition(event, m));
6124
+ }
6125
+ return result;
6126
+ }
6127
+ getFieldValue(event, field) {
6128
+ switch (field) {
6129
+ case "message":
6130
+ return event.message;
6131
+ case "severity":
6132
+ return event.severity;
6133
+ case "module":
6134
+ return event.module;
6135
+ case "category":
6136
+ return event.category;
6137
+ case "source_ip":
6138
+ return event.source_ip;
6139
+ default:
6140
+ return event.details?.[field];
6141
+ }
6142
+ }
6143
+ // Periodic cleanup of stale windows
6144
+ cleanup() {
6145
+ const now = Date.now();
6146
+ for (const [key, window] of this.windows.entries()) {
6147
+ if (window.events.length === 0 && now - window.lastAlert > 36e5) {
6148
+ this.windows.delete(key);
6149
+ }
6150
+ }
6151
+ }
6152
+ };
6153
+
6154
+ // src/daemon/rules/loader.ts
6155
+ var import_node_fs13 = require("fs");
6156
+ var import_node_path9 = require("path");
6157
+
6158
+ // src/daemon/rules/default-rules.ts
6159
+ var DEFAULT_RULES = [
6160
+ {
6161
+ id: "ssh-brute-force",
6162
+ title: "SSH Brute Force Detected",
6163
+ description: "Multiple failed SSH login attempts from the same source",
6164
+ version: "1.0.0",
6165
+ category: "auth",
6166
+ severity: "high",
6167
+ source_types: ["ssh-guard", "auth"],
6168
+ match: {
6169
+ field: "message",
6170
+ operator: "regex",
6171
+ value: "failed ssh login|invalid ssh user"
6172
+ },
6173
+ threshold: 5,
6174
+ window_seconds: 300,
6175
+ cooldown_seconds: 600,
6176
+ tags: ["ssh", "brute-force", "credential-stuffing"],
6177
+ remediation: {
6178
+ action: "block",
6179
+ ttl_seconds: 3600,
6180
+ description: "Block source IP for 1 hour"
6181
+ },
6182
+ enabled: true
6183
+ },
6184
+ {
6185
+ id: "ssh-success-after-failures",
6186
+ title: "SSH Login After Failed Attempts",
6187
+ description: "Successful SSH login from an IP that had recent failures",
6188
+ version: "1.0.0",
6189
+ category: "auth",
6190
+ severity: "critical",
6191
+ source_types: ["ssh-guard", "auth"],
6192
+ match: {
6193
+ field: "message",
6194
+ operator: "contains",
6195
+ value: "SSH login accepted"
6196
+ },
6197
+ threshold: 1,
6198
+ window_seconds: 60,
6199
+ cooldown_seconds: 300,
6200
+ tags: ["ssh", "compromise-indicator"],
6201
+ enabled: true
6202
+ },
6203
+ {
6204
+ id: "ssh-root-login",
6205
+ title: "Root SSH Login Attempt",
6206
+ description: "Direct root login via SSH detected",
6207
+ version: "1.0.0",
6208
+ category: "auth",
6209
+ severity: "high",
6210
+ source_types: ["ssh-guard", "auth"],
6211
+ match: {
6212
+ field: "message",
6213
+ operator: "regex",
6214
+ value: "(failed|accepted).*\\broot\\b"
6215
+ },
6216
+ threshold: 1,
6217
+ window_seconds: 60,
6218
+ cooldown_seconds: 300,
6219
+ tags: ["ssh", "root-access"],
6220
+ remediation: {
6221
+ action: "block",
6222
+ ttl_seconds: 7200,
6223
+ description: "Block source IP attempting root login"
6224
+ },
6225
+ enabled: true
6226
+ },
6227
+ {
6228
+ id: "ssh-user-enumeration",
6229
+ title: "SSH User Enumeration",
6230
+ description: "Multiple SSH attempts with different usernames from same source",
6231
+ version: "1.0.0",
6232
+ category: "auth",
6233
+ severity: "high",
6234
+ source_types: ["ssh-guard", "auth"],
6235
+ match: {
6236
+ field: "message",
6237
+ operator: "contains",
6238
+ value: "Invalid SSH user"
6239
+ },
6240
+ threshold: 3,
6241
+ window_seconds: 120,
6242
+ cooldown_seconds: 600,
6243
+ tags: ["ssh", "enumeration", "reconnaissance"],
6244
+ remediation: {
6245
+ action: "block",
6246
+ ttl_seconds: 3600,
6247
+ description: "Block source IP performing user enumeration"
6248
+ },
6249
+ enabled: true
6250
+ },
6251
+ {
6252
+ id: "sudo-abuse",
6253
+ title: "Sudo Authentication Failure",
6254
+ description: "Repeated sudo authentication failures",
6255
+ version: "1.0.0",
6256
+ category: "auth",
6257
+ severity: "high",
6258
+ source_types: ["user-journal", "system"],
6259
+ match: {
6260
+ field: "message",
6261
+ operator: "regex",
6262
+ value: "sudo.*authentication failure|sudo.*incorrect password|sudo.*FAILED"
6263
+ },
6264
+ threshold: 3,
6265
+ window_seconds: 300,
6266
+ cooldown_seconds: 600,
6267
+ tags: ["sudo", "privilege-escalation"],
6268
+ enabled: true
6269
+ },
6270
+ {
6271
+ id: "web-sqli-attack",
6272
+ title: "SQL Injection Attack Detected",
6273
+ description: "HTTP request with SQL injection patterns",
6274
+ version: "1.0.0",
6275
+ category: "web",
6276
+ severity: "critical",
6277
+ source_types: ["log-watcher", "web"],
6278
+ match: {
6279
+ field: "message",
6280
+ operator: "contains",
6281
+ value: "Attack detected [SQLI]"
6282
+ },
6283
+ threshold: 1,
6284
+ window_seconds: 60,
6285
+ cooldown_seconds: 300,
6286
+ tags: ["web", "sqli", "injection"],
6287
+ remediation: {
6288
+ action: "block",
6289
+ ttl_seconds: 3600,
6290
+ description: "Block source IP performing SQL injection"
6291
+ },
6292
+ enabled: true
6293
+ },
6294
+ {
6295
+ id: "web-path-traversal",
6296
+ title: "Path Traversal Attack Detected",
6297
+ description: "HTTP request with path traversal patterns",
6298
+ version: "1.0.0",
6299
+ category: "web",
6300
+ severity: "critical",
6301
+ source_types: ["log-watcher", "web"],
6302
+ match: {
6303
+ field: "message",
6304
+ operator: "contains",
6305
+ value: "Attack detected [PATH_TRAVERSAL]"
6306
+ },
6307
+ threshold: 1,
6308
+ window_seconds: 60,
6309
+ cooldown_seconds: 300,
6310
+ tags: ["web", "path-traversal", "lfi"],
6311
+ remediation: {
6312
+ action: "block",
6313
+ ttl_seconds: 3600,
6314
+ description: "Block source IP performing path traversal"
6315
+ },
6316
+ enabled: true
6317
+ },
6318
+ {
6319
+ id: "web-xss-attack",
6320
+ title: "XSS Attack Detected",
6321
+ description: "HTTP request with cross-site scripting patterns",
6322
+ version: "1.0.0",
6323
+ category: "web",
6324
+ severity: "high",
6325
+ source_types: ["log-watcher", "web"],
6326
+ match: {
6327
+ field: "message",
6328
+ operator: "regex",
6329
+ value: "Attack detected \\[XSS\\]"
6330
+ },
6331
+ threshold: 1,
6332
+ window_seconds: 60,
6333
+ cooldown_seconds: 300,
6334
+ tags: ["web", "xss", "injection"],
6335
+ remediation: {
6336
+ action: "block",
6337
+ ttl_seconds: 3600,
6338
+ description: "Block source IP performing XSS attack"
6339
+ },
6340
+ enabled: true
6341
+ },
6342
+ {
6343
+ id: "web-scanner-detection",
6344
+ title: "Web Vulnerability Scanner Detected",
6345
+ description: "High volume of 4xx errors suggesting automated scanning",
6346
+ version: "1.0.0",
6347
+ category: "web",
6348
+ severity: "medium",
6349
+ source_types: ["log-watcher", "web"],
6350
+ match: {
6351
+ field: "message",
6352
+ operator: "regex",
6353
+ value: "Client error 4\\d{2}:"
6354
+ },
6355
+ threshold: 20,
6356
+ window_seconds: 60,
6357
+ cooldown_seconds: 600,
6358
+ tags: ["web", "scanner", "reconnaissance"],
6359
+ remediation: {
6360
+ action: "block",
6361
+ ttl_seconds: 1800,
6362
+ description: "Block automated scanner"
6363
+ },
6364
+ enabled: true
6365
+ },
6366
+ {
6367
+ id: "port-scan-indicator",
6368
+ title: "Port Scan Indicators",
6369
+ description: "Connection attempts to many ports from a single source",
6370
+ version: "1.0.0",
6371
+ category: "network",
6372
+ severity: "medium",
6373
+ source_types: ["network-monitor", "network"],
6374
+ match: {
6375
+ field: "message",
6376
+ operator: "contains",
6377
+ value: "port scan"
6378
+ },
6379
+ threshold: 1,
6380
+ window_seconds: 60,
6381
+ cooldown_seconds: 300,
6382
+ tags: ["network", "port-scan", "reconnaissance"],
6383
+ remediation: {
6384
+ action: "block",
6385
+ ttl_seconds: 3600,
6386
+ description: "Block port scanner"
6387
+ },
6388
+ enabled: true
6389
+ },
6390
+ {
6391
+ id: "system-critical-error",
6392
+ title: "Critical System Error",
6393
+ description: "Critical or emergency level system log message",
6394
+ version: "1.0.0",
6395
+ category: "system",
6396
+ severity: "critical",
6397
+ source_types: ["user-journal", "system"],
6398
+ match: {
6399
+ field: "severity",
6400
+ operator: "equals",
6401
+ value: "critical"
6402
+ },
6403
+ threshold: 1,
6404
+ window_seconds: 60,
6405
+ cooldown_seconds: 300,
6406
+ tags: ["system", "critical"],
6407
+ enabled: true
6408
+ },
6409
+ {
6410
+ id: "exploit-probe-pattern",
6411
+ title: "Exploit Probe Pattern",
6412
+ description: "HTTP requests matching common exploit probe patterns",
6413
+ version: "1.0.0",
6414
+ category: "web",
6415
+ severity: "high",
6416
+ source_types: ["log-watcher", "web"],
6417
+ match: {
6418
+ field: "message",
6419
+ operator: "regex",
6420
+ value: "Attack detected \\[(CMD_INJECTION|RCE|SSRF|XXE)\\]"
6421
+ },
6422
+ threshold: 1,
6423
+ window_seconds: 60,
6424
+ cooldown_seconds: 300,
6425
+ tags: ["web", "exploit", "probe"],
6426
+ remediation: {
6427
+ action: "block",
6428
+ ttl_seconds: 7200,
6429
+ description: "Block source IP performing exploit probes"
6430
+ },
6431
+ enabled: true
6432
+ }
6433
+ ];
6434
+
6435
+ // src/daemon/rules/loader.ts
6436
+ var RULES_DIR = "/etc/threatcrush/rules.d";
6437
+ function loadAllRules(customDir) {
6438
+ const rules = [...DEFAULT_RULES];
6439
+ const dir = customDir || RULES_DIR;
6440
+ if ((0, import_node_fs13.existsSync)(dir)) {
6441
+ const files = (0, import_node_fs13.readdirSync)(dir).filter((f) => f.endsWith(".json"));
6442
+ for (const file of files) {
6443
+ try {
6444
+ const raw = (0, import_node_fs13.readFileSync)((0, import_node_path9.join)(dir, file), "utf-8");
6445
+ const parsed = JSON.parse(raw);
6446
+ const customRules = Array.isArray(parsed) ? parsed : [parsed];
6447
+ for (const rule of customRules) {
6448
+ if (!rule.id || !rule.title || !rule.match) {
6449
+ console.warn(`[rules] skipping invalid rule in ${file}: missing required fields`);
6450
+ continue;
6451
+ }
6452
+ const existingIdx = rules.findIndex((r) => r.id === rule.id);
6453
+ if (existingIdx >= 0) {
6454
+ rules[existingIdx] = { ...rules[existingIdx], ...rule };
6455
+ } else {
6456
+ rules.push(rule);
6457
+ }
6458
+ }
6459
+ } catch (err) {
6460
+ console.warn(`[rules] failed to load ${file}: ${err.message}`);
6461
+ }
6462
+ }
6463
+ }
6464
+ return rules;
6465
+ }
6466
+
6467
+ // src/daemon/firewall/adapters.ts
6468
+ var import_node_child_process3 = require("child_process");
6469
+ var import_node_net2 = require("net");
6470
+ function assertValidFirewallIp(ip) {
6471
+ if ((0, import_node_net2.isIP)(ip) !== 4) {
6472
+ throw new Error(`Invalid IPv4 address: ${ip}`);
6473
+ }
6474
+ }
6475
+ var NftablesAdapter = class {
6476
+ name = "nftables";
6477
+ table = "threatcrush";
6478
+ set = "blocklist";
6479
+ isAvailable() {
6480
+ const result = (0, import_node_child_process3.spawnSync)("nft", ["--version"], { stdio: "pipe" });
6481
+ return result.status === 0;
6482
+ }
6483
+ ensureSetup() {
6484
+ try {
6485
+ (0, import_node_child_process3.execSync)(`nft list table inet ${this.table} 2>/dev/null`, { stdio: "pipe" });
6486
+ } catch {
6487
+ (0, import_node_child_process3.execSync)(`nft add table inet ${this.table}`);
6488
+ (0, import_node_child_process3.execSync)(`nft add set inet ${this.table} ${this.set} '{ type ipv4_addr; flags timeout; }'`);
6489
+ (0, import_node_child_process3.execSync)(`nft add chain inet ${this.table} input '{ type filter hook input priority -1; policy accept; }'`);
6490
+ (0, import_node_child_process3.execSync)(`nft add rule inet ${this.table} input ip saddr @${this.set} drop`);
6491
+ }
6492
+ }
6493
+ async block(ip) {
6494
+ assertValidFirewallIp(ip);
6495
+ this.ensureSetup();
6496
+ (0, import_node_child_process3.execSync)(`nft add element inet ${this.table} ${this.set} '{ ${ip} }'`);
6497
+ }
6498
+ async unblock(ip) {
6499
+ assertValidFirewallIp(ip);
6500
+ try {
6501
+ (0, import_node_child_process3.execSync)(`nft delete element inet ${this.table} ${this.set} '{ ${ip} }'`);
6502
+ } catch {
6503
+ }
6504
+ }
6505
+ async isBlocked(ip) {
6506
+ assertValidFirewallIp(ip);
6507
+ try {
6508
+ const output = (0, import_node_child_process3.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
6509
+ return output.includes(ip);
6510
+ } catch {
6511
+ return false;
6512
+ }
6513
+ }
6514
+ async listBlocked() {
6515
+ try {
6516
+ const output = (0, import_node_child_process3.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
6517
+ const match = output.match(/elements\s*=\s*\{([^}]*)\}/);
6518
+ if (!match) return [];
6519
+ return match[1].split(",").map((s) => s.trim().split(/\s/)[0]).filter(Boolean);
6520
+ } catch {
6521
+ return [];
6522
+ }
6523
+ }
6524
+ };
6525
+ var IptablesAdapter = class {
6526
+ name = "iptables";
6527
+ chain = "THREATCRUSH";
6528
+ isAvailable() {
6529
+ const result = (0, import_node_child_process3.spawnSync)("iptables", ["--version"], { stdio: "pipe" });
6530
+ return result.status === 0;
6531
+ }
6532
+ ensureChain() {
6533
+ try {
6534
+ (0, import_node_child_process3.execSync)(`iptables -n -L ${this.chain} 2>/dev/null`, { stdio: "pipe" });
6535
+ } catch {
6536
+ (0, import_node_child_process3.execSync)(`iptables -N ${this.chain}`);
6537
+ (0, import_node_child_process3.execSync)(`iptables -I INPUT 1 -j ${this.chain}`);
6538
+ }
6539
+ }
6540
+ async block(ip) {
6541
+ assertValidFirewallIp(ip);
6542
+ this.ensureChain();
6543
+ if (await this.isBlocked(ip)) return;
6544
+ (0, import_node_child_process3.execSync)(`iptables -A ${this.chain} -s ${ip} -j DROP`);
6545
+ }
6546
+ async unblock(ip) {
6547
+ assertValidFirewallIp(ip);
6548
+ try {
6549
+ (0, import_node_child_process3.execSync)(`iptables -D ${this.chain} -s ${ip} -j DROP`);
6550
+ } catch {
6551
+ }
6552
+ }
6553
+ async isBlocked(ip) {
6554
+ assertValidFirewallIp(ip);
6555
+ try {
6556
+ const output = (0, import_node_child_process3.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
6557
+ return output.includes(ip);
6558
+ } catch {
6559
+ return false;
6560
+ }
6561
+ }
6562
+ async listBlocked() {
6563
+ try {
6564
+ const output = (0, import_node_child_process3.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
6565
+ const ips = [];
6566
+ for (const line of output.split("\n")) {
6567
+ const match = line.match(/DROP\s+all\s+--\s+(\d+\.\d+\.\d+\.\d+)/);
6568
+ if (match) ips.push(match[1]);
6569
+ }
6570
+ return ips;
6571
+ } catch {
6572
+ return [];
6573
+ }
6574
+ }
6575
+ };
6576
+ var DryRunAdapter = class {
6577
+ name = "dry-run";
6578
+ blocked = /* @__PURE__ */ new Set();
6579
+ isAvailable() {
6580
+ return true;
6581
+ }
6582
+ async block(ip) {
6583
+ assertValidFirewallIp(ip);
6584
+ this.blocked.add(ip);
6585
+ }
6586
+ async unblock(ip) {
6587
+ assertValidFirewallIp(ip);
6588
+ this.blocked.delete(ip);
6589
+ }
6590
+ async isBlocked(ip) {
6591
+ assertValidFirewallIp(ip);
6592
+ return this.blocked.has(ip);
6593
+ }
6594
+ async listBlocked() {
6595
+ return [...this.blocked];
6596
+ }
6597
+ };
6598
+ function detectFirewallAdapter() {
6599
+ const nft = new NftablesAdapter();
6600
+ if (nft.isAvailable()) return nft;
6601
+ const ipt = new IptablesAdapter();
6602
+ if (ipt.isAvailable()) return ipt;
6603
+ return new DryRunAdapter();
6604
+ }
6605
+
6606
+ // src/daemon/firewall/remediation.ts
6607
+ var import_node_fs14 = require("fs");
6608
+ var DEFAULT_CONFIG2 = {
6609
+ enabled: true,
6610
+ dry_run: true,
6611
+ default_ttl_seconds: 3600,
6612
+ min_severity: "high",
6613
+ allowlist: ["127.0.0.1", "::1"]
6614
+ };
6615
+ var SEVERITY_RANK4 = {
6616
+ info: 0,
6617
+ low: 1,
6618
+ medium: 2,
6619
+ high: 3,
6620
+ critical: 4
6621
+ };
6622
+ var RemediationManager = class {
6623
+ constructor(adapter, bus2, config) {
6624
+ this.adapter = adapter;
6625
+ this.bus = bus2;
6626
+ this.config = { ...DEFAULT_CONFIG2, ...config };
6627
+ this.loadState();
6628
+ this.startExpiryWorker();
6629
+ }
6630
+ adapter;
6631
+ bus;
6632
+ config;
6633
+ blocklist = [];
6634
+ expiryTimer = null;
6635
+ async handleDetection(event) {
6636
+ if (!this.config.enabled) return;
6637
+ const eventRank = SEVERITY_RANK4[event.severity] ?? 0;
6638
+ const minRank = SEVERITY_RANK4[this.config.min_severity] ?? 3;
6639
+ if (eventRank < minRank) return;
6640
+ const ip = event.source_ip;
6641
+ if (!ip) return;
6642
+ if (this.isAllowlisted(ip)) return;
6643
+ if (this.blocklist.some((b) => b.ip === ip)) return;
6644
+ const ruleRemediation = event.details?.remediation;
6645
+ const ttl = ruleRemediation?.ttl_seconds || this.config.default_ttl_seconds;
6646
+ const ruleId = event.details?.rule_id;
6647
+ await this.blockIp(ip, event.message, ruleId, ttl);
6648
+ }
6649
+ async blockIp(ip, reason, ruleId, ttlSeconds) {
6650
+ if (this.isAllowlisted(ip)) return false;
6651
+ const entry = {
6652
+ ip,
6653
+ reason,
6654
+ rule_id: ruleId,
6655
+ blocked_at: Date.now(),
6656
+ expires_at: ttlSeconds ? Date.now() + ttlSeconds * 1e3 : void 0,
6657
+ dry_run: this.config.dry_run
6658
+ };
6659
+ if (!this.config.dry_run) {
6660
+ try {
6661
+ await this.adapter.block(ip);
6662
+ } catch (err) {
6663
+ this.logLine(`[firewall] EACCES or error blocking ${ip}: ${err.message}`);
6664
+ this.bus.publish({
6665
+ timestamp: /* @__PURE__ */ new Date(),
6666
+ module: "firewall-rules",
6667
+ category: "system",
6668
+ severity: "medium",
6669
+ message: `Failed to block ${ip}: ${err.message}. Ensure daemon has CAP_NET_ADMIN.`
6670
+ });
6671
+ return false;
6672
+ }
6673
+ }
6674
+ this.blocklist.push(entry);
6675
+ this.saveState();
6676
+ const mode = this.config.dry_run ? "[DRY-RUN] " : "";
6677
+ const expiryMsg = ttlSeconds ? ` (expires in ${ttlSeconds}s)` : " (permanent)";
6678
+ this.logLine(`[firewall] ${mode}Blocked ${ip}: ${reason}${expiryMsg}`);
6679
+ this.bus.publish({
6680
+ timestamp: /* @__PURE__ */ new Date(),
6681
+ module: "firewall-rules",
6682
+ category: "system",
6683
+ severity: "info",
6684
+ message: `${mode}Blocked ${ip}: ${reason}${expiryMsg}`,
6685
+ source_ip: ip,
6686
+ details: { action: "block", rule_id: ruleId, dry_run: this.config.dry_run, ttl_seconds: ttlSeconds }
6687
+ });
6688
+ return true;
6689
+ }
6690
+ async unblockIp(ip) {
6691
+ const idx = this.blocklist.findIndex((b) => b.ip === ip);
6692
+ if (idx < 0) return false;
6693
+ const entry = this.blocklist[idx];
6694
+ if (!entry.dry_run) {
6695
+ try {
6696
+ await this.adapter.unblock(ip);
6697
+ } catch (err) {
6698
+ this.logLine(`[firewall] Error unblocking ${ip}: ${err.message}`);
6699
+ return false;
6700
+ }
6701
+ }
6702
+ this.blocklist.splice(idx, 1);
6703
+ this.saveState();
6704
+ this.logLine(`[firewall] Unblocked ${ip}`);
6705
+ this.bus.publish({
6706
+ timestamp: /* @__PURE__ */ new Date(),
6707
+ module: "firewall-rules",
6708
+ category: "system",
6709
+ severity: "info",
6710
+ message: `Unblocked ${ip}`,
6711
+ source_ip: ip,
6712
+ details: { action: "unblock" }
6713
+ });
6714
+ return true;
6715
+ }
6716
+ isAllowlisted(ip) {
6717
+ return this.config.allowlist.includes(ip);
6718
+ }
6719
+ addToAllowlist(ip) {
6720
+ if (!this.config.allowlist.includes(ip)) {
6721
+ this.config.allowlist.push(ip);
6722
+ }
6723
+ }
6724
+ removeFromAllowlist(ip) {
6725
+ this.config.allowlist = this.config.allowlist.filter((a) => a !== ip);
6726
+ }
6727
+ getBlocklist() {
6728
+ return [...this.blocklist];
6729
+ }
6730
+ getAllowlist() {
6731
+ return [...this.config.allowlist];
6732
+ }
6733
+ stop() {
6734
+ if (this.expiryTimer) clearInterval(this.expiryTimer);
6735
+ this.expiryTimer = null;
6736
+ }
6737
+ startExpiryWorker() {
6738
+ this.expiryTimer = setInterval(() => void this.processExpiries(), 3e4);
6739
+ }
6740
+ async processExpiries() {
6741
+ const now = Date.now();
6742
+ const expired = this.blocklist.filter((b) => b.expires_at && b.expires_at <= now);
6743
+ for (const entry of expired) {
6744
+ await this.unblockIp(entry.ip);
6745
+ }
6746
+ }
6747
+ loadState() {
6748
+ try {
6749
+ const saved = getModuleState("firewall-rules", "blocklist");
6750
+ if (Array.isArray(saved)) this.blocklist = saved;
6751
+ } catch {
6752
+ }
6753
+ }
6754
+ saveState() {
6755
+ try {
6756
+ setModuleState("firewall-rules", "blocklist", this.blocklist);
6757
+ } catch {
6758
+ }
6759
+ }
6760
+ logLine(line) {
6761
+ try {
6762
+ (0, import_node_fs14.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
6763
+ `);
6764
+ } catch {
6765
+ }
6766
+ }
6767
+ };
6768
+
4190
6769
  // src/core/telemetry.ts
4191
6770
  var ready = false;
4192
6771
  var sentry = null;
@@ -4234,9 +6813,9 @@ async function flushTelemetry(timeoutMs = 2e3) {
4234
6813
  }
4235
6814
 
4236
6815
  // src/daemon/index.ts
4237
- function readVersion() {
6816
+ function readVersion2() {
4238
6817
  try {
4239
- const pkg = JSON.parse((0, import_node_fs9.readFileSync)((0, import_node_path6.join)(__dirname, "..", "package.json"), "utf-8"));
6818
+ const pkg = JSON.parse((0, import_node_fs15.readFileSync)((0, import_node_path10.join)(__dirname, "..", "package.json"), "utf-8"));
4240
6819
  return pkg.version || "0.0.0";
4241
6820
  } catch {
4242
6821
  return "0.0.0";
@@ -4244,7 +6823,7 @@ function readVersion() {
4244
6823
  }
4245
6824
  function logLine(line) {
4246
6825
  try {
4247
- (0, import_node_fs9.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
6826
+ (0, import_node_fs15.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
4248
6827
  `);
4249
6828
  } catch {
4250
6829
  }
@@ -4265,19 +6844,49 @@ async function runDaemon() {
4265
6844
  logLine(`[daemon] unhandledRejection: ${String(reason)}`);
4266
6845
  captureException(reason);
4267
6846
  });
4268
- const version = readVersion();
6847
+ const version = readVersion2();
4269
6848
  logLine(`[daemon] starting threatcrushd v${version} mode=${PATHS.mode}`);
4270
6849
  try {
4271
6850
  initStateDB(PATHS.stateDb);
4272
6851
  } catch (err) {
4273
6852
  logLine(`[daemon] state db unavailable: ${err.message}`);
4274
6853
  }
4275
- const config = loadConfig((0, import_node_fs9.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
6854
+ const config = loadConfig((0, import_node_fs15.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
4276
6855
  bus.on("event", (event) => {
4277
6856
  logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
4278
6857
  });
4279
6858
  const moduleHost = new ModuleHost(bus);
4280
6859
  await moduleHost.start();
6860
+ const ruleEngine = new RuleEngine((detection) => {
6861
+ const event = {
6862
+ timestamp: /* @__PURE__ */ new Date(),
6863
+ module: "rule-engine",
6864
+ category: detection.raw_metadata?.category || "system",
6865
+ severity: detection.severity,
6866
+ message: `[DETECTION] ${detection.title}`,
6867
+ source_ip: detection.source_ip,
6868
+ details: {
6869
+ rule_id: detection.rule_id,
6870
+ username: detection.username,
6871
+ ...detection.raw_metadata
6872
+ }
6873
+ };
6874
+ bus.publish(event);
6875
+ });
6876
+ ruleEngine.loadRules(loadAllRules());
6877
+ bus.on("event", (event) => {
6878
+ if (event.module !== "rule-engine") ruleEngine.evaluate(event);
6879
+ });
6880
+ logLine(`[daemon] rule engine loaded ${ruleEngine.getRules().length} rules`);
6881
+ setInterval(() => ruleEngine.cleanup(), 3e5);
6882
+ const firewallAdapter = detectFirewallAdapter();
6883
+ const remediation = new RemediationManager(firewallAdapter, bus, config.remediation);
6884
+ bus.on("event", (event) => {
6885
+ if (event.module !== "firewall-rules") {
6886
+ void remediation.handleDetection(event);
6887
+ }
6888
+ });
6889
+ logLine(`[daemon] firewall remediation active (backend=${firewallAdapter.name}, dry_run=${config.remediation?.dry_run ?? true})`);
4281
6890
  new AlertDispatcher(bus, config);
4282
6891
  const runsWorker = new RunsWorker(bus);
4283
6892
  try {
@@ -4290,6 +6899,10 @@ async function runDaemon() {
4290
6899
  logLine(`[daemon] ipc listening on ${PATHS.socket}`);
4291
6900
  const shutdown = async (signal) => {
4292
6901
  logLine(`[daemon] received ${signal}, shutting down`);
6902
+ try {
6903
+ remediation.stop();
6904
+ } catch {
6905
+ }
4293
6906
  try {
4294
6907
  runsWorker.stop();
4295
6908
  } catch {