@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/README.md +1 -0
- package/dist/daemon.js +2821 -208
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +4138 -590
- package/dist/index.js.map +1 -1
- package/dist/systemd/threatcrushd.service +10 -3
- package/package.json +6 -3
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((
|
|
1643
|
-
setImmediate(parseAsyncNext, index, blocksize,
|
|
1642
|
+
return new Promise((resolve3, reject) => {
|
|
1643
|
+
setImmediate(parseAsyncNext, index, blocksize, resolve3, reject);
|
|
1644
1644
|
});
|
|
1645
|
-
function parseAsyncNext(index2, blocksize2,
|
|
1645
|
+
function parseAsyncNext(index2, blocksize2, resolve3, reject) {
|
|
1646
1646
|
if (index2 >= str.length) {
|
|
1647
1647
|
try {
|
|
1648
|
-
return
|
|
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,
|
|
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((
|
|
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
|
-
|
|
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
|
|
2026
|
-
var
|
|
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
|
|
2033
|
-
|
|
2034
|
-
if (process.getuid && process.getuid() === 0) return true;
|
|
2035
|
-
try {
|
|
2036
|
-
if (!(0, import_node_fs.existsSync)("/etc/threatcrush")) return false;
|
|
2037
|
-
(0, import_node_fs.mkdirSync)("/etc/threatcrush/.probe", { recursive: true });
|
|
2038
|
-
return true;
|
|
2039
|
-
} catch {
|
|
2040
|
-
return false;
|
|
2041
|
-
}
|
|
2032
|
+
function isRoot() {
|
|
2033
|
+
return process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
|
|
2042
2034
|
}
|
|
2043
|
-
var systemMode = canWriteSystemPaths();
|
|
2044
2035
|
var userBase = (0, import_node_path.join)((0, import_node_os.homedir)(), ".threatcrush");
|
|
2045
|
-
var
|
|
2036
|
+
var SYSTEM_PATHS = {
|
|
2046
2037
|
mode: "system",
|
|
2047
2038
|
configDir: "/etc/threatcrush",
|
|
2048
2039
|
configFile: "/etc/threatcrush/threatcrushd.conf",
|
|
@@ -2055,7 +2046,8 @@ var PATHS = systemMode ? {
|
|
|
2055
2046
|
runDir: "/var/run/threatcrush",
|
|
2056
2047
|
pidFile: "/var/run/threatcrush/threatcrushd.pid",
|
|
2057
2048
|
socket: "/var/run/threatcrush/threatcrushd.sock"
|
|
2058
|
-
}
|
|
2049
|
+
};
|
|
2050
|
+
var USER_PATHS = {
|
|
2059
2051
|
mode: "user",
|
|
2060
2052
|
configDir: userBase,
|
|
2061
2053
|
configFile: (0, import_node_path.join)(userBase, "threatcrushd.conf"),
|
|
@@ -2069,6 +2061,7 @@ var PATHS = systemMode ? {
|
|
|
2069
2061
|
pidFile: (0, import_node_path.join)(userBase, "run", "threatcrushd.pid"),
|
|
2070
2062
|
socket: (0, import_node_path.join)(userBase, "run", "threatcrushd.sock")
|
|
2071
2063
|
};
|
|
2064
|
+
var PATHS = isRoot() ? SYSTEM_PATHS : USER_PATHS;
|
|
2072
2065
|
function ensureRuntimeDirs() {
|
|
2073
2066
|
for (const dir of [PATHS.configDir, PATHS.confD, PATHS.moduleDir, PATHS.logDir, PATHS.stateDir, PATHS.runDir]) {
|
|
2074
2067
|
try {
|
|
@@ -2100,8 +2093,9 @@ function isProcessAlive(pid) {
|
|
|
2100
2093
|
try {
|
|
2101
2094
|
process.kill(pid, 0);
|
|
2102
2095
|
return true;
|
|
2103
|
-
} catch {
|
|
2104
|
-
|
|
2096
|
+
} catch (err) {
|
|
2097
|
+
const code = err.code;
|
|
2098
|
+
return code === "EPERM";
|
|
2105
2099
|
}
|
|
2106
2100
|
}
|
|
2107
2101
|
function findRunningDaemon() {
|
|
@@ -2316,15 +2310,24 @@ var IpcServer = class {
|
|
|
2316
2310
|
} catch {
|
|
2317
2311
|
}
|
|
2318
2312
|
}
|
|
2319
|
-
return new Promise((
|
|
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
|
-
|
|
2319
|
+
nodeFs.chmodSync(PATHS.socket, 432);
|
|
2325
2320
|
} catch {
|
|
2326
2321
|
}
|
|
2327
|
-
|
|
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((
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2553
|
-
|
|
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
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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", [
|
|
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
|
-
[
|
|
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/
|
|
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,
|
|
3269
|
+
if (!(0, import_node_fs7.existsSync)(path)) {
|
|
2857
3270
|
return { ...DEFAULT_CONFIG };
|
|
2858
3271
|
}
|
|
2859
3272
|
try {
|
|
2860
|
-
const raw = (0,
|
|
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,
|
|
3289
|
+
if (!(0, import_node_fs7.existsSync)(dir)) {
|
|
2877
3290
|
return configs;
|
|
2878
3291
|
}
|
|
2879
|
-
const files = (0,
|
|
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,
|
|
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 =
|
|
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,
|
|
3409
|
+
if (!(0, import_node_fs8.existsSync)(PATHS.moduleDir)) return;
|
|
2973
3410
|
const configs = loadModuleConfigs(PATHS.confD);
|
|
2974
|
-
const entries = (0,
|
|
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,
|
|
3415
|
+
if (!(0, import_node_fs8.existsSync)(manifestPath)) continue;
|
|
2979
3416
|
try {
|
|
2980
|
-
const manifest = import_toml2.default.parse((0,
|
|
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,
|
|
3480
|
+
if ((0, import_node_fs8.existsSync)(packageJson)) {
|
|
3044
3481
|
try {
|
|
3045
|
-
const pkg = JSON.parse((0,
|
|
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,
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
3237
|
-
var
|
|
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
|
|
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/
|
|
3764
|
-
var
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
{
|
|
3768
|
-
{
|
|
3769
|
-
{
|
|
3770
|
-
{
|
|
3771
|
-
{
|
|
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
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3834
|
-
|
|
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
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
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
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
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
|
|
5716
|
+
const counts = summarize(structured);
|
|
3854
5717
|
return {
|
|
3855
5718
|
type: "scan",
|
|
3856
5719
|
target: targetPath,
|
|
3857
5720
|
findings: structured,
|
|
3858
|
-
severity_summary:
|
|
3859
|
-
summary: findings.length === 0 ?
|
|
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
|
|
3863
|
-
|
|
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
|
-
|
|
3866
|
-
|
|
3867
|
-
return;
|
|
3868
|
-
}
|
|
3869
|
-
|
|
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
|
|
6816
|
+
function readVersion2() {
|
|
4238
6817
|
try {
|
|
4239
|
-
const pkg = JSON.parse((0,
|
|
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,
|
|
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 =
|
|
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,
|
|
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 {
|