@profullstack/threatcrush 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.js +1449 -48
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +2248 -198
- package/dist/index.js.map +1 -1
- package/dist/systemd/threatcrushd.service +10 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3834,16 +3834,14 @@ var init_app = __esm({
|
|
|
3834
3834
|
});
|
|
3835
3835
|
|
|
3836
3836
|
// src/daemon/paths.ts
|
|
3837
|
-
function
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
return false;
|
|
3846
|
-
}
|
|
3837
|
+
function isRoot() {
|
|
3838
|
+
return process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
|
|
3839
|
+
}
|
|
3840
|
+
function resolveClientSocket() {
|
|
3841
|
+
if ((0, import_node_fs.existsSync)(PATHS.socket)) return PATHS.socket;
|
|
3842
|
+
const other = PATHS.mode === "system" ? USER_PATHS.socket : SYSTEM_PATHS.socket;
|
|
3843
|
+
if ((0, import_node_fs.existsSync)(other)) return other;
|
|
3844
|
+
return PATHS.socket;
|
|
3847
3845
|
}
|
|
3848
3846
|
function ensureRuntimeDirs() {
|
|
3849
3847
|
for (const dir of [PATHS.configDir, PATHS.confD, PATHS.moduleDir, PATHS.logDir, PATHS.stateDir, PATHS.runDir]) {
|
|
@@ -3853,16 +3851,15 @@ function ensureRuntimeDirs() {
|
|
|
3853
3851
|
}
|
|
3854
3852
|
}
|
|
3855
3853
|
}
|
|
3856
|
-
var import_node_fs, import_node_os2, import_node_path,
|
|
3854
|
+
var import_node_fs, import_node_os2, import_node_path, userBase, SYSTEM_PATHS, USER_PATHS, PATHS;
|
|
3857
3855
|
var init_paths = __esm({
|
|
3858
3856
|
"src/daemon/paths.ts"() {
|
|
3859
3857
|
"use strict";
|
|
3860
3858
|
import_node_fs = require("fs");
|
|
3861
3859
|
import_node_os2 = require("os");
|
|
3862
3860
|
import_node_path = require("path");
|
|
3863
|
-
systemMode = canWriteSystemPaths();
|
|
3864
3861
|
userBase = (0, import_node_path.join)((0, import_node_os2.homedir)(), ".threatcrush");
|
|
3865
|
-
|
|
3862
|
+
SYSTEM_PATHS = {
|
|
3866
3863
|
mode: "system",
|
|
3867
3864
|
configDir: "/etc/threatcrush",
|
|
3868
3865
|
configFile: "/etc/threatcrush/threatcrushd.conf",
|
|
@@ -3875,7 +3872,8 @@ var init_paths = __esm({
|
|
|
3875
3872
|
runDir: "/var/run/threatcrush",
|
|
3876
3873
|
pidFile: "/var/run/threatcrush/threatcrushd.pid",
|
|
3877
3874
|
socket: "/var/run/threatcrush/threatcrushd.sock"
|
|
3878
|
-
}
|
|
3875
|
+
};
|
|
3876
|
+
USER_PATHS = {
|
|
3879
3877
|
mode: "user",
|
|
3880
3878
|
configDir: userBase,
|
|
3881
3879
|
configFile: (0, import_node_path.join)(userBase, "threatcrushd.conf"),
|
|
@@ -3889,6 +3887,7 @@ var init_paths = __esm({
|
|
|
3889
3887
|
pidFile: (0, import_node_path.join)(userBase, "run", "threatcrushd.pid"),
|
|
3890
3888
|
socket: (0, import_node_path.join)(userBase, "run", "threatcrushd.sock")
|
|
3891
3889
|
};
|
|
3890
|
+
PATHS = isRoot() ? SYSTEM_PATHS : USER_PATHS;
|
|
3892
3891
|
}
|
|
3893
3892
|
});
|
|
3894
3893
|
|
|
@@ -3903,7 +3902,7 @@ var init_ipc_client = __esm({
|
|
|
3903
3902
|
IpcClient = class {
|
|
3904
3903
|
constructor(opts = {}) {
|
|
3905
3904
|
this.opts = opts;
|
|
3906
|
-
this.socketPath = opts.socketPath ||
|
|
3905
|
+
this.socketPath = opts.socketPath || resolveClientSocket();
|
|
3907
3906
|
}
|
|
3908
3907
|
opts;
|
|
3909
3908
|
socket = null;
|
|
@@ -3911,7 +3910,7 @@ var init_ipc_client = __esm({
|
|
|
3911
3910
|
nextId = 1;
|
|
3912
3911
|
pending = /* @__PURE__ */ new Map();
|
|
3913
3912
|
socketPath;
|
|
3914
|
-
static isDaemonRunning(socketPath =
|
|
3913
|
+
static isDaemonRunning(socketPath = resolveClientSocket()) {
|
|
3915
3914
|
return (0, import_node_fs2.existsSync)(socketPath);
|
|
3916
3915
|
}
|
|
3917
3916
|
async connect(timeoutMs = 2e3) {
|
|
@@ -4037,8 +4036,9 @@ function isProcessAlive(pid) {
|
|
|
4037
4036
|
try {
|
|
4038
4037
|
process.kill(pid, 0);
|
|
4039
4038
|
return true;
|
|
4040
|
-
} catch {
|
|
4041
|
-
|
|
4039
|
+
} catch (err) {
|
|
4040
|
+
const code = err.code;
|
|
4041
|
+
return code === "EPERM";
|
|
4042
4042
|
}
|
|
4043
4043
|
}
|
|
4044
4044
|
function findRunningDaemon() {
|
|
@@ -8259,9 +8259,9 @@ var source_default = chalk;
|
|
|
8259
8259
|
|
|
8260
8260
|
// src/index.ts
|
|
8261
8261
|
var import_readline = __toESM(require("readline"));
|
|
8262
|
-
var
|
|
8263
|
-
var
|
|
8264
|
-
var
|
|
8262
|
+
var import_node_child_process10 = require("child_process");
|
|
8263
|
+
var import_node_fs27 = require("fs");
|
|
8264
|
+
var import_node_path16 = require("path");
|
|
8265
8265
|
var import_node_os8 = require("os");
|
|
8266
8266
|
|
|
8267
8267
|
// src/commands/monitor.ts
|
|
@@ -8454,16 +8454,41 @@ async function monitorCommand(options) {
|
|
|
8454
8454
|
banner();
|
|
8455
8455
|
logger.info("Starting foreground monitor...");
|
|
8456
8456
|
const moduleFilter = options.module?.split(",").map((m) => m.trim());
|
|
8457
|
-
const availableSources =
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8457
|
+
const availableSources = [];
|
|
8458
|
+
const unreadable = [];
|
|
8459
|
+
for (const s of LOG_SOURCES) {
|
|
8460
|
+
if (moduleFilter && !moduleFilter.includes(s.name)) continue;
|
|
8461
|
+
if (!(0, import_node_fs4.existsSync)(s.path)) continue;
|
|
8462
|
+
try {
|
|
8463
|
+
(0, import_node_fs4.accessSync)(s.path, import_node_fs4.constants.R_OK);
|
|
8464
|
+
availableSources.push(s);
|
|
8465
|
+
} catch {
|
|
8466
|
+
unreadable.push(s);
|
|
8467
|
+
}
|
|
8468
|
+
}
|
|
8469
|
+
if (unreadable.length > 0) {
|
|
8470
|
+
logger.warn(`Skipping ${unreadable.length} unreadable log source(s):`);
|
|
8471
|
+
for (const s of unreadable) {
|
|
8472
|
+
console.log(` ${source_default.yellow("!")} ${source_default.gray(s.path)} (permission denied \u2014 add yourself to the 'adm' group or run as root)`);
|
|
8473
|
+
}
|
|
8474
|
+
console.log();
|
|
8475
|
+
}
|
|
8461
8476
|
if (availableSources.length === 0) {
|
|
8462
|
-
logger.warn("No log files found to monitor.");
|
|
8477
|
+
logger.warn("No readable log files found to monitor.");
|
|
8463
8478
|
logger.info("Available log paths checked:");
|
|
8464
8479
|
for (const s of LOG_SOURCES) {
|
|
8465
8480
|
const exists = (0, import_node_fs4.existsSync)(s.path);
|
|
8466
|
-
|
|
8481
|
+
let readable = false;
|
|
8482
|
+
if (exists) {
|
|
8483
|
+
try {
|
|
8484
|
+
(0, import_node_fs4.accessSync)(s.path, import_node_fs4.constants.R_OK);
|
|
8485
|
+
readable = true;
|
|
8486
|
+
} catch {
|
|
8487
|
+
readable = false;
|
|
8488
|
+
}
|
|
8489
|
+
}
|
|
8490
|
+
const glyph = !exists ? source_default.red("\u2717 missing") : readable ? source_default.green("\u2713 readable") : source_default.yellow("! no read perm");
|
|
8491
|
+
console.log(` ${glyph} ${s.path}`);
|
|
8467
8492
|
}
|
|
8468
8493
|
console.log();
|
|
8469
8494
|
logger.info("Starting demo mode with synthetic events...\n");
|
|
@@ -8498,7 +8523,13 @@ function tailLog(source) {
|
|
|
8498
8523
|
return;
|
|
8499
8524
|
}
|
|
8500
8525
|
const stream = (0, import_node_fs4.createReadStream)(path, { start: position, encoding: "utf-8" });
|
|
8526
|
+
stream.on("error", (err) => {
|
|
8527
|
+
logger.warn(`stopped tailing ${path}: ${err.code || err.message}`);
|
|
8528
|
+
position = currentStat.size;
|
|
8529
|
+
});
|
|
8501
8530
|
const rl = (0, import_node_readline.createInterface)({ input: stream });
|
|
8531
|
+
rl.on("error", () => {
|
|
8532
|
+
});
|
|
8502
8533
|
rl.on("line", (line) => {
|
|
8503
8534
|
if (!line.trim()) return;
|
|
8504
8535
|
processLine(line, name, category);
|
|
@@ -9607,6 +9638,8 @@ async function runScan(targetPath) {
|
|
|
9607
9638
|
try {
|
|
9608
9639
|
scanDirectory(targetPath, targetPath, findings, () => {
|
|
9609
9640
|
});
|
|
9641
|
+
const depFindings = await scanDependencies(targetPath);
|
|
9642
|
+
findings.push(...depFindings);
|
|
9610
9643
|
} catch (err) {
|
|
9611
9644
|
return {
|
|
9612
9645
|
type: "scan",
|
|
@@ -9776,6 +9809,92 @@ function scanDirectory(basePath, currentPath, findings, onFile) {
|
|
|
9776
9809
|
}
|
|
9777
9810
|
}
|
|
9778
9811
|
}
|
|
9812
|
+
async function scanDependencies(targetPath) {
|
|
9813
|
+
const findings = [];
|
|
9814
|
+
const lockfiles = [
|
|
9815
|
+
{ file: "package-lock.json", ecosystem: "npm" },
|
|
9816
|
+
{ file: "pnpm-lock.yaml", ecosystem: "npm" },
|
|
9817
|
+
{ file: "yarn.lock", ecosystem: "npm" },
|
|
9818
|
+
{ file: "requirements.txt", ecosystem: "PyPI" },
|
|
9819
|
+
{ file: "Pipfile.lock", ecosystem: "PyPI" }
|
|
9820
|
+
];
|
|
9821
|
+
for (const { file, ecosystem } of lockfiles) {
|
|
9822
|
+
const lockPath = (0, import_node_path2.join)(targetPath, file);
|
|
9823
|
+
if (!(0, import_node_fs5.existsSync)(lockPath)) continue;
|
|
9824
|
+
try {
|
|
9825
|
+
const deps = parseDependencies(lockPath, file, ecosystem);
|
|
9826
|
+
for (const dep of deps.slice(0, 50)) {
|
|
9827
|
+
try {
|
|
9828
|
+
const vulns = await queryOsv(dep.name, dep.version, ecosystem);
|
|
9829
|
+
for (const vuln of vulns) {
|
|
9830
|
+
const cvssScore = vuln.severity?.find((s) => s.type === "CVSS_V3")?.score;
|
|
9831
|
+
const severity = cvssScore ? parseFloat(cvssScore) >= 9 ? "critical" : parseFloat(cvssScore) >= 7 ? "high" : parseFloat(cvssScore) >= 4 ? "medium" : "low" : "medium";
|
|
9832
|
+
findings.push({
|
|
9833
|
+
file,
|
|
9834
|
+
line: 0,
|
|
9835
|
+
type: "Dependency CVE",
|
|
9836
|
+
severity,
|
|
9837
|
+
message: `${dep.name}@${dep.version}: ${vuln.summary || vuln.id}`,
|
|
9838
|
+
snippet: `${vuln.id}${cvssScore ? ` (CVSS: ${cvssScore})` : ""}`
|
|
9839
|
+
});
|
|
9840
|
+
}
|
|
9841
|
+
} catch {
|
|
9842
|
+
}
|
|
9843
|
+
}
|
|
9844
|
+
} catch {
|
|
9845
|
+
}
|
|
9846
|
+
}
|
|
9847
|
+
return findings;
|
|
9848
|
+
}
|
|
9849
|
+
function parseDependencies(lockPath, filename, ecosystem) {
|
|
9850
|
+
const deps = [];
|
|
9851
|
+
if (filename === "package-lock.json") {
|
|
9852
|
+
try {
|
|
9853
|
+
const lock = JSON.parse((0, import_node_fs5.readFileSync)(lockPath, "utf-8"));
|
|
9854
|
+
const packages = lock.packages || lock.dependencies || {};
|
|
9855
|
+
for (const [key, value] of Object.entries(packages)) {
|
|
9856
|
+
const name = key.replace(/^node_modules\//, "");
|
|
9857
|
+
const version = value.version;
|
|
9858
|
+
if (name && version && !name.startsWith(".")) {
|
|
9859
|
+
deps.push({ name, version });
|
|
9860
|
+
}
|
|
9861
|
+
}
|
|
9862
|
+
} catch {
|
|
9863
|
+
}
|
|
9864
|
+
} else if (filename === "requirements.txt") {
|
|
9865
|
+
try {
|
|
9866
|
+
const content = (0, import_node_fs5.readFileSync)(lockPath, "utf-8");
|
|
9867
|
+
for (const line of content.split("\n")) {
|
|
9868
|
+
const match = line.match(/^([a-zA-Z0-9_.-]+)==([0-9.]+)/);
|
|
9869
|
+
if (match) deps.push({ name: match[1], version: match[2] });
|
|
9870
|
+
}
|
|
9871
|
+
} catch {
|
|
9872
|
+
}
|
|
9873
|
+
}
|
|
9874
|
+
return deps;
|
|
9875
|
+
}
|
|
9876
|
+
function isValidPackageName(name) {
|
|
9877
|
+
return /^[@a-zA-Z0-9_.\-/]{1,214}$/.test(name);
|
|
9878
|
+
}
|
|
9879
|
+
function isValidVersion(version) {
|
|
9880
|
+
return /^[0-9a-zA-Z._\-+]{1,50}$/.test(version);
|
|
9881
|
+
}
|
|
9882
|
+
async function queryOsv(name, version, ecosystem) {
|
|
9883
|
+
if (!isValidPackageName(name) || !isValidVersion(version)) return [];
|
|
9884
|
+
try {
|
|
9885
|
+
const res = await fetch("https://api.osv.dev/v1/query", {
|
|
9886
|
+
method: "POST",
|
|
9887
|
+
headers: { "Content-Type": "application/json" },
|
|
9888
|
+
body: JSON.stringify({ package: { name, ecosystem }, version }),
|
|
9889
|
+
signal: AbortSignal.timeout(5e3)
|
|
9890
|
+
});
|
|
9891
|
+
if (!res.ok) return [];
|
|
9892
|
+
const data = await res.json();
|
|
9893
|
+
return data.vulns || [];
|
|
9894
|
+
} catch {
|
|
9895
|
+
return [];
|
|
9896
|
+
}
|
|
9897
|
+
}
|
|
9779
9898
|
|
|
9780
9899
|
// src/commands/init.ts
|
|
9781
9900
|
var import_node_fs8 = require("fs");
|
|
@@ -10591,7 +10710,16 @@ async function modulesInstallCommand(source) {
|
|
|
10591
10710
|
`));
|
|
10592
10711
|
return;
|
|
10593
10712
|
}
|
|
10594
|
-
if (install.
|
|
10713
|
+
if (install.npm_package) {
|
|
10714
|
+
const npmSpinner = ora({ text: `Installing ${install.npm_package}...`, color: "green" }).start();
|
|
10715
|
+
try {
|
|
10716
|
+
(0, import_node_child_process2.execSync)(`npm install -g ${install.npm_package}`, { stdio: "pipe" });
|
|
10717
|
+
npmSpinner.succeed(`Package ${install.npm_package} installed globally`);
|
|
10718
|
+
} catch (err) {
|
|
10719
|
+
npmSpinner.fail(`npm install failed: ${err.message}`);
|
|
10720
|
+
return;
|
|
10721
|
+
}
|
|
10722
|
+
} else if (install.git_url) {
|
|
10595
10723
|
const cloneSpinner = ora({ text: "Cloning module repository...", color: "green" }).start();
|
|
10596
10724
|
try {
|
|
10597
10725
|
(0, import_node_child_process2.execSync)(`git clone --depth 1 ${install.git_url} ${dest}`, { stdio: "pipe" });
|
|
@@ -10609,15 +10737,6 @@ async function modulesInstallCommand(source) {
|
|
|
10609
10737
|
return;
|
|
10610
10738
|
}
|
|
10611
10739
|
cloneSpinner.succeed(`Installed ${mod.name} v${mod.version} \u2192 ${dest}`);
|
|
10612
|
-
} else if (install.npm_package) {
|
|
10613
|
-
const npmSpinner = ora({ text: `Installing ${install.npm_package}...`, color: "green" }).start();
|
|
10614
|
-
try {
|
|
10615
|
-
(0, import_node_child_process2.execSync)(`npm install -g ${install.npm_package}`, { stdio: "pipe" });
|
|
10616
|
-
npmSpinner.succeed(`Package ${install.npm_package} installed globally`);
|
|
10617
|
-
} catch (err) {
|
|
10618
|
-
npmSpinner.fail(`npm install failed: ${err.message}`);
|
|
10619
|
-
return;
|
|
10620
|
-
}
|
|
10621
10740
|
} else if (install.tarball_url) {
|
|
10622
10741
|
const dlSpinner = ora({ text: "Downloading module tarball...", color: "green" }).start();
|
|
10623
10742
|
try {
|
|
@@ -10758,6 +10877,39 @@ var PENTEST_CHECKS = [
|
|
|
10758
10877
|
test: (html) => /stack trace|traceback|exception|at\s+\w+\.\w+\(/i.test(html),
|
|
10759
10878
|
severity: "medium",
|
|
10760
10879
|
message: "Error page reveals internal information"
|
|
10880
|
+
},
|
|
10881
|
+
// PRD 07: Additional checks
|
|
10882
|
+
{
|
|
10883
|
+
name: "CORS Misconfiguration",
|
|
10884
|
+
test: (_url, _body, headers) => {
|
|
10885
|
+
const acao = headers["access-control-allow-origin"];
|
|
10886
|
+
return acao === "*" || acao === "null";
|
|
10887
|
+
},
|
|
10888
|
+
severity: "medium",
|
|
10889
|
+
message: "CORS allows any origin (Access-Control-Allow-Origin: *)"
|
|
10890
|
+
},
|
|
10891
|
+
{
|
|
10892
|
+
name: "Cookie Security",
|
|
10893
|
+
test: (_url, _body, headers) => {
|
|
10894
|
+
const setCookie = headers["set-cookie"] || "";
|
|
10895
|
+
return setCookie.length > 0 && (!setCookie.includes("HttpOnly") || !setCookie.includes("Secure"));
|
|
10896
|
+
},
|
|
10897
|
+
severity: "medium",
|
|
10898
|
+
message: "Cookies missing HttpOnly or Secure flags"
|
|
10899
|
+
},
|
|
10900
|
+
{
|
|
10901
|
+
name: "Content Security Policy",
|
|
10902
|
+
test: (_url, _body, headers) => {
|
|
10903
|
+
return !headers["content-security-policy"];
|
|
10904
|
+
},
|
|
10905
|
+
severity: "low",
|
|
10906
|
+
message: "No Content-Security-Policy header set"
|
|
10907
|
+
},
|
|
10908
|
+
{
|
|
10909
|
+
name: "Sensitive Path Exposure",
|
|
10910
|
+
test: (html) => /\.env|wp-admin|phpinfo|\.git\/config|server-status/i.test(html),
|
|
10911
|
+
severity: "high",
|
|
10912
|
+
message: "Response references sensitive paths or admin endpoints"
|
|
10761
10913
|
}
|
|
10762
10914
|
];
|
|
10763
10915
|
async function runPentest(rawUrl) {
|
|
@@ -11423,14 +11575,14 @@ async function sshConnect(options) {
|
|
|
11423
11575
|
}
|
|
11424
11576
|
|
|
11425
11577
|
// src/commands/daemon.ts
|
|
11426
|
-
var
|
|
11427
|
-
var
|
|
11428
|
-
var
|
|
11429
|
-
var
|
|
11578
|
+
var import_node_child_process7 = require("child_process");
|
|
11579
|
+
var import_node_fs22 = require("fs");
|
|
11580
|
+
var import_node_path13 = require("path");
|
|
11581
|
+
var import_node_fs23 = require("fs");
|
|
11430
11582
|
|
|
11431
11583
|
// src/daemon/index.ts
|
|
11432
|
-
var
|
|
11433
|
-
var
|
|
11584
|
+
var import_node_fs21 = require("fs");
|
|
11585
|
+
var import_node_path12 = require("path");
|
|
11434
11586
|
init_paths();
|
|
11435
11587
|
init_pidfile();
|
|
11436
11588
|
|
|
@@ -11493,10 +11645,19 @@ var IpcServer = class {
|
|
|
11493
11645
|
this.server = (0, import_node_net2.createServer)((sock) => this.handleClient(sock));
|
|
11494
11646
|
this.server.on("error", reject);
|
|
11495
11647
|
this.server.listen(PATHS.socket, () => {
|
|
11648
|
+
const nodeFs = require("fs");
|
|
11496
11649
|
try {
|
|
11497
|
-
|
|
11650
|
+
nodeFs.chmodSync(PATHS.socket, 432);
|
|
11498
11651
|
} catch {
|
|
11499
11652
|
}
|
|
11653
|
+
const isRoot3 = process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
|
|
11654
|
+
if (isRoot3) {
|
|
11655
|
+
try {
|
|
11656
|
+
const { gid } = nodeFs.statSync("/var/log/auth.log");
|
|
11657
|
+
nodeFs.chownSync(PATHS.socket, 0, gid);
|
|
11658
|
+
} catch {
|
|
11659
|
+
}
|
|
11660
|
+
}
|
|
11500
11661
|
resolve3();
|
|
11501
11662
|
});
|
|
11502
11663
|
});
|
|
@@ -11625,7 +11786,7 @@ var IpcServer = class {
|
|
|
11625
11786
|
};
|
|
11626
11787
|
|
|
11627
11788
|
// src/daemon/module-host.ts
|
|
11628
|
-
var
|
|
11789
|
+
var import_node_fs18 = require("fs");
|
|
11629
11790
|
var import_node_path10 = require("path");
|
|
11630
11791
|
var import_node_url = require("url");
|
|
11631
11792
|
var import_toml4 = __toESM(require_toml());
|
|
@@ -11780,8 +11941,16 @@ var JournalWatcher = class _JournalWatcher {
|
|
|
11780
11941
|
buffer = "";
|
|
11781
11942
|
moduleName = "user-journal";
|
|
11782
11943
|
active = false;
|
|
11944
|
+
// When the daemon runs as root (system mode), tail the SYSTEM journal so
|
|
11945
|
+
// we pick up sshd / sudo / kernel / UFW events. Falling back to --user
|
|
11946
|
+
// would give us root's mostly-empty per-user journal. Otherwise we use
|
|
11947
|
+
// --user so the daemon can run unprivileged on a workstation.
|
|
11948
|
+
static scopeArgs() {
|
|
11949
|
+
const isRoot3 = process.platform === "linux" && typeof process.getuid === "function" && process.getuid() === 0;
|
|
11950
|
+
return isRoot3 ? [] : ["--user"];
|
|
11951
|
+
}
|
|
11783
11952
|
static isAvailable() {
|
|
11784
|
-
const probe = (0, import_node_child_process4.spawnSync)("journalctl", [
|
|
11953
|
+
const probe = (0, import_node_child_process4.spawnSync)("journalctl", [...this.scopeArgs(), "-n", "0", "--no-pager"], {
|
|
11785
11954
|
stdio: ["ignore", "ignore", "ignore"]
|
|
11786
11955
|
});
|
|
11787
11956
|
return probe.status === 0;
|
|
@@ -11790,7 +11959,7 @@ var JournalWatcher = class _JournalWatcher {
|
|
|
11790
11959
|
if (!_JournalWatcher.isAvailable()) return false;
|
|
11791
11960
|
const child = (0, import_node_child_process4.spawn)(
|
|
11792
11961
|
"journalctl",
|
|
11793
|
-
[
|
|
11962
|
+
[..._JournalWatcher.scopeArgs(), "-o", "json", "-f", "--since", "now"],
|
|
11794
11963
|
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
11795
11964
|
);
|
|
11796
11965
|
if (!child.stdout) return false;
|
|
@@ -11880,98 +12049,511 @@ function realtimeToDate(rt) {
|
|
|
11880
12049
|
return new Date(Math.floor(us / 1e3));
|
|
11881
12050
|
}
|
|
11882
12051
|
|
|
11883
|
-
// src/
|
|
12052
|
+
// src/modules/network-monitor/index.ts
|
|
12053
|
+
var import_node_child_process5 = require("child_process");
|
|
12054
|
+
var import_node_fs16 = require("fs");
|
|
11884
12055
|
init_state();
|
|
11885
|
-
var
|
|
12056
|
+
var NetworkMonitor = class {
|
|
11886
12057
|
constructor(bus2) {
|
|
11887
12058
|
this.bus = bus2;
|
|
11888
|
-
bus2.on("event", (event) => {
|
|
11889
|
-
const mod = this.modules.get(event.module);
|
|
11890
|
-
if (mod) mod.events++;
|
|
11891
|
-
for (const hosted of this.modules.values()) {
|
|
11892
|
-
if (hosted.status !== "running" || !hosted.instance?.onEvent) continue;
|
|
11893
|
-
void hosted.instance.onEvent(event).catch((err) => {
|
|
11894
|
-
hosted.status = "error";
|
|
11895
|
-
hosted.detail = `onEvent failed: ${String(err.message || err)}`;
|
|
11896
|
-
this.bus.announceModule(hosted.name, "error", hosted.detail);
|
|
11897
|
-
});
|
|
11898
|
-
}
|
|
11899
|
-
});
|
|
11900
12059
|
}
|
|
11901
12060
|
bus;
|
|
11902
|
-
|
|
11903
|
-
|
|
11904
|
-
|
|
11905
|
-
|
|
11906
|
-
|
|
11907
|
-
|
|
11908
|
-
|
|
11909
|
-
|
|
11910
|
-
|
|
11911
|
-
|
|
11912
|
-
|
|
11913
|
-
|
|
11914
|
-
|
|
11915
|
-
|
|
12061
|
+
active = false;
|
|
12062
|
+
pollTimer = null;
|
|
12063
|
+
scanTrackers = /* @__PURE__ */ new Map();
|
|
12064
|
+
halfOpenTrackers = /* @__PURE__ */ new Map();
|
|
12065
|
+
lastConnections = /* @__PURE__ */ new Set();
|
|
12066
|
+
// Config
|
|
12067
|
+
pollIntervalMs = 5e3;
|
|
12068
|
+
portScanThreshold = 10;
|
|
12069
|
+
// unique ports in window
|
|
12070
|
+
portScanWindowMs = 3e4;
|
|
12071
|
+
synFloodThreshold = 50;
|
|
12072
|
+
// half-open connections
|
|
12073
|
+
synFloodWindowMs = 1e4;
|
|
12074
|
+
start() {
|
|
12075
|
+
if (!this.hasConntrackOrSs()) {
|
|
12076
|
+
return false;
|
|
12077
|
+
}
|
|
12078
|
+
this.active = true;
|
|
12079
|
+
this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs);
|
|
12080
|
+
return true;
|
|
12081
|
+
}
|
|
12082
|
+
stop() {
|
|
12083
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
12084
|
+
this.pollTimer = null;
|
|
12085
|
+
this.active = false;
|
|
12086
|
+
}
|
|
12087
|
+
isActive() {
|
|
12088
|
+
return this.active;
|
|
12089
|
+
}
|
|
12090
|
+
hasConntrackOrSs() {
|
|
12091
|
+
const ss = (0, import_node_child_process5.spawnSync)("ss", ["--version"], { stdio: "pipe" });
|
|
12092
|
+
if (ss.status === 0) return true;
|
|
12093
|
+
return (0, import_node_fs16.existsSync)("/proc/net/tcp");
|
|
12094
|
+
}
|
|
12095
|
+
poll() {
|
|
12096
|
+
try {
|
|
12097
|
+
const connections = this.getConnections();
|
|
12098
|
+
this.analyzePortScans(connections);
|
|
12099
|
+
this.analyzeSynFlood(connections);
|
|
12100
|
+
this.cleanupTrackers();
|
|
12101
|
+
} catch {
|
|
12102
|
+
}
|
|
12103
|
+
}
|
|
12104
|
+
getConnections() {
|
|
12105
|
+
const records = [];
|
|
12106
|
+
const now = Date.now();
|
|
12107
|
+
try {
|
|
12108
|
+
const ct = (0, import_node_child_process5.spawnSync)("conntrack", ["-L", "-p", "tcp", "-o", "extended"], {
|
|
12109
|
+
encoding: "utf-8",
|
|
12110
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
12111
|
+
timeout: 3e3
|
|
12112
|
+
});
|
|
12113
|
+
if (ct.status === 0 && ct.stdout) {
|
|
12114
|
+
for (const line of ct.stdout.split("\n")) {
|
|
12115
|
+
const srcMatch = line.match(/src=(\d+\.\d+\.\d+\.\d+)/);
|
|
12116
|
+
const dportMatch = line.match(/dport=(\d+)/);
|
|
12117
|
+
if (srcMatch && dportMatch) {
|
|
12118
|
+
records.push({ source_ip: srcMatch[1], dest_port: parseInt(dportMatch[1]), timestamp: now });
|
|
12119
|
+
}
|
|
12120
|
+
}
|
|
12121
|
+
if (records.length > 0) return records;
|
|
11916
12122
|
}
|
|
12123
|
+
} catch {
|
|
11917
12124
|
}
|
|
11918
|
-
|
|
11919
|
-
|
|
11920
|
-
|
|
11921
|
-
|
|
11922
|
-
|
|
11923
|
-
|
|
11924
|
-
|
|
12125
|
+
try {
|
|
12126
|
+
const ss = (0, import_node_child_process5.spawnSync)("ss", ["-tnp", "-H"], {
|
|
12127
|
+
encoding: "utf-8",
|
|
12128
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
12129
|
+
timeout: 3e3
|
|
12130
|
+
});
|
|
12131
|
+
if (ss.status === 0 && ss.stdout) {
|
|
12132
|
+
for (const line of ss.stdout.split("\n")) {
|
|
12133
|
+
const parts = line.trim().split(/\s+/);
|
|
12134
|
+
if (parts.length < 5) continue;
|
|
12135
|
+
const peerParts = parts[4].split(":");
|
|
12136
|
+
const localParts = parts[3].split(":");
|
|
12137
|
+
if (peerParts.length >= 2 && localParts.length >= 2) {
|
|
12138
|
+
const sourceIp = peerParts.slice(0, -1).join(":");
|
|
12139
|
+
const destPort = parseInt(localParts[localParts.length - 1]);
|
|
12140
|
+
if (sourceIp && !isNaN(destPort) && !this.isLocalIp(sourceIp)) {
|
|
12141
|
+
records.push({ source_ip: sourceIp, dest_port: destPort, timestamp: now });
|
|
12142
|
+
}
|
|
12143
|
+
}
|
|
12144
|
+
}
|
|
11925
12145
|
}
|
|
12146
|
+
} catch {
|
|
11926
12147
|
}
|
|
12148
|
+
return records;
|
|
11927
12149
|
}
|
|
11928
|
-
|
|
11929
|
-
|
|
11930
|
-
|
|
11931
|
-
|
|
11932
|
-
|
|
11933
|
-
|
|
11934
|
-
|
|
12150
|
+
analyzePortScans(connections) {
|
|
12151
|
+
const now = Date.now();
|
|
12152
|
+
for (const conn of connections) {
|
|
12153
|
+
const key = conn.source_ip;
|
|
12154
|
+
let tracker = this.scanTrackers.get(key);
|
|
12155
|
+
if (!tracker) {
|
|
12156
|
+
tracker = { ports: /* @__PURE__ */ new Set(), firstSeen: now, lastSeen: now, count: 0 };
|
|
12157
|
+
this.scanTrackers.set(key, tracker);
|
|
12158
|
+
}
|
|
12159
|
+
tracker.ports.add(conn.dest_port);
|
|
12160
|
+
tracker.lastSeen = now;
|
|
12161
|
+
tracker.count++;
|
|
12162
|
+
if (tracker.ports.size >= this.portScanThreshold && now - tracker.firstSeen <= this.portScanWindowMs) {
|
|
12163
|
+
this.emitEvent(
|
|
12164
|
+
"high",
|
|
12165
|
+
`Port scan detected: ${conn.source_ip} probed ${tracker.ports.size} ports in ${Math.round((now - tracker.firstSeen) / 1e3)}s`,
|
|
12166
|
+
conn.source_ip,
|
|
12167
|
+
{ ports_scanned: tracker.ports.size, window_seconds: Math.round((now - tracker.firstSeen) / 1e3) }
|
|
12168
|
+
);
|
|
12169
|
+
this.scanTrackers.delete(key);
|
|
12170
|
+
}
|
|
12171
|
+
}
|
|
12172
|
+
}
|
|
12173
|
+
analyzeSynFlood(connections) {
|
|
12174
|
+
try {
|
|
12175
|
+
const ss = (0, import_node_child_process5.spawnSync)("ss", ["-tn", "state", "syn-recv", "-H"], {
|
|
12176
|
+
encoding: "utf-8",
|
|
12177
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
12178
|
+
timeout: 3e3
|
|
12179
|
+
});
|
|
12180
|
+
if (ss.status !== 0 || !ss.stdout) return;
|
|
12181
|
+
const perSource = /* @__PURE__ */ new Map();
|
|
12182
|
+
for (const line of ss.stdout.split("\n")) {
|
|
12183
|
+
const parts = line.trim().split(/\s+/);
|
|
12184
|
+
if (parts.length < 5) continue;
|
|
12185
|
+
const peer = parts[4].split(":");
|
|
12186
|
+
const ip = peer.slice(0, -1).join(":");
|
|
12187
|
+
if (ip) perSource.set(ip, (perSource.get(ip) || 0) + 1);
|
|
12188
|
+
}
|
|
12189
|
+
for (const [ip, count] of perSource) {
|
|
12190
|
+
if (count >= this.synFloodThreshold) {
|
|
12191
|
+
this.emitEvent(
|
|
12192
|
+
"critical",
|
|
12193
|
+
`SYN flood indicators: ${count} half-open connections from ${ip}`,
|
|
12194
|
+
ip,
|
|
12195
|
+
{ half_open_count: count }
|
|
12196
|
+
);
|
|
11935
12197
|
}
|
|
11936
|
-
} catch (err) {
|
|
11937
|
-
mod.status = "error";
|
|
11938
|
-
mod.detail = `stop failed: ${String(err.message || err)}`;
|
|
11939
|
-
this.bus.announceModule(mod.name, "error", mod.detail);
|
|
11940
|
-
continue;
|
|
11941
12198
|
}
|
|
11942
|
-
|
|
11943
|
-
this.bus.announceModule(mod.name, "stopped");
|
|
12199
|
+
} catch {
|
|
11944
12200
|
}
|
|
11945
12201
|
}
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
|
|
11949
|
-
|
|
11950
|
-
|
|
11951
|
-
|
|
11952
|
-
|
|
12202
|
+
emitEvent(severity, message, sourceIp, details) {
|
|
12203
|
+
const event = {
|
|
12204
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
12205
|
+
module: "network-monitor",
|
|
12206
|
+
category: "network",
|
|
12207
|
+
severity,
|
|
12208
|
+
message,
|
|
12209
|
+
source_ip: sourceIp,
|
|
12210
|
+
details
|
|
12211
|
+
};
|
|
12212
|
+
try {
|
|
12213
|
+
insertEvent(event);
|
|
12214
|
+
} catch {
|
|
12215
|
+
}
|
|
12216
|
+
this.bus.publish(event);
|
|
11953
12217
|
}
|
|
11954
|
-
|
|
11955
|
-
const
|
|
11956
|
-
|
|
11957
|
-
|
|
11958
|
-
|
|
11959
|
-
|
|
11960
|
-
|
|
12218
|
+
cleanupTrackers() {
|
|
12219
|
+
const now = Date.now();
|
|
12220
|
+
for (const [key, tracker] of this.scanTrackers) {
|
|
12221
|
+
if (now - tracker.lastSeen > this.portScanWindowMs * 2) {
|
|
12222
|
+
this.scanTrackers.delete(key);
|
|
12223
|
+
}
|
|
12224
|
+
}
|
|
11961
12225
|
}
|
|
11962
|
-
|
|
11963
|
-
|
|
11964
|
-
|
|
11965
|
-
|
|
11966
|
-
|
|
11967
|
-
|
|
11968
|
-
|
|
11969
|
-
|
|
12226
|
+
isLocalIp(ip) {
|
|
12227
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "0.0.0.0" || ip.startsWith("::ffff:127.");
|
|
12228
|
+
}
|
|
12229
|
+
};
|
|
12230
|
+
|
|
12231
|
+
// src/modules/dns-monitor/index.ts
|
|
12232
|
+
var import_node_fs17 = require("fs");
|
|
12233
|
+
var import_node_readline5 = require("readline");
|
|
12234
|
+
init_state();
|
|
12235
|
+
var DNS_LOG_SOURCES = [
|
|
12236
|
+
"/var/log/syslog",
|
|
12237
|
+
// systemd-resolved logs here
|
|
12238
|
+
"/var/log/dnsmasq.log",
|
|
12239
|
+
// dnsmasq
|
|
12240
|
+
"/var/log/named/queries.log",
|
|
12241
|
+
// bind9
|
|
12242
|
+
"/var/log/pihole.log"
|
|
12243
|
+
// Pi-hole
|
|
12244
|
+
];
|
|
12245
|
+
var DnsMonitor = class {
|
|
12246
|
+
// Shannon entropy threshold for DGA
|
|
12247
|
+
constructor(bus2) {
|
|
12248
|
+
this.bus = bus2;
|
|
12249
|
+
}
|
|
12250
|
+
bus;
|
|
12251
|
+
active = false;
|
|
12252
|
+
timers = /* @__PURE__ */ new Map();
|
|
12253
|
+
positions = /* @__PURE__ */ new Map();
|
|
12254
|
+
// Tracking windows
|
|
12255
|
+
txtQueryCounts = /* @__PURE__ */ new Map();
|
|
12256
|
+
domainBuffer = [];
|
|
12257
|
+
// Config
|
|
12258
|
+
txtRateThreshold = 20;
|
|
12259
|
+
// TXT queries per source per window
|
|
12260
|
+
txtWindowMs = 6e4;
|
|
12261
|
+
dgaBurstThreshold = 15;
|
|
12262
|
+
// unique high-entropy domains per window
|
|
12263
|
+
dgaWindowMs = 6e4;
|
|
12264
|
+
entropyThreshold = 3.5;
|
|
12265
|
+
start() {
|
|
12266
|
+
const sources = DNS_LOG_SOURCES.filter((p) => {
|
|
12267
|
+
if (!(0, import_node_fs17.existsSync)(p)) return false;
|
|
11970
12268
|
try {
|
|
11971
|
-
|
|
11972
|
-
|
|
11973
|
-
|
|
11974
|
-
|
|
12269
|
+
(0, import_node_fs17.accessSync)(p, import_node_fs17.constants.R_OK);
|
|
12270
|
+
return true;
|
|
12271
|
+
} catch {
|
|
12272
|
+
return false;
|
|
12273
|
+
}
|
|
12274
|
+
});
|
|
12275
|
+
if (sources.length === 0) return false;
|
|
12276
|
+
this.active = true;
|
|
12277
|
+
for (const src of sources) {
|
|
12278
|
+
this.tailLog(src);
|
|
12279
|
+
}
|
|
12280
|
+
setInterval(() => this.analyzeBuffer(), 1e4);
|
|
12281
|
+
return true;
|
|
12282
|
+
}
|
|
12283
|
+
stop() {
|
|
12284
|
+
for (const t of this.timers.values()) clearInterval(t);
|
|
12285
|
+
this.timers.clear();
|
|
12286
|
+
this.active = false;
|
|
12287
|
+
}
|
|
12288
|
+
isActive() {
|
|
12289
|
+
return this.active;
|
|
12290
|
+
}
|
|
12291
|
+
tailLog(path) {
|
|
12292
|
+
try {
|
|
12293
|
+
this.positions.set(path, (0, import_node_fs17.statSync)(path).size);
|
|
12294
|
+
} catch {
|
|
12295
|
+
this.positions.set(path, 0);
|
|
12296
|
+
}
|
|
12297
|
+
const timer = setInterval(() => this.pollLog(path), 2e3);
|
|
12298
|
+
this.timers.set(path, timer);
|
|
12299
|
+
}
|
|
12300
|
+
pollLog(path) {
|
|
12301
|
+
let stat;
|
|
12302
|
+
try {
|
|
12303
|
+
stat = (0, import_node_fs17.statSync)(path);
|
|
12304
|
+
} catch {
|
|
12305
|
+
return;
|
|
12306
|
+
}
|
|
12307
|
+
const prev = this.positions.get(path) ?? 0;
|
|
12308
|
+
if (stat.size < prev) {
|
|
12309
|
+
this.positions.set(path, 0);
|
|
12310
|
+
return;
|
|
12311
|
+
}
|
|
12312
|
+
if (stat.size === prev) return;
|
|
12313
|
+
const stream = (0, import_node_fs17.createReadStream)(path, { start: prev, encoding: "utf-8" });
|
|
12314
|
+
stream.on("error", () => this.positions.set(path, stat.size));
|
|
12315
|
+
const rl = (0, import_node_readline5.createInterface)({ input: stream });
|
|
12316
|
+
rl.on("line", (line) => this.parseDnsLine(line));
|
|
12317
|
+
rl.on("close", () => this.positions.set(path, stat.size));
|
|
12318
|
+
}
|
|
12319
|
+
parseDnsLine(line) {
|
|
12320
|
+
const resolvedMatch = line.match(/query\[(\w+)\]\s+(\S+)\s+from\s+(\S+)/i);
|
|
12321
|
+
if (resolvedMatch) {
|
|
12322
|
+
this.domainBuffer.push({
|
|
12323
|
+
type: resolvedMatch[1],
|
|
12324
|
+
domain: resolvedMatch[2],
|
|
12325
|
+
source_ip: resolvedMatch[3],
|
|
12326
|
+
timestamp: Date.now()
|
|
12327
|
+
});
|
|
12328
|
+
return;
|
|
12329
|
+
}
|
|
12330
|
+
const dnsmasqMatch = line.match(/query\[(\w+)\]\s+(\S+)\s+from\s+(\S+)/i);
|
|
12331
|
+
if (dnsmasqMatch) {
|
|
12332
|
+
this.domainBuffer.push({
|
|
12333
|
+
type: dnsmasqMatch[1],
|
|
12334
|
+
domain: dnsmasqMatch[2],
|
|
12335
|
+
source_ip: dnsmasqMatch[3],
|
|
12336
|
+
timestamp: Date.now()
|
|
12337
|
+
});
|
|
12338
|
+
return;
|
|
12339
|
+
}
|
|
12340
|
+
const genericMatch = line.match(/(?:query|lookup|resolve)[:\s]+(\S+)/i);
|
|
12341
|
+
if (genericMatch) {
|
|
12342
|
+
const typeMatch = line.match(/type[:\s]+(\w+)/i);
|
|
12343
|
+
this.domainBuffer.push({
|
|
12344
|
+
type: typeMatch?.[1] || "A",
|
|
12345
|
+
domain: genericMatch[1],
|
|
12346
|
+
timestamp: Date.now()
|
|
12347
|
+
});
|
|
12348
|
+
}
|
|
12349
|
+
}
|
|
12350
|
+
analyzeBuffer() {
|
|
12351
|
+
const now = Date.now();
|
|
12352
|
+
const cutoff = now - this.txtWindowMs;
|
|
12353
|
+
this.domainBuffer = this.domainBuffer.filter((q) => q.timestamp > cutoff);
|
|
12354
|
+
this.detectTunneling();
|
|
12355
|
+
this.detectDga();
|
|
12356
|
+
}
|
|
12357
|
+
detectTunneling() {
|
|
12358
|
+
const txtBySource = /* @__PURE__ */ new Map();
|
|
12359
|
+
const longLabelDomains = [];
|
|
12360
|
+
for (const q of this.domainBuffer) {
|
|
12361
|
+
if (q.type === "TXT") {
|
|
12362
|
+
const key = q.source_ip || "unknown";
|
|
12363
|
+
txtBySource.set(key, (txtBySource.get(key) || 0) + 1);
|
|
12364
|
+
}
|
|
12365
|
+
const labels = q.domain.split(".");
|
|
12366
|
+
const maxLabel = Math.max(...labels.map((l) => l.length));
|
|
12367
|
+
if (maxLabel > 50) {
|
|
12368
|
+
longLabelDomains.push(q.domain);
|
|
12369
|
+
}
|
|
12370
|
+
}
|
|
12371
|
+
for (const [source, count] of txtBySource) {
|
|
12372
|
+
if (count >= this.txtRateThreshold) {
|
|
12373
|
+
this.emitEvent(
|
|
12374
|
+
"high",
|
|
12375
|
+
`DNS tunneling indicators: ${count} TXT queries from ${source} in ${this.txtWindowMs / 1e3}s`,
|
|
12376
|
+
source !== "unknown" ? source : void 0,
|
|
12377
|
+
{ txt_query_count: count, type: "tunneling" }
|
|
12378
|
+
);
|
|
12379
|
+
}
|
|
12380
|
+
}
|
|
12381
|
+
if (longLabelDomains.length >= 5) {
|
|
12382
|
+
this.emitEvent(
|
|
12383
|
+
"high",
|
|
12384
|
+
`DNS tunneling: ${longLabelDomains.length} queries with abnormally long labels detected`,
|
|
12385
|
+
void 0,
|
|
12386
|
+
{ domains: longLabelDomains.slice(0, 5), type: "tunneling-labels" }
|
|
12387
|
+
);
|
|
12388
|
+
}
|
|
12389
|
+
}
|
|
12390
|
+
detectDga() {
|
|
12391
|
+
const highEntropyDomains = [];
|
|
12392
|
+
for (const q of this.domainBuffer) {
|
|
12393
|
+
const domain = q.domain.toLowerCase();
|
|
12394
|
+
const parts = domain.split(".");
|
|
12395
|
+
if (parts.length < 2) continue;
|
|
12396
|
+
const sld = parts[parts.length - 2];
|
|
12397
|
+
if (sld.length >= 8 && this.shannonEntropy(sld) >= this.entropyThreshold) {
|
|
12398
|
+
highEntropyDomains.push(domain);
|
|
12399
|
+
}
|
|
12400
|
+
}
|
|
12401
|
+
const unique = [...new Set(highEntropyDomains)];
|
|
12402
|
+
if (unique.length >= this.dgaBurstThreshold) {
|
|
12403
|
+
this.emitEvent(
|
|
12404
|
+
"critical",
|
|
12405
|
+
`DGA-like domain burst: ${unique.length} unique high-entropy domains detected`,
|
|
12406
|
+
void 0,
|
|
12407
|
+
{ sample_domains: unique.slice(0, 10), type: "dga", unique_count: unique.length }
|
|
12408
|
+
);
|
|
12409
|
+
}
|
|
12410
|
+
}
|
|
12411
|
+
shannonEntropy(str) {
|
|
12412
|
+
const freq = /* @__PURE__ */ new Map();
|
|
12413
|
+
for (const ch of str) {
|
|
12414
|
+
freq.set(ch, (freq.get(ch) || 0) + 1);
|
|
12415
|
+
}
|
|
12416
|
+
let entropy = 0;
|
|
12417
|
+
for (const count of freq.values()) {
|
|
12418
|
+
const p = count / str.length;
|
|
12419
|
+
if (p > 0) entropy -= p * Math.log2(p);
|
|
12420
|
+
}
|
|
12421
|
+
return entropy;
|
|
12422
|
+
}
|
|
12423
|
+
emitEvent(severity, message, sourceIp, details) {
|
|
12424
|
+
const event = {
|
|
12425
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
12426
|
+
module: "dns-monitor",
|
|
12427
|
+
category: "network",
|
|
12428
|
+
severity,
|
|
12429
|
+
message,
|
|
12430
|
+
source_ip: sourceIp,
|
|
12431
|
+
details
|
|
12432
|
+
};
|
|
12433
|
+
try {
|
|
12434
|
+
insertEvent(event);
|
|
12435
|
+
} catch {
|
|
12436
|
+
}
|
|
12437
|
+
this.bus.publish(event);
|
|
12438
|
+
}
|
|
12439
|
+
};
|
|
12440
|
+
|
|
12441
|
+
// src/daemon/module-host.ts
|
|
12442
|
+
init_state();
|
|
12443
|
+
var ModuleHost = class {
|
|
12444
|
+
constructor(bus2) {
|
|
12445
|
+
this.bus = bus2;
|
|
12446
|
+
bus2.on("event", (event) => {
|
|
12447
|
+
const mod = this.modules.get(event.module);
|
|
12448
|
+
if (mod) mod.events++;
|
|
12449
|
+
for (const hosted of this.modules.values()) {
|
|
12450
|
+
if (hosted.status !== "running" || !hosted.instance?.onEvent) continue;
|
|
12451
|
+
void hosted.instance.onEvent(event).catch((err) => {
|
|
12452
|
+
hosted.status = "error";
|
|
12453
|
+
hosted.detail = `onEvent failed: ${String(err.message || err)}`;
|
|
12454
|
+
this.bus.announceModule(hosted.name, "error", hosted.detail);
|
|
12455
|
+
});
|
|
12456
|
+
}
|
|
12457
|
+
});
|
|
12458
|
+
}
|
|
12459
|
+
bus;
|
|
12460
|
+
modules = /* @__PURE__ */ new Map();
|
|
12461
|
+
logWatcher = null;
|
|
12462
|
+
journalWatcher = null;
|
|
12463
|
+
networkMonitor = null;
|
|
12464
|
+
dnsMonitor = null;
|
|
12465
|
+
async start() {
|
|
12466
|
+
this.registerBuiltins();
|
|
12467
|
+
await this.discoverAndStartInstalled();
|
|
12468
|
+
this.logWatcher = new LogWatcher(this.bus);
|
|
12469
|
+
const watched = this.logWatcher.start();
|
|
12470
|
+
for (const modName of this.logWatcher.activeModules()) {
|
|
12471
|
+
const mod = this.modules.get(modName);
|
|
12472
|
+
if (mod) {
|
|
12473
|
+
mod.status = "running";
|
|
12474
|
+
mod.detail = `watching ${watched.length} log source(s)`;
|
|
12475
|
+
this.bus.announceModule(modName, "running", mod.detail);
|
|
12476
|
+
}
|
|
12477
|
+
}
|
|
12478
|
+
this.journalWatcher = new JournalWatcher(this.bus);
|
|
12479
|
+
if (this.journalWatcher.start()) {
|
|
12480
|
+
const mod = this.modules.get("user-journal");
|
|
12481
|
+
if (mod) {
|
|
12482
|
+
mod.status = "running";
|
|
12483
|
+
mod.detail = `tailing ${JournalWatcher.scopeArgs().includes("--user") ? "user journal" : "system journal"}`;
|
|
12484
|
+
this.bus.announceModule("user-journal", "running", mod.detail);
|
|
12485
|
+
}
|
|
12486
|
+
}
|
|
12487
|
+
this.networkMonitor = new NetworkMonitor(this.bus);
|
|
12488
|
+
if (this.networkMonitor.start()) {
|
|
12489
|
+
const nmod = this.modules.get("network-monitor");
|
|
12490
|
+
if (nmod) {
|
|
12491
|
+
nmod.status = "running";
|
|
12492
|
+
nmod.detail = "monitoring connections via conntrack/ss";
|
|
12493
|
+
this.bus.announceModule("network-monitor", "running", nmod.detail);
|
|
12494
|
+
}
|
|
12495
|
+
}
|
|
12496
|
+
this.dnsMonitor = new DnsMonitor(this.bus);
|
|
12497
|
+
if (this.dnsMonitor.start()) {
|
|
12498
|
+
const dmod = this.modules.get("dns-monitor");
|
|
12499
|
+
if (dmod) {
|
|
12500
|
+
dmod.status = "running";
|
|
12501
|
+
dmod.detail = "monitoring DNS queries";
|
|
12502
|
+
this.bus.announceModule("dns-monitor", "running", dmod.detail);
|
|
12503
|
+
}
|
|
12504
|
+
}
|
|
12505
|
+
}
|
|
12506
|
+
async stop() {
|
|
12507
|
+
this.logWatcher?.stop();
|
|
12508
|
+
this.journalWatcher?.stop();
|
|
12509
|
+
this.networkMonitor?.stop();
|
|
12510
|
+
this.dnsMonitor?.stop();
|
|
12511
|
+
for (const mod of this.modules.values()) {
|
|
12512
|
+
try {
|
|
12513
|
+
if (mod.instance && mod.status === "running") {
|
|
12514
|
+
await mod.instance.stop();
|
|
12515
|
+
}
|
|
12516
|
+
} catch (err) {
|
|
12517
|
+
mod.status = "error";
|
|
12518
|
+
mod.detail = `stop failed: ${String(err.message || err)}`;
|
|
12519
|
+
this.bus.announceModule(mod.name, "error", mod.detail);
|
|
12520
|
+
continue;
|
|
12521
|
+
}
|
|
12522
|
+
mod.status = "loaded";
|
|
12523
|
+
this.bus.announceModule(mod.name, "stopped");
|
|
12524
|
+
}
|
|
12525
|
+
}
|
|
12526
|
+
summary() {
|
|
12527
|
+
return [...this.modules.values()].map((m) => ({
|
|
12528
|
+
name: m.name,
|
|
12529
|
+
status: m.status,
|
|
12530
|
+
events: m.events,
|
|
12531
|
+
detail: m.detail
|
|
12532
|
+
}));
|
|
12533
|
+
}
|
|
12534
|
+
registerBuiltins() {
|
|
12535
|
+
const builtins = [
|
|
12536
|
+
{ name: "log-watcher", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
|
|
12537
|
+
{ name: "ssh-guard", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
|
|
12538
|
+
{ name: "user-journal", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
|
|
12539
|
+
{ name: "network-monitor", version: "0.1.0", source: "builtin", status: "loaded", events: 0 },
|
|
12540
|
+
{ name: "dns-monitor", version: "0.1.0", source: "builtin", status: "loaded", events: 0 }
|
|
12541
|
+
];
|
|
12542
|
+
for (const m of builtins) this.modules.set(m.name, m);
|
|
12543
|
+
}
|
|
12544
|
+
async discoverAndStartInstalled() {
|
|
12545
|
+
if (!(0, import_node_fs18.existsSync)(PATHS.moduleDir)) return;
|
|
12546
|
+
const configs = loadModuleConfigs(PATHS.confD);
|
|
12547
|
+
const entries = (0, import_node_fs18.readdirSync)(PATHS.moduleDir, { withFileTypes: true });
|
|
12548
|
+
for (const entry of entries) {
|
|
12549
|
+
if (!entry.isDirectory()) continue;
|
|
12550
|
+
const manifestPath = (0, import_node_path10.join)(PATHS.moduleDir, entry.name, "mod.toml");
|
|
12551
|
+
if (!(0, import_node_fs18.existsSync)(manifestPath)) continue;
|
|
12552
|
+
try {
|
|
12553
|
+
const manifest = import_toml4.default.parse((0, import_node_fs18.readFileSync)(manifestPath, "utf-8"));
|
|
12554
|
+
const name = manifest.module?.name || entry.name;
|
|
12555
|
+
const defaults = manifest.module?.config?.defaults || {};
|
|
12556
|
+
const config = {
|
|
11975
12557
|
enabled: true,
|
|
11976
12558
|
...defaults,
|
|
11977
12559
|
...configs.get(name) || {}
|
|
@@ -12031,15 +12613,15 @@ var ModuleHost = class {
|
|
|
12031
12613
|
installedEntrypoint(modulePath) {
|
|
12032
12614
|
const packageJson = (0, import_node_path10.join)(modulePath, "package.json");
|
|
12033
12615
|
const candidates = [];
|
|
12034
|
-
if ((0,
|
|
12616
|
+
if ((0, import_node_fs18.existsSync)(packageJson)) {
|
|
12035
12617
|
try {
|
|
12036
|
-
const pkg = JSON.parse((0,
|
|
12618
|
+
const pkg = JSON.parse((0, import_node_fs18.readFileSync)(packageJson, "utf-8"));
|
|
12037
12619
|
if (pkg.main) candidates.push((0, import_node_path10.join)(modulePath, pkg.main));
|
|
12038
12620
|
} catch {
|
|
12039
12621
|
}
|
|
12040
12622
|
}
|
|
12041
12623
|
candidates.push((0, import_node_path10.join)(modulePath, "dist", "index.js"), (0, import_node_path10.join)(modulePath, "index.js"));
|
|
12042
|
-
return candidates.find((candidate) => (0,
|
|
12624
|
+
return candidates.find((candidate) => (0, import_node_fs18.existsSync)(candidate)) || null;
|
|
12043
12625
|
}
|
|
12044
12626
|
isThreatCrushModule(value) {
|
|
12045
12627
|
return Boolean(
|
|
@@ -12143,6 +12725,98 @@ function smtpChannel(config) {
|
|
|
12143
12725
|
};
|
|
12144
12726
|
}
|
|
12145
12727
|
|
|
12728
|
+
// src/daemon/alerts/discord.ts
|
|
12729
|
+
var SEVERITY_RANK2 = {
|
|
12730
|
+
info: 0,
|
|
12731
|
+
low: 1,
|
|
12732
|
+
medium: 2,
|
|
12733
|
+
high: 3,
|
|
12734
|
+
critical: 4
|
|
12735
|
+
};
|
|
12736
|
+
var SEVERITY_COLORS2 = {
|
|
12737
|
+
info: 3066993,
|
|
12738
|
+
// green
|
|
12739
|
+
low: 3447003,
|
|
12740
|
+
// blue
|
|
12741
|
+
medium: 15965202,
|
|
12742
|
+
// orange
|
|
12743
|
+
high: 15158332,
|
|
12744
|
+
// red
|
|
12745
|
+
critical: 10181046
|
|
12746
|
+
// purple
|
|
12747
|
+
};
|
|
12748
|
+
function discordChannel(config) {
|
|
12749
|
+
return async (event) => {
|
|
12750
|
+
if (config.min_severity) {
|
|
12751
|
+
const eventRank = SEVERITY_RANK2[event.severity] ?? 0;
|
|
12752
|
+
const minRank = SEVERITY_RANK2[config.min_severity] ?? 0;
|
|
12753
|
+
if (eventRank < minRank) return;
|
|
12754
|
+
}
|
|
12755
|
+
const embed = {
|
|
12756
|
+
title: `${event.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F"} [${event.severity.toUpperCase()}] ${event.module}`,
|
|
12757
|
+
description: event.message,
|
|
12758
|
+
color: SEVERITY_COLORS2[event.severity] ?? 16777215,
|
|
12759
|
+
fields: [
|
|
12760
|
+
...event.source_ip ? [{ name: "Source IP", value: `\`${event.source_ip}\``, inline: true }] : [],
|
|
12761
|
+
{ name: "Category", value: event.category, inline: true },
|
|
12762
|
+
{ name: "Time", value: event.timestamp.toISOString(), inline: true }
|
|
12763
|
+
],
|
|
12764
|
+
footer: { text: "ThreatCrush Security Alert" }
|
|
12765
|
+
};
|
|
12766
|
+
await fetch(config.webhook_url, {
|
|
12767
|
+
method: "POST",
|
|
12768
|
+
headers: { "Content-Type": "application/json" },
|
|
12769
|
+
body: JSON.stringify({ embeds: [embed] })
|
|
12770
|
+
});
|
|
12771
|
+
};
|
|
12772
|
+
}
|
|
12773
|
+
|
|
12774
|
+
// src/daemon/alerts/pagerduty.ts
|
|
12775
|
+
var SEVERITY_RANK3 = {
|
|
12776
|
+
info: 0,
|
|
12777
|
+
low: 1,
|
|
12778
|
+
medium: 2,
|
|
12779
|
+
high: 3,
|
|
12780
|
+
critical: 4
|
|
12781
|
+
};
|
|
12782
|
+
var PD_SEVERITY = {
|
|
12783
|
+
info: "info",
|
|
12784
|
+
low: "info",
|
|
12785
|
+
medium: "warning",
|
|
12786
|
+
high: "error",
|
|
12787
|
+
critical: "critical"
|
|
12788
|
+
};
|
|
12789
|
+
function pagerdutyChannel(config) {
|
|
12790
|
+
return async (event) => {
|
|
12791
|
+
if (config.min_severity) {
|
|
12792
|
+
const eventRank = SEVERITY_RANK3[event.severity] ?? 0;
|
|
12793
|
+
const minRank = SEVERITY_RANK3[config.min_severity] ?? 0;
|
|
12794
|
+
if (eventRank < minRank) return;
|
|
12795
|
+
}
|
|
12796
|
+
const payload = {
|
|
12797
|
+
routing_key: config.routing_key,
|
|
12798
|
+
event_action: "trigger",
|
|
12799
|
+
payload: {
|
|
12800
|
+
summary: `[${event.severity.toUpperCase()}] ${event.module}: ${event.message}`,
|
|
12801
|
+
source: "threatcrush",
|
|
12802
|
+
severity: PD_SEVERITY[event.severity] || "warning",
|
|
12803
|
+
timestamp: event.timestamp.toISOString(),
|
|
12804
|
+
custom_details: {
|
|
12805
|
+
module: event.module,
|
|
12806
|
+
category: event.category,
|
|
12807
|
+
source_ip: event.source_ip,
|
|
12808
|
+
details: event.details
|
|
12809
|
+
}
|
|
12810
|
+
}
|
|
12811
|
+
};
|
|
12812
|
+
await fetch("https://events.pagerduty.com/v2/enqueue", {
|
|
12813
|
+
method: "POST",
|
|
12814
|
+
headers: { "Content-Type": "application/json" },
|
|
12815
|
+
body: JSON.stringify(payload)
|
|
12816
|
+
});
|
|
12817
|
+
};
|
|
12818
|
+
}
|
|
12819
|
+
|
|
12146
12820
|
// src/daemon/alerts/index.ts
|
|
12147
12821
|
var AlertDispatcher = class {
|
|
12148
12822
|
constructor(bus2, config) {
|
|
@@ -12156,6 +12830,7 @@ var AlertDispatcher = class {
|
|
|
12156
12830
|
bus;
|
|
12157
12831
|
config;
|
|
12158
12832
|
channels = [];
|
|
12833
|
+
rateLimits = /* @__PURE__ */ new Map();
|
|
12159
12834
|
bindChannels() {
|
|
12160
12835
|
const alerts = this.config.alerts || {};
|
|
12161
12836
|
for (const [name, raw] of Object.entries(alerts)) {
|
|
@@ -12170,11 +12845,31 @@ var AlertDispatcher = class {
|
|
|
12170
12845
|
if (name === "email" && typeof cfg.host === "string" && typeof cfg.from === "string") {
|
|
12171
12846
|
this.channels.push(smtpChannel(cfg));
|
|
12172
12847
|
}
|
|
12848
|
+
if (name === "discord" && typeof cfg.webhook_url === "string") {
|
|
12849
|
+
this.channels.push(discordChannel(cfg));
|
|
12850
|
+
}
|
|
12851
|
+
if (name === "pagerduty" && typeof cfg.routing_key === "string") {
|
|
12852
|
+
this.channels.push(pagerdutyChannel(cfg));
|
|
12853
|
+
}
|
|
12173
12854
|
}
|
|
12174
12855
|
}
|
|
12856
|
+
checkRateLimit(channelIdx, maxPerHour = 60) {
|
|
12857
|
+
const key = String(channelIdx);
|
|
12858
|
+
const now = Date.now();
|
|
12859
|
+
const hour = 36e5;
|
|
12860
|
+
let timestamps = this.rateLimits.get(key) || [];
|
|
12861
|
+
timestamps = timestamps.filter((t) => t > now - hour);
|
|
12862
|
+
if (timestamps.length >= maxPerHour) return false;
|
|
12863
|
+
timestamps.push(now);
|
|
12864
|
+
this.rateLimits.set(key, timestamps);
|
|
12865
|
+
return true;
|
|
12866
|
+
}
|
|
12175
12867
|
async dispatch(event) {
|
|
12176
|
-
await Promise.all(this.channels.map((ch
|
|
12177
|
-
|
|
12868
|
+
await Promise.all(this.channels.map((ch, idx) => {
|
|
12869
|
+
if (!this.checkRateLimit(idx)) return Promise.resolve();
|
|
12870
|
+
return ch(event).catch(() => {
|
|
12871
|
+
});
|
|
12872
|
+
}));
|
|
12178
12873
|
}
|
|
12179
12874
|
};
|
|
12180
12875
|
function webhookChannel(url, secret) {
|
|
@@ -12309,28 +13004,741 @@ var RunsWorker = class {
|
|
|
12309
13004
|
};
|
|
12310
13005
|
}
|
|
12311
13006
|
}
|
|
12312
|
-
async finalize(orgId, claimed, result) {
|
|
13007
|
+
async finalize(orgId, claimed, result) {
|
|
13008
|
+
try {
|
|
13009
|
+
await fetch(
|
|
13010
|
+
`${API_URL6}/api/orgs/${orgId}/properties/${claimed.property_id}/runs/${claimed.id}`,
|
|
13011
|
+
{
|
|
13012
|
+
method: "PATCH",
|
|
13013
|
+
headers: authHeaders(),
|
|
13014
|
+
body: JSON.stringify({
|
|
13015
|
+
status: result.error ? "failed" : "succeeded",
|
|
13016
|
+
findings_count: result.findings.length,
|
|
13017
|
+
severity_summary: result.severity_summary,
|
|
13018
|
+
summary: result.summary,
|
|
13019
|
+
findings: result.findings,
|
|
13020
|
+
error: result.error,
|
|
13021
|
+
source: "daemon",
|
|
13022
|
+
worker_id: workerId()
|
|
13023
|
+
})
|
|
13024
|
+
}
|
|
13025
|
+
);
|
|
13026
|
+
} catch {
|
|
13027
|
+
} finally {
|
|
13028
|
+
this.bus.announceModule("runs-worker", "idle");
|
|
13029
|
+
}
|
|
13030
|
+
}
|
|
13031
|
+
};
|
|
13032
|
+
|
|
13033
|
+
// src/daemon/rules/engine.ts
|
|
13034
|
+
var RuleEngine = class {
|
|
13035
|
+
constructor(onDetection) {
|
|
13036
|
+
this.onDetection = onDetection;
|
|
13037
|
+
}
|
|
13038
|
+
onDetection;
|
|
13039
|
+
rules = [];
|
|
13040
|
+
windows = /* @__PURE__ */ new Map();
|
|
13041
|
+
loadRules(rules) {
|
|
13042
|
+
this.rules = rules.filter((r) => r.enabled !== false);
|
|
13043
|
+
}
|
|
13044
|
+
getRules() {
|
|
13045
|
+
return [...this.rules];
|
|
13046
|
+
}
|
|
13047
|
+
evaluate(event) {
|
|
13048
|
+
const now = Date.now();
|
|
13049
|
+
for (const rule of this.rules) {
|
|
13050
|
+
if (rule.source_types.length > 0 && !rule.source_types.includes(event.module) && !rule.source_types.includes(event.category)) {
|
|
13051
|
+
continue;
|
|
13052
|
+
}
|
|
13053
|
+
if (!this.matchesCondition(event, rule.match)) continue;
|
|
13054
|
+
const windowKey = `${rule.id}:${event.source_ip || "global"}`;
|
|
13055
|
+
let window = this.windows.get(windowKey);
|
|
13056
|
+
if (!window) {
|
|
13057
|
+
window = { events: [], lastAlert: 0 };
|
|
13058
|
+
this.windows.set(windowKey, window);
|
|
13059
|
+
}
|
|
13060
|
+
window.events.push({ timestamp: now, event });
|
|
13061
|
+
const cutoff = now - rule.window_seconds * 1e3;
|
|
13062
|
+
window.events = window.events.filter((e) => e.timestamp >= cutoff);
|
|
13063
|
+
if (window.events.length < rule.threshold) continue;
|
|
13064
|
+
if (window.lastAlert > 0 && now - window.lastAlert < rule.cooldown_seconds * 1e3) continue;
|
|
13065
|
+
window.lastAlert = now;
|
|
13066
|
+
window.events = [];
|
|
13067
|
+
this.onDetection({
|
|
13068
|
+
rule_id: rule.id,
|
|
13069
|
+
severity: rule.severity,
|
|
13070
|
+
title: rule.title,
|
|
13071
|
+
description: `${rule.description} (${rule.threshold} events in ${rule.window_seconds}s)`,
|
|
13072
|
+
source_ip: event.source_ip,
|
|
13073
|
+
username: event.details?.user || void 0,
|
|
13074
|
+
raw_metadata: {
|
|
13075
|
+
rule_version: rule.version,
|
|
13076
|
+
tags: rule.tags,
|
|
13077
|
+
category: rule.category,
|
|
13078
|
+
remediation: rule.remediation
|
|
13079
|
+
}
|
|
13080
|
+
});
|
|
13081
|
+
}
|
|
13082
|
+
}
|
|
13083
|
+
matchesCondition(event, match) {
|
|
13084
|
+
const fieldValue = this.getFieldValue(event, match.field);
|
|
13085
|
+
if (fieldValue === void 0) return false;
|
|
13086
|
+
const strValue = String(fieldValue);
|
|
13087
|
+
let result = false;
|
|
13088
|
+
switch (match.operator) {
|
|
13089
|
+
case "contains":
|
|
13090
|
+
result = strValue.toLowerCase().includes(String(match.value).toLowerCase());
|
|
13091
|
+
break;
|
|
13092
|
+
case "regex":
|
|
13093
|
+
try {
|
|
13094
|
+
result = new RegExp(String(match.value), "i").test(strValue);
|
|
13095
|
+
} catch {
|
|
13096
|
+
result = false;
|
|
13097
|
+
}
|
|
13098
|
+
break;
|
|
13099
|
+
case "equals":
|
|
13100
|
+
result = strValue === String(match.value);
|
|
13101
|
+
break;
|
|
13102
|
+
case "starts_with":
|
|
13103
|
+
result = strValue.startsWith(String(match.value));
|
|
13104
|
+
break;
|
|
13105
|
+
case "ends_with":
|
|
13106
|
+
result = strValue.endsWith(String(match.value));
|
|
13107
|
+
break;
|
|
13108
|
+
}
|
|
13109
|
+
if (result && match.and) {
|
|
13110
|
+
result = match.and.every((m) => this.matchesCondition(event, m));
|
|
13111
|
+
}
|
|
13112
|
+
if (!result && match.or) {
|
|
13113
|
+
result = match.or.some((m) => this.matchesCondition(event, m));
|
|
13114
|
+
}
|
|
13115
|
+
return result;
|
|
13116
|
+
}
|
|
13117
|
+
getFieldValue(event, field) {
|
|
13118
|
+
switch (field) {
|
|
13119
|
+
case "message":
|
|
13120
|
+
return event.message;
|
|
13121
|
+
case "severity":
|
|
13122
|
+
return event.severity;
|
|
13123
|
+
case "module":
|
|
13124
|
+
return event.module;
|
|
13125
|
+
case "category":
|
|
13126
|
+
return event.category;
|
|
13127
|
+
case "source_ip":
|
|
13128
|
+
return event.source_ip;
|
|
13129
|
+
default:
|
|
13130
|
+
return event.details?.[field];
|
|
13131
|
+
}
|
|
13132
|
+
}
|
|
13133
|
+
// Periodic cleanup of stale windows
|
|
13134
|
+
cleanup() {
|
|
13135
|
+
const now = Date.now();
|
|
13136
|
+
for (const [key, window] of this.windows.entries()) {
|
|
13137
|
+
if (window.events.length === 0 && now - window.lastAlert > 36e5) {
|
|
13138
|
+
this.windows.delete(key);
|
|
13139
|
+
}
|
|
13140
|
+
}
|
|
13141
|
+
}
|
|
13142
|
+
};
|
|
13143
|
+
|
|
13144
|
+
// src/daemon/rules/loader.ts
|
|
13145
|
+
var import_node_fs19 = require("fs");
|
|
13146
|
+
var import_node_path11 = require("path");
|
|
13147
|
+
|
|
13148
|
+
// src/daemon/rules/default-rules.ts
|
|
13149
|
+
var DEFAULT_RULES = [
|
|
13150
|
+
{
|
|
13151
|
+
id: "ssh-brute-force",
|
|
13152
|
+
title: "SSH Brute Force Detected",
|
|
13153
|
+
description: "Multiple failed SSH login attempts from the same source",
|
|
13154
|
+
version: "1.0.0",
|
|
13155
|
+
category: "auth",
|
|
13156
|
+
severity: "high",
|
|
13157
|
+
source_types: ["ssh-guard", "auth"],
|
|
13158
|
+
match: {
|
|
13159
|
+
field: "message",
|
|
13160
|
+
operator: "regex",
|
|
13161
|
+
value: "failed ssh login|invalid ssh user"
|
|
13162
|
+
},
|
|
13163
|
+
threshold: 5,
|
|
13164
|
+
window_seconds: 300,
|
|
13165
|
+
cooldown_seconds: 600,
|
|
13166
|
+
tags: ["ssh", "brute-force", "credential-stuffing"],
|
|
13167
|
+
remediation: {
|
|
13168
|
+
action: "block",
|
|
13169
|
+
ttl_seconds: 3600,
|
|
13170
|
+
description: "Block source IP for 1 hour"
|
|
13171
|
+
},
|
|
13172
|
+
enabled: true
|
|
13173
|
+
},
|
|
13174
|
+
{
|
|
13175
|
+
id: "ssh-success-after-failures",
|
|
13176
|
+
title: "SSH Login After Failed Attempts",
|
|
13177
|
+
description: "Successful SSH login from an IP that had recent failures",
|
|
13178
|
+
version: "1.0.0",
|
|
13179
|
+
category: "auth",
|
|
13180
|
+
severity: "critical",
|
|
13181
|
+
source_types: ["ssh-guard", "auth"],
|
|
13182
|
+
match: {
|
|
13183
|
+
field: "message",
|
|
13184
|
+
operator: "contains",
|
|
13185
|
+
value: "SSH login accepted"
|
|
13186
|
+
},
|
|
13187
|
+
threshold: 1,
|
|
13188
|
+
window_seconds: 60,
|
|
13189
|
+
cooldown_seconds: 300,
|
|
13190
|
+
tags: ["ssh", "compromise-indicator"],
|
|
13191
|
+
enabled: true
|
|
13192
|
+
},
|
|
13193
|
+
{
|
|
13194
|
+
id: "ssh-root-login",
|
|
13195
|
+
title: "Root SSH Login Attempt",
|
|
13196
|
+
description: "Direct root login via SSH detected",
|
|
13197
|
+
version: "1.0.0",
|
|
13198
|
+
category: "auth",
|
|
13199
|
+
severity: "high",
|
|
13200
|
+
source_types: ["ssh-guard", "auth"],
|
|
13201
|
+
match: {
|
|
13202
|
+
field: "message",
|
|
13203
|
+
operator: "regex",
|
|
13204
|
+
value: "(failed|accepted).*\\broot\\b"
|
|
13205
|
+
},
|
|
13206
|
+
threshold: 1,
|
|
13207
|
+
window_seconds: 60,
|
|
13208
|
+
cooldown_seconds: 300,
|
|
13209
|
+
tags: ["ssh", "root-access"],
|
|
13210
|
+
remediation: {
|
|
13211
|
+
action: "block",
|
|
13212
|
+
ttl_seconds: 7200,
|
|
13213
|
+
description: "Block source IP attempting root login"
|
|
13214
|
+
},
|
|
13215
|
+
enabled: true
|
|
13216
|
+
},
|
|
13217
|
+
{
|
|
13218
|
+
id: "ssh-user-enumeration",
|
|
13219
|
+
title: "SSH User Enumeration",
|
|
13220
|
+
description: "Multiple SSH attempts with different usernames from same source",
|
|
13221
|
+
version: "1.0.0",
|
|
13222
|
+
category: "auth",
|
|
13223
|
+
severity: "high",
|
|
13224
|
+
source_types: ["ssh-guard", "auth"],
|
|
13225
|
+
match: {
|
|
13226
|
+
field: "message",
|
|
13227
|
+
operator: "contains",
|
|
13228
|
+
value: "Invalid SSH user"
|
|
13229
|
+
},
|
|
13230
|
+
threshold: 3,
|
|
13231
|
+
window_seconds: 120,
|
|
13232
|
+
cooldown_seconds: 600,
|
|
13233
|
+
tags: ["ssh", "enumeration", "reconnaissance"],
|
|
13234
|
+
remediation: {
|
|
13235
|
+
action: "block",
|
|
13236
|
+
ttl_seconds: 3600,
|
|
13237
|
+
description: "Block source IP performing user enumeration"
|
|
13238
|
+
},
|
|
13239
|
+
enabled: true
|
|
13240
|
+
},
|
|
13241
|
+
{
|
|
13242
|
+
id: "sudo-abuse",
|
|
13243
|
+
title: "Sudo Authentication Failure",
|
|
13244
|
+
description: "Repeated sudo authentication failures",
|
|
13245
|
+
version: "1.0.0",
|
|
13246
|
+
category: "auth",
|
|
13247
|
+
severity: "high",
|
|
13248
|
+
source_types: ["user-journal", "system"],
|
|
13249
|
+
match: {
|
|
13250
|
+
field: "message",
|
|
13251
|
+
operator: "regex",
|
|
13252
|
+
value: "sudo.*authentication failure|sudo.*incorrect password|sudo.*FAILED"
|
|
13253
|
+
},
|
|
13254
|
+
threshold: 3,
|
|
13255
|
+
window_seconds: 300,
|
|
13256
|
+
cooldown_seconds: 600,
|
|
13257
|
+
tags: ["sudo", "privilege-escalation"],
|
|
13258
|
+
enabled: true
|
|
13259
|
+
},
|
|
13260
|
+
{
|
|
13261
|
+
id: "web-sqli-attack",
|
|
13262
|
+
title: "SQL Injection Attack Detected",
|
|
13263
|
+
description: "HTTP request with SQL injection patterns",
|
|
13264
|
+
version: "1.0.0",
|
|
13265
|
+
category: "web",
|
|
13266
|
+
severity: "critical",
|
|
13267
|
+
source_types: ["log-watcher", "web"],
|
|
13268
|
+
match: {
|
|
13269
|
+
field: "message",
|
|
13270
|
+
operator: "contains",
|
|
13271
|
+
value: "Attack detected [SQLI]"
|
|
13272
|
+
},
|
|
13273
|
+
threshold: 1,
|
|
13274
|
+
window_seconds: 60,
|
|
13275
|
+
cooldown_seconds: 300,
|
|
13276
|
+
tags: ["web", "sqli", "injection"],
|
|
13277
|
+
remediation: {
|
|
13278
|
+
action: "block",
|
|
13279
|
+
ttl_seconds: 3600,
|
|
13280
|
+
description: "Block source IP performing SQL injection"
|
|
13281
|
+
},
|
|
13282
|
+
enabled: true
|
|
13283
|
+
},
|
|
13284
|
+
{
|
|
13285
|
+
id: "web-path-traversal",
|
|
13286
|
+
title: "Path Traversal Attack Detected",
|
|
13287
|
+
description: "HTTP request with path traversal patterns",
|
|
13288
|
+
version: "1.0.0",
|
|
13289
|
+
category: "web",
|
|
13290
|
+
severity: "critical",
|
|
13291
|
+
source_types: ["log-watcher", "web"],
|
|
13292
|
+
match: {
|
|
13293
|
+
field: "message",
|
|
13294
|
+
operator: "contains",
|
|
13295
|
+
value: "Attack detected [PATH_TRAVERSAL]"
|
|
13296
|
+
},
|
|
13297
|
+
threshold: 1,
|
|
13298
|
+
window_seconds: 60,
|
|
13299
|
+
cooldown_seconds: 300,
|
|
13300
|
+
tags: ["web", "path-traversal", "lfi"],
|
|
13301
|
+
remediation: {
|
|
13302
|
+
action: "block",
|
|
13303
|
+
ttl_seconds: 3600,
|
|
13304
|
+
description: "Block source IP performing path traversal"
|
|
13305
|
+
},
|
|
13306
|
+
enabled: true
|
|
13307
|
+
},
|
|
13308
|
+
{
|
|
13309
|
+
id: "web-xss-attack",
|
|
13310
|
+
title: "XSS Attack Detected",
|
|
13311
|
+
description: "HTTP request with cross-site scripting patterns",
|
|
13312
|
+
version: "1.0.0",
|
|
13313
|
+
category: "web",
|
|
13314
|
+
severity: "high",
|
|
13315
|
+
source_types: ["log-watcher", "web"],
|
|
13316
|
+
match: {
|
|
13317
|
+
field: "message",
|
|
13318
|
+
operator: "regex",
|
|
13319
|
+
value: "Attack detected \\[XSS\\]"
|
|
13320
|
+
},
|
|
13321
|
+
threshold: 1,
|
|
13322
|
+
window_seconds: 60,
|
|
13323
|
+
cooldown_seconds: 300,
|
|
13324
|
+
tags: ["web", "xss", "injection"],
|
|
13325
|
+
remediation: {
|
|
13326
|
+
action: "block",
|
|
13327
|
+
ttl_seconds: 3600,
|
|
13328
|
+
description: "Block source IP performing XSS attack"
|
|
13329
|
+
},
|
|
13330
|
+
enabled: true
|
|
13331
|
+
},
|
|
13332
|
+
{
|
|
13333
|
+
id: "web-scanner-detection",
|
|
13334
|
+
title: "Web Vulnerability Scanner Detected",
|
|
13335
|
+
description: "High volume of 4xx errors suggesting automated scanning",
|
|
13336
|
+
version: "1.0.0",
|
|
13337
|
+
category: "web",
|
|
13338
|
+
severity: "medium",
|
|
13339
|
+
source_types: ["log-watcher", "web"],
|
|
13340
|
+
match: {
|
|
13341
|
+
field: "message",
|
|
13342
|
+
operator: "regex",
|
|
13343
|
+
value: "Client error 4\\d{2}:"
|
|
13344
|
+
},
|
|
13345
|
+
threshold: 20,
|
|
13346
|
+
window_seconds: 60,
|
|
13347
|
+
cooldown_seconds: 600,
|
|
13348
|
+
tags: ["web", "scanner", "reconnaissance"],
|
|
13349
|
+
remediation: {
|
|
13350
|
+
action: "block",
|
|
13351
|
+
ttl_seconds: 1800,
|
|
13352
|
+
description: "Block automated scanner"
|
|
13353
|
+
},
|
|
13354
|
+
enabled: true
|
|
13355
|
+
},
|
|
13356
|
+
{
|
|
13357
|
+
id: "port-scan-indicator",
|
|
13358
|
+
title: "Port Scan Indicators",
|
|
13359
|
+
description: "Connection attempts to many ports from a single source",
|
|
13360
|
+
version: "1.0.0",
|
|
13361
|
+
category: "network",
|
|
13362
|
+
severity: "medium",
|
|
13363
|
+
source_types: ["network-monitor", "network"],
|
|
13364
|
+
match: {
|
|
13365
|
+
field: "message",
|
|
13366
|
+
operator: "contains",
|
|
13367
|
+
value: "port scan"
|
|
13368
|
+
},
|
|
13369
|
+
threshold: 1,
|
|
13370
|
+
window_seconds: 60,
|
|
13371
|
+
cooldown_seconds: 300,
|
|
13372
|
+
tags: ["network", "port-scan", "reconnaissance"],
|
|
13373
|
+
remediation: {
|
|
13374
|
+
action: "block",
|
|
13375
|
+
ttl_seconds: 3600,
|
|
13376
|
+
description: "Block port scanner"
|
|
13377
|
+
},
|
|
13378
|
+
enabled: true
|
|
13379
|
+
},
|
|
13380
|
+
{
|
|
13381
|
+
id: "system-critical-error",
|
|
13382
|
+
title: "Critical System Error",
|
|
13383
|
+
description: "Critical or emergency level system log message",
|
|
13384
|
+
version: "1.0.0",
|
|
13385
|
+
category: "system",
|
|
13386
|
+
severity: "critical",
|
|
13387
|
+
source_types: ["user-journal", "system"],
|
|
13388
|
+
match: {
|
|
13389
|
+
field: "severity",
|
|
13390
|
+
operator: "equals",
|
|
13391
|
+
value: "critical"
|
|
13392
|
+
},
|
|
13393
|
+
threshold: 1,
|
|
13394
|
+
window_seconds: 60,
|
|
13395
|
+
cooldown_seconds: 300,
|
|
13396
|
+
tags: ["system", "critical"],
|
|
13397
|
+
enabled: true
|
|
13398
|
+
},
|
|
13399
|
+
{
|
|
13400
|
+
id: "exploit-probe-pattern",
|
|
13401
|
+
title: "Exploit Probe Pattern",
|
|
13402
|
+
description: "HTTP requests matching common exploit probe patterns",
|
|
13403
|
+
version: "1.0.0",
|
|
13404
|
+
category: "web",
|
|
13405
|
+
severity: "high",
|
|
13406
|
+
source_types: ["log-watcher", "web"],
|
|
13407
|
+
match: {
|
|
13408
|
+
field: "message",
|
|
13409
|
+
operator: "regex",
|
|
13410
|
+
value: "Attack detected \\[(CMD_INJECTION|RCE|SSRF|XXE)\\]"
|
|
13411
|
+
},
|
|
13412
|
+
threshold: 1,
|
|
13413
|
+
window_seconds: 60,
|
|
13414
|
+
cooldown_seconds: 300,
|
|
13415
|
+
tags: ["web", "exploit", "probe"],
|
|
13416
|
+
remediation: {
|
|
13417
|
+
action: "block",
|
|
13418
|
+
ttl_seconds: 7200,
|
|
13419
|
+
description: "Block source IP performing exploit probes"
|
|
13420
|
+
},
|
|
13421
|
+
enabled: true
|
|
13422
|
+
}
|
|
13423
|
+
];
|
|
13424
|
+
|
|
13425
|
+
// src/daemon/rules/loader.ts
|
|
13426
|
+
var RULES_DIR = "/etc/threatcrush/rules.d";
|
|
13427
|
+
function loadAllRules(customDir) {
|
|
13428
|
+
const rules = [...DEFAULT_RULES];
|
|
13429
|
+
const dir = customDir || RULES_DIR;
|
|
13430
|
+
if ((0, import_node_fs19.existsSync)(dir)) {
|
|
13431
|
+
const files = (0, import_node_fs19.readdirSync)(dir).filter((f) => f.endsWith(".json"));
|
|
13432
|
+
for (const file of files) {
|
|
13433
|
+
try {
|
|
13434
|
+
const raw = (0, import_node_fs19.readFileSync)((0, import_node_path11.join)(dir, file), "utf-8");
|
|
13435
|
+
const parsed = JSON.parse(raw);
|
|
13436
|
+
const customRules = Array.isArray(parsed) ? parsed : [parsed];
|
|
13437
|
+
for (const rule of customRules) {
|
|
13438
|
+
if (!rule.id || !rule.title || !rule.match) {
|
|
13439
|
+
console.warn(`[rules] skipping invalid rule in ${file}: missing required fields`);
|
|
13440
|
+
continue;
|
|
13441
|
+
}
|
|
13442
|
+
const existingIdx = rules.findIndex((r) => r.id === rule.id);
|
|
13443
|
+
if (existingIdx >= 0) {
|
|
13444
|
+
rules[existingIdx] = { ...rules[existingIdx], ...rule };
|
|
13445
|
+
} else {
|
|
13446
|
+
rules.push(rule);
|
|
13447
|
+
}
|
|
13448
|
+
}
|
|
13449
|
+
} catch (err) {
|
|
13450
|
+
console.warn(`[rules] failed to load ${file}: ${err.message}`);
|
|
13451
|
+
}
|
|
13452
|
+
}
|
|
13453
|
+
}
|
|
13454
|
+
return rules;
|
|
13455
|
+
}
|
|
13456
|
+
|
|
13457
|
+
// src/daemon/firewall/adapters.ts
|
|
13458
|
+
var import_node_child_process6 = require("child_process");
|
|
13459
|
+
var NftablesAdapter = class {
|
|
13460
|
+
name = "nftables";
|
|
13461
|
+
table = "threatcrush";
|
|
13462
|
+
set = "blocklist";
|
|
13463
|
+
isAvailable() {
|
|
13464
|
+
const result = (0, import_node_child_process6.spawnSync)("nft", ["--version"], { stdio: "pipe" });
|
|
13465
|
+
return result.status === 0;
|
|
13466
|
+
}
|
|
13467
|
+
ensureSetup() {
|
|
13468
|
+
try {
|
|
13469
|
+
(0, import_node_child_process6.execSync)(`nft list table inet ${this.table} 2>/dev/null`, { stdio: "pipe" });
|
|
13470
|
+
} catch {
|
|
13471
|
+
(0, import_node_child_process6.execSync)(`nft add table inet ${this.table}`);
|
|
13472
|
+
(0, import_node_child_process6.execSync)(`nft add set inet ${this.table} ${this.set} '{ type ipv4_addr; flags timeout; }'`);
|
|
13473
|
+
(0, import_node_child_process6.execSync)(`nft add chain inet ${this.table} input '{ type filter hook input priority -1; policy accept; }'`);
|
|
13474
|
+
(0, import_node_child_process6.execSync)(`nft add rule inet ${this.table} input ip saddr @${this.set} drop`);
|
|
13475
|
+
}
|
|
13476
|
+
}
|
|
13477
|
+
async block(ip) {
|
|
13478
|
+
this.ensureSetup();
|
|
13479
|
+
(0, import_node_child_process6.execSync)(`nft add element inet ${this.table} ${this.set} '{ ${ip} }'`);
|
|
13480
|
+
}
|
|
13481
|
+
async unblock(ip) {
|
|
13482
|
+
try {
|
|
13483
|
+
(0, import_node_child_process6.execSync)(`nft delete element inet ${this.table} ${this.set} '{ ${ip} }'`);
|
|
13484
|
+
} catch {
|
|
13485
|
+
}
|
|
13486
|
+
}
|
|
13487
|
+
async isBlocked(ip) {
|
|
13488
|
+
try {
|
|
13489
|
+
const output = (0, import_node_child_process6.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
|
|
13490
|
+
return output.includes(ip);
|
|
13491
|
+
} catch {
|
|
13492
|
+
return false;
|
|
13493
|
+
}
|
|
13494
|
+
}
|
|
13495
|
+
async listBlocked() {
|
|
13496
|
+
try {
|
|
13497
|
+
const output = (0, import_node_child_process6.execSync)(`nft list set inet ${this.table} ${this.set}`, { encoding: "utf-8" });
|
|
13498
|
+
const match = output.match(/elements\s*=\s*\{([^}]*)\}/);
|
|
13499
|
+
if (!match) return [];
|
|
13500
|
+
return match[1].split(",").map((s) => s.trim().split(/\s/)[0]).filter(Boolean);
|
|
13501
|
+
} catch {
|
|
13502
|
+
return [];
|
|
13503
|
+
}
|
|
13504
|
+
}
|
|
13505
|
+
};
|
|
13506
|
+
var IptablesAdapter = class {
|
|
13507
|
+
name = "iptables";
|
|
13508
|
+
chain = "THREATCRUSH";
|
|
13509
|
+
isAvailable() {
|
|
13510
|
+
const result = (0, import_node_child_process6.spawnSync)("iptables", ["--version"], { stdio: "pipe" });
|
|
13511
|
+
return result.status === 0;
|
|
13512
|
+
}
|
|
13513
|
+
ensureChain() {
|
|
13514
|
+
try {
|
|
13515
|
+
(0, import_node_child_process6.execSync)(`iptables -n -L ${this.chain} 2>/dev/null`, { stdio: "pipe" });
|
|
13516
|
+
} catch {
|
|
13517
|
+
(0, import_node_child_process6.execSync)(`iptables -N ${this.chain}`);
|
|
13518
|
+
(0, import_node_child_process6.execSync)(`iptables -I INPUT 1 -j ${this.chain}`);
|
|
13519
|
+
}
|
|
13520
|
+
}
|
|
13521
|
+
async block(ip) {
|
|
13522
|
+
this.ensureChain();
|
|
13523
|
+
if (await this.isBlocked(ip)) return;
|
|
13524
|
+
(0, import_node_child_process6.execSync)(`iptables -A ${this.chain} -s ${ip} -j DROP`);
|
|
13525
|
+
}
|
|
13526
|
+
async unblock(ip) {
|
|
13527
|
+
try {
|
|
13528
|
+
(0, import_node_child_process6.execSync)(`iptables -D ${this.chain} -s ${ip} -j DROP`);
|
|
13529
|
+
} catch {
|
|
13530
|
+
}
|
|
13531
|
+
}
|
|
13532
|
+
async isBlocked(ip) {
|
|
13533
|
+
try {
|
|
13534
|
+
const output = (0, import_node_child_process6.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
|
|
13535
|
+
return output.includes(ip);
|
|
13536
|
+
} catch {
|
|
13537
|
+
return false;
|
|
13538
|
+
}
|
|
13539
|
+
}
|
|
13540
|
+
async listBlocked() {
|
|
13541
|
+
try {
|
|
13542
|
+
const output = (0, import_node_child_process6.execSync)(`iptables -n -L ${this.chain}`, { encoding: "utf-8" });
|
|
13543
|
+
const ips = [];
|
|
13544
|
+
for (const line of output.split("\n")) {
|
|
13545
|
+
const match = line.match(/DROP\s+all\s+--\s+(\d+\.\d+\.\d+\.\d+)/);
|
|
13546
|
+
if (match) ips.push(match[1]);
|
|
13547
|
+
}
|
|
13548
|
+
return ips;
|
|
13549
|
+
} catch {
|
|
13550
|
+
return [];
|
|
13551
|
+
}
|
|
13552
|
+
}
|
|
13553
|
+
};
|
|
13554
|
+
var DryRunAdapter = class {
|
|
13555
|
+
name = "dry-run";
|
|
13556
|
+
blocked = /* @__PURE__ */ new Set();
|
|
13557
|
+
isAvailable() {
|
|
13558
|
+
return true;
|
|
13559
|
+
}
|
|
13560
|
+
async block(ip) {
|
|
13561
|
+
this.blocked.add(ip);
|
|
13562
|
+
}
|
|
13563
|
+
async unblock(ip) {
|
|
13564
|
+
this.blocked.delete(ip);
|
|
13565
|
+
}
|
|
13566
|
+
async isBlocked(ip) {
|
|
13567
|
+
return this.blocked.has(ip);
|
|
13568
|
+
}
|
|
13569
|
+
async listBlocked() {
|
|
13570
|
+
return [...this.blocked];
|
|
13571
|
+
}
|
|
13572
|
+
};
|
|
13573
|
+
function detectFirewallAdapter() {
|
|
13574
|
+
const nft = new NftablesAdapter();
|
|
13575
|
+
if (nft.isAvailable()) return nft;
|
|
13576
|
+
const ipt = new IptablesAdapter();
|
|
13577
|
+
if (ipt.isAvailable()) return ipt;
|
|
13578
|
+
return new DryRunAdapter();
|
|
13579
|
+
}
|
|
13580
|
+
|
|
13581
|
+
// src/daemon/firewall/remediation.ts
|
|
13582
|
+
var import_node_fs20 = require("fs");
|
|
13583
|
+
init_state();
|
|
13584
|
+
init_paths();
|
|
13585
|
+
var DEFAULT_CONFIG2 = {
|
|
13586
|
+
enabled: true,
|
|
13587
|
+
dry_run: true,
|
|
13588
|
+
default_ttl_seconds: 3600,
|
|
13589
|
+
min_severity: "high",
|
|
13590
|
+
allowlist: ["127.0.0.1", "::1"]
|
|
13591
|
+
};
|
|
13592
|
+
var SEVERITY_RANK4 = {
|
|
13593
|
+
info: 0,
|
|
13594
|
+
low: 1,
|
|
13595
|
+
medium: 2,
|
|
13596
|
+
high: 3,
|
|
13597
|
+
critical: 4
|
|
13598
|
+
};
|
|
13599
|
+
var RemediationManager = class {
|
|
13600
|
+
constructor(adapter, bus2, config) {
|
|
13601
|
+
this.adapter = adapter;
|
|
13602
|
+
this.bus = bus2;
|
|
13603
|
+
this.config = { ...DEFAULT_CONFIG2, ...config };
|
|
13604
|
+
this.loadState();
|
|
13605
|
+
this.startExpiryWorker();
|
|
13606
|
+
}
|
|
13607
|
+
adapter;
|
|
13608
|
+
bus;
|
|
13609
|
+
config;
|
|
13610
|
+
blocklist = [];
|
|
13611
|
+
expiryTimer = null;
|
|
13612
|
+
async handleDetection(event) {
|
|
13613
|
+
if (!this.config.enabled) return;
|
|
13614
|
+
const eventRank = SEVERITY_RANK4[event.severity] ?? 0;
|
|
13615
|
+
const minRank = SEVERITY_RANK4[this.config.min_severity] ?? 3;
|
|
13616
|
+
if (eventRank < minRank) return;
|
|
13617
|
+
const ip = event.source_ip;
|
|
13618
|
+
if (!ip) return;
|
|
13619
|
+
if (this.isAllowlisted(ip)) return;
|
|
13620
|
+
if (this.blocklist.some((b) => b.ip === ip)) return;
|
|
13621
|
+
const ruleRemediation = event.details?.remediation;
|
|
13622
|
+
const ttl = ruleRemediation?.ttl_seconds || this.config.default_ttl_seconds;
|
|
13623
|
+
const ruleId = event.details?.rule_id;
|
|
13624
|
+
await this.blockIp(ip, event.message, ruleId, ttl);
|
|
13625
|
+
}
|
|
13626
|
+
async blockIp(ip, reason, ruleId, ttlSeconds) {
|
|
13627
|
+
if (this.isAllowlisted(ip)) return false;
|
|
13628
|
+
const entry = {
|
|
13629
|
+
ip,
|
|
13630
|
+
reason,
|
|
13631
|
+
rule_id: ruleId,
|
|
13632
|
+
blocked_at: Date.now(),
|
|
13633
|
+
expires_at: ttlSeconds ? Date.now() + ttlSeconds * 1e3 : void 0,
|
|
13634
|
+
dry_run: this.config.dry_run
|
|
13635
|
+
};
|
|
13636
|
+
if (!this.config.dry_run) {
|
|
13637
|
+
try {
|
|
13638
|
+
await this.adapter.block(ip);
|
|
13639
|
+
} catch (err) {
|
|
13640
|
+
this.logLine(`[firewall] EACCES or error blocking ${ip}: ${err.message}`);
|
|
13641
|
+
this.bus.publish({
|
|
13642
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
13643
|
+
module: "firewall-rules",
|
|
13644
|
+
category: "system",
|
|
13645
|
+
severity: "medium",
|
|
13646
|
+
message: `Failed to block ${ip}: ${err.message}. Ensure daemon has CAP_NET_ADMIN.`
|
|
13647
|
+
});
|
|
13648
|
+
return false;
|
|
13649
|
+
}
|
|
13650
|
+
}
|
|
13651
|
+
this.blocklist.push(entry);
|
|
13652
|
+
this.saveState();
|
|
13653
|
+
const mode = this.config.dry_run ? "[DRY-RUN] " : "";
|
|
13654
|
+
const expiryMsg = ttlSeconds ? ` (expires in ${ttlSeconds}s)` : " (permanent)";
|
|
13655
|
+
this.logLine(`[firewall] ${mode}Blocked ${ip}: ${reason}${expiryMsg}`);
|
|
13656
|
+
this.bus.publish({
|
|
13657
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
13658
|
+
module: "firewall-rules",
|
|
13659
|
+
category: "system",
|
|
13660
|
+
severity: "info",
|
|
13661
|
+
message: `${mode}Blocked ${ip}: ${reason}${expiryMsg}`,
|
|
13662
|
+
source_ip: ip,
|
|
13663
|
+
details: { action: "block", rule_id: ruleId, dry_run: this.config.dry_run, ttl_seconds: ttlSeconds }
|
|
13664
|
+
});
|
|
13665
|
+
return true;
|
|
13666
|
+
}
|
|
13667
|
+
async unblockIp(ip) {
|
|
13668
|
+
const idx = this.blocklist.findIndex((b) => b.ip === ip);
|
|
13669
|
+
if (idx < 0) return false;
|
|
13670
|
+
const entry = this.blocklist[idx];
|
|
13671
|
+
if (!entry.dry_run) {
|
|
13672
|
+
try {
|
|
13673
|
+
await this.adapter.unblock(ip);
|
|
13674
|
+
} catch (err) {
|
|
13675
|
+
this.logLine(`[firewall] Error unblocking ${ip}: ${err.message}`);
|
|
13676
|
+
return false;
|
|
13677
|
+
}
|
|
13678
|
+
}
|
|
13679
|
+
this.blocklist.splice(idx, 1);
|
|
13680
|
+
this.saveState();
|
|
13681
|
+
this.logLine(`[firewall] Unblocked ${ip}`);
|
|
13682
|
+
this.bus.publish({
|
|
13683
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
13684
|
+
module: "firewall-rules",
|
|
13685
|
+
category: "system",
|
|
13686
|
+
severity: "info",
|
|
13687
|
+
message: `Unblocked ${ip}`,
|
|
13688
|
+
source_ip: ip,
|
|
13689
|
+
details: { action: "unblock" }
|
|
13690
|
+
});
|
|
13691
|
+
return true;
|
|
13692
|
+
}
|
|
13693
|
+
isAllowlisted(ip) {
|
|
13694
|
+
return this.config.allowlist.includes(ip);
|
|
13695
|
+
}
|
|
13696
|
+
addToAllowlist(ip) {
|
|
13697
|
+
if (!this.config.allowlist.includes(ip)) {
|
|
13698
|
+
this.config.allowlist.push(ip);
|
|
13699
|
+
}
|
|
13700
|
+
}
|
|
13701
|
+
removeFromAllowlist(ip) {
|
|
13702
|
+
this.config.allowlist = this.config.allowlist.filter((a) => a !== ip);
|
|
13703
|
+
}
|
|
13704
|
+
getBlocklist() {
|
|
13705
|
+
return [...this.blocklist];
|
|
13706
|
+
}
|
|
13707
|
+
getAllowlist() {
|
|
13708
|
+
return [...this.config.allowlist];
|
|
13709
|
+
}
|
|
13710
|
+
stop() {
|
|
13711
|
+
if (this.expiryTimer) clearInterval(this.expiryTimer);
|
|
13712
|
+
this.expiryTimer = null;
|
|
13713
|
+
}
|
|
13714
|
+
startExpiryWorker() {
|
|
13715
|
+
this.expiryTimer = setInterval(() => void this.processExpiries(), 3e4);
|
|
13716
|
+
}
|
|
13717
|
+
async processExpiries() {
|
|
13718
|
+
const now = Date.now();
|
|
13719
|
+
const expired = this.blocklist.filter((b) => b.expires_at && b.expires_at <= now);
|
|
13720
|
+
for (const entry of expired) {
|
|
13721
|
+
await this.unblockIp(entry.ip);
|
|
13722
|
+
}
|
|
13723
|
+
}
|
|
13724
|
+
loadState() {
|
|
13725
|
+
try {
|
|
13726
|
+
const saved = getModuleState("firewall-rules", "blocklist");
|
|
13727
|
+
if (Array.isArray(saved)) this.blocklist = saved;
|
|
13728
|
+
} catch {
|
|
13729
|
+
}
|
|
13730
|
+
}
|
|
13731
|
+
saveState() {
|
|
12313
13732
|
try {
|
|
12314
|
-
|
|
12315
|
-
|
|
12316
|
-
|
|
12317
|
-
|
|
12318
|
-
|
|
12319
|
-
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
severity_summary: result.severity_summary,
|
|
12323
|
-
summary: result.summary,
|
|
12324
|
-
findings: result.findings,
|
|
12325
|
-
error: result.error,
|
|
12326
|
-
source: "daemon",
|
|
12327
|
-
worker_id: workerId()
|
|
12328
|
-
})
|
|
12329
|
-
}
|
|
12330
|
-
);
|
|
13733
|
+
setModuleState("firewall-rules", "blocklist", this.blocklist);
|
|
13734
|
+
} catch {
|
|
13735
|
+
}
|
|
13736
|
+
}
|
|
13737
|
+
logLine(line) {
|
|
13738
|
+
try {
|
|
13739
|
+
(0, import_node_fs20.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
13740
|
+
`);
|
|
12331
13741
|
} catch {
|
|
12332
|
-
} finally {
|
|
12333
|
-
this.bus.announceModule("runs-worker", "idle");
|
|
12334
13742
|
}
|
|
12335
13743
|
}
|
|
12336
13744
|
};
|
|
@@ -12387,7 +13795,7 @@ async function flushTelemetry(timeoutMs = 2e3) {
|
|
|
12387
13795
|
// src/daemon/index.ts
|
|
12388
13796
|
function readVersion() {
|
|
12389
13797
|
try {
|
|
12390
|
-
const pkg = JSON.parse((0,
|
|
13798
|
+
const pkg = JSON.parse((0, import_node_fs21.readFileSync)((0, import_node_path12.join)(__dirname, "..", "package.json"), "utf-8"));
|
|
12391
13799
|
return pkg.version || "0.0.0";
|
|
12392
13800
|
} catch {
|
|
12393
13801
|
return "0.0.0";
|
|
@@ -12395,7 +13803,7 @@ function readVersion() {
|
|
|
12395
13803
|
}
|
|
12396
13804
|
function logLine(line) {
|
|
12397
13805
|
try {
|
|
12398
|
-
(0,
|
|
13806
|
+
(0, import_node_fs21.appendFileSync)(PATHS.logFile, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
12399
13807
|
`);
|
|
12400
13808
|
} catch {
|
|
12401
13809
|
}
|
|
@@ -12423,12 +13831,42 @@ async function runDaemon() {
|
|
|
12423
13831
|
} catch (err) {
|
|
12424
13832
|
logLine(`[daemon] state db unavailable: ${err.message}`);
|
|
12425
13833
|
}
|
|
12426
|
-
const config = loadConfig((0,
|
|
13834
|
+
const config = loadConfig((0, import_node_fs21.existsSync)(PATHS.configFile) ? PATHS.configFile : void 0);
|
|
12427
13835
|
bus.on("event", (event) => {
|
|
12428
13836
|
logLine(`[event] ${event.severity} ${event.module} ${event.message}`);
|
|
12429
13837
|
});
|
|
12430
13838
|
const moduleHost = new ModuleHost(bus);
|
|
12431
13839
|
await moduleHost.start();
|
|
13840
|
+
const ruleEngine = new RuleEngine((detection) => {
|
|
13841
|
+
const event = {
|
|
13842
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
13843
|
+
module: "rule-engine",
|
|
13844
|
+
category: detection.raw_metadata?.category || "system",
|
|
13845
|
+
severity: detection.severity,
|
|
13846
|
+
message: `[DETECTION] ${detection.title}`,
|
|
13847
|
+
source_ip: detection.source_ip,
|
|
13848
|
+
details: {
|
|
13849
|
+
rule_id: detection.rule_id,
|
|
13850
|
+
username: detection.username,
|
|
13851
|
+
...detection.raw_metadata
|
|
13852
|
+
}
|
|
13853
|
+
};
|
|
13854
|
+
bus.publish(event);
|
|
13855
|
+
});
|
|
13856
|
+
ruleEngine.loadRules(loadAllRules());
|
|
13857
|
+
bus.on("event", (event) => {
|
|
13858
|
+
if (event.module !== "rule-engine") ruleEngine.evaluate(event);
|
|
13859
|
+
});
|
|
13860
|
+
logLine(`[daemon] rule engine loaded ${ruleEngine.getRules().length} rules`);
|
|
13861
|
+
setInterval(() => ruleEngine.cleanup(), 3e5);
|
|
13862
|
+
const firewallAdapter = detectFirewallAdapter();
|
|
13863
|
+
const remediation = new RemediationManager(firewallAdapter, bus, config.remediation);
|
|
13864
|
+
bus.on("event", (event) => {
|
|
13865
|
+
if (event.module !== "firewall-rules") {
|
|
13866
|
+
void remediation.handleDetection(event);
|
|
13867
|
+
}
|
|
13868
|
+
});
|
|
13869
|
+
logLine(`[daemon] firewall remediation active (backend=${firewallAdapter.name}, dry_run=${config.remediation?.dry_run ?? true})`);
|
|
12432
13870
|
new AlertDispatcher(bus, config);
|
|
12433
13871
|
const runsWorker = new RunsWorker(bus);
|
|
12434
13872
|
try {
|
|
@@ -12441,6 +13879,10 @@ async function runDaemon() {
|
|
|
12441
13879
|
logLine(`[daemon] ipc listening on ${PATHS.socket}`);
|
|
12442
13880
|
const shutdown = async (signal) => {
|
|
12443
13881
|
logLine(`[daemon] received ${signal}, shutting down`);
|
|
13882
|
+
try {
|
|
13883
|
+
remediation.stop();
|
|
13884
|
+
} catch {
|
|
13885
|
+
}
|
|
12444
13886
|
try {
|
|
12445
13887
|
runsWorker.stop();
|
|
12446
13888
|
} catch {
|
|
@@ -12481,7 +13923,7 @@ async function runDaemon() {
|
|
|
12481
13923
|
init_paths();
|
|
12482
13924
|
init_pidfile();
|
|
12483
13925
|
init_ipc_client();
|
|
12484
|
-
var DAEMON_ENTRY = (0,
|
|
13926
|
+
var DAEMON_ENTRY = (0, import_node_path13.join)(__dirname, "daemon.js");
|
|
12485
13927
|
async function daemonForeground() {
|
|
12486
13928
|
await runDaemon();
|
|
12487
13929
|
}
|
|
@@ -12492,14 +13934,29 @@ async function daemonStart() {
|
|
|
12492
13934
|
return;
|
|
12493
13935
|
}
|
|
12494
13936
|
ensureRuntimeDirs();
|
|
12495
|
-
if (!(0,
|
|
13937
|
+
if (!(0, import_node_fs22.existsSync)(DAEMON_ENTRY)) {
|
|
12496
13938
|
console.log(source_default.red(` Daemon entry not found at ${DAEMON_ENTRY}.`));
|
|
12497
13939
|
console.log(source_default.dim(" Reinstall with `threatcrush update` or `pnpm run build` in the cli package."));
|
|
12498
13940
|
return;
|
|
12499
13941
|
}
|
|
12500
|
-
|
|
12501
|
-
|
|
12502
|
-
|
|
13942
|
+
let out;
|
|
13943
|
+
let err;
|
|
13944
|
+
try {
|
|
13945
|
+
out = (0, import_node_fs23.openSync)(PATHS.logFile, "a");
|
|
13946
|
+
err = (0, import_node_fs23.openSync)(PATHS.logFile, "a");
|
|
13947
|
+
} catch (e) {
|
|
13948
|
+
const code = e.code;
|
|
13949
|
+
console.log(source_default.red(` \u2717 Cannot write daemon log at ${PATHS.logFile} (${code ?? "error"}).`));
|
|
13950
|
+
if (code === "EACCES") {
|
|
13951
|
+
console.log(
|
|
13952
|
+
source_default.dim(
|
|
13953
|
+
PATHS.mode === "system" ? " Run as root (sudo) to use system paths, or run without sudo to use ~/.threatcrush." : ` Fix permissions on ${PATHS.logDir} (it should be owned by your user).`
|
|
13954
|
+
)
|
|
13955
|
+
);
|
|
13956
|
+
}
|
|
13957
|
+
return;
|
|
13958
|
+
}
|
|
13959
|
+
const child = (0, import_node_child_process7.spawn)(process.execPath, [DAEMON_ENTRY], {
|
|
12503
13960
|
detached: true,
|
|
12504
13961
|
stdio: ["ignore", out, err],
|
|
12505
13962
|
env: { ...process.env, THREATCRUSH_DAEMON: "1" }
|
|
@@ -12564,27 +14021,27 @@ async function daemonStop() {
|
|
|
12564
14021
|
}
|
|
12565
14022
|
|
|
12566
14023
|
// src/commands/service.ts
|
|
12567
|
-
var
|
|
12568
|
-
var
|
|
12569
|
-
var
|
|
14024
|
+
var import_node_child_process8 = require("child_process");
|
|
14025
|
+
var import_node_fs24 = require("fs");
|
|
14026
|
+
var import_node_path14 = require("path");
|
|
12570
14027
|
var UNIT_PATH = "/etc/systemd/system/threatcrushd.service";
|
|
12571
14028
|
function resolveTemplate() {
|
|
12572
|
-
const templatePath = (0,
|
|
12573
|
-
if (!(0,
|
|
14029
|
+
const templatePath = (0, import_node_path14.join)(__dirname, "systemd", "threatcrushd.service");
|
|
14030
|
+
if (!(0, import_node_fs24.existsSync)(templatePath)) {
|
|
12574
14031
|
throw new Error(`systemd unit template not found at ${templatePath}`);
|
|
12575
14032
|
}
|
|
12576
|
-
return (0,
|
|
14033
|
+
return (0, import_node_fs24.readFileSync)(templatePath, "utf-8");
|
|
12577
14034
|
}
|
|
12578
14035
|
function resolveBinPath() {
|
|
12579
14036
|
const arg = process.argv[1];
|
|
12580
|
-
if (arg && (0,
|
|
14037
|
+
if (arg && (0, import_node_fs24.existsSync)(arg)) return arg;
|
|
12581
14038
|
try {
|
|
12582
|
-
return (0,
|
|
14039
|
+
return (0, import_node_child_process8.execSync)("command -v threatcrush", { encoding: "utf-8" }).trim();
|
|
12583
14040
|
} catch {
|
|
12584
14041
|
return "threatcrush";
|
|
12585
14042
|
}
|
|
12586
14043
|
}
|
|
12587
|
-
function
|
|
14044
|
+
function isRoot2() {
|
|
12588
14045
|
return typeof process.getuid === "function" && process.getuid() === 0;
|
|
12589
14046
|
}
|
|
12590
14047
|
async function installServiceCommand() {
|
|
@@ -12593,16 +14050,17 @@ async function installServiceCommand() {
|
|
|
12593
14050
|
console.log(source_default.yellow(" systemd install is only supported on Linux."));
|
|
12594
14051
|
return;
|
|
12595
14052
|
}
|
|
12596
|
-
if (!
|
|
14053
|
+
if (!isRoot2()) {
|
|
12597
14054
|
console.log(source_default.red(" Must run as root (try `sudo threatcrush install-service`)."));
|
|
12598
14055
|
return;
|
|
12599
14056
|
}
|
|
12600
14057
|
const unit = resolveTemplate().replace("{{BIN_PATH}}", resolveBinPath());
|
|
12601
|
-
(0,
|
|
14058
|
+
(0, import_node_fs24.writeFileSync)(UNIT_PATH, unit, { mode: 420 });
|
|
12602
14059
|
console.log(source_default.green(` \u2713 Installed unit file: ${UNIT_PATH}`));
|
|
14060
|
+
ensureSystemDirs();
|
|
12603
14061
|
try {
|
|
12604
|
-
(0,
|
|
12605
|
-
(0,
|
|
14062
|
+
(0, import_node_child_process8.execSync)("systemctl daemon-reload", { stdio: "inherit" });
|
|
14063
|
+
(0, import_node_child_process8.execSync)("systemctl enable threatcrushd.service", { stdio: "inherit" });
|
|
12606
14064
|
console.log(source_default.green(" \u2713 Service enabled on boot."));
|
|
12607
14065
|
console.log(source_default.dim(" Start now with: systemctl start threatcrushd"));
|
|
12608
14066
|
console.log(source_default.dim(" View logs with: journalctl -u threatcrushd -f"));
|
|
@@ -12610,33 +14068,62 @@ async function installServiceCommand() {
|
|
|
12610
14068
|
console.log(source_default.yellow(` ! systemctl error: ${err.message}`));
|
|
12611
14069
|
}
|
|
12612
14070
|
}
|
|
14071
|
+
function ensureSystemDirs() {
|
|
14072
|
+
const dirs = [
|
|
14073
|
+
{ path: "/etc/threatcrush" },
|
|
14074
|
+
{ path: "/etc/threatcrush/modules", sticky: true },
|
|
14075
|
+
{ path: "/etc/threatcrush/threatcrushd.conf.d" },
|
|
14076
|
+
{ path: "/var/log/threatcrush" },
|
|
14077
|
+
{ path: "/var/lib/threatcrush" },
|
|
14078
|
+
{ path: "/var/run/threatcrush" }
|
|
14079
|
+
];
|
|
14080
|
+
let admGid = null;
|
|
14081
|
+
try {
|
|
14082
|
+
admGid = (0, import_node_fs24.statSync)("/var/log/auth.log").gid;
|
|
14083
|
+
} catch {
|
|
14084
|
+
}
|
|
14085
|
+
for (const { path, sticky } of dirs) {
|
|
14086
|
+
try {
|
|
14087
|
+
(0, import_node_fs24.mkdirSync)(path, { recursive: true });
|
|
14088
|
+
} catch {
|
|
14089
|
+
}
|
|
14090
|
+
if (admGid !== null) {
|
|
14091
|
+
try {
|
|
14092
|
+
(0, import_node_fs24.chmodSync)(path, sticky ? 1533 : 509);
|
|
14093
|
+
(0, import_node_child_process8.execSync)(`chgrp adm ${path}`, { stdio: "ignore" });
|
|
14094
|
+
} catch {
|
|
14095
|
+
}
|
|
14096
|
+
}
|
|
14097
|
+
}
|
|
14098
|
+
console.log(source_default.green(" \u2713 Runtime dirs prepared (group `adm` may install modules / edit config without sudo)."));
|
|
14099
|
+
}
|
|
12613
14100
|
async function uninstallServiceCommand() {
|
|
12614
14101
|
banner();
|
|
12615
14102
|
if (process.platform !== "linux") {
|
|
12616
14103
|
console.log(source_default.yellow(" systemd uninstall is only supported on Linux."));
|
|
12617
14104
|
return;
|
|
12618
14105
|
}
|
|
12619
|
-
if (!
|
|
14106
|
+
if (!isRoot2()) {
|
|
12620
14107
|
console.log(source_default.red(" Must run as root (try `sudo threatcrush uninstall-service`)."));
|
|
12621
14108
|
return;
|
|
12622
14109
|
}
|
|
12623
14110
|
try {
|
|
12624
|
-
(0,
|
|
14111
|
+
(0, import_node_child_process8.execSync)("systemctl stop threatcrushd.service", { stdio: "inherit" });
|
|
12625
14112
|
} catch {
|
|
12626
14113
|
}
|
|
12627
14114
|
try {
|
|
12628
|
-
(0,
|
|
14115
|
+
(0, import_node_child_process8.execSync)("systemctl disable threatcrushd.service", { stdio: "inherit" });
|
|
12629
14116
|
} catch {
|
|
12630
14117
|
}
|
|
12631
14118
|
try {
|
|
12632
|
-
if ((0,
|
|
12633
|
-
(0,
|
|
14119
|
+
if ((0, import_node_fs24.existsSync)(UNIT_PATH)) {
|
|
14120
|
+
(0, import_node_child_process8.execSync)(`rm -f ${UNIT_PATH}`);
|
|
12634
14121
|
console.log(source_default.green(` \u2713 Removed unit file: ${UNIT_PATH}`));
|
|
12635
14122
|
}
|
|
12636
14123
|
} catch {
|
|
12637
14124
|
}
|
|
12638
14125
|
try {
|
|
12639
|
-
(0,
|
|
14126
|
+
(0, import_node_child_process8.execSync)("systemctl daemon-reload", { stdio: "inherit" });
|
|
12640
14127
|
} catch {
|
|
12641
14128
|
}
|
|
12642
14129
|
console.log(source_default.green(" \u2713 threatcrushd service removed."));
|
|
@@ -12728,13 +14215,13 @@ function welcomeCommand() {
|
|
|
12728
14215
|
}
|
|
12729
14216
|
|
|
12730
14217
|
// src/commands/properties.ts
|
|
12731
|
-
var
|
|
12732
|
-
var
|
|
12733
|
-
var
|
|
14218
|
+
var import_node_fs25 = require("fs");
|
|
14219
|
+
var import_node_path15 = require("path");
|
|
14220
|
+
var import_node_readline6 = __toESM(require("readline"));
|
|
12734
14221
|
var API_URL7 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
|
|
12735
14222
|
var KINDS = ["url", "api", "domain", "ip", "repo"];
|
|
12736
14223
|
function prompt2(question) {
|
|
12737
|
-
const rl =
|
|
14224
|
+
const rl = import_node_readline6.default.createInterface({ input: process.stdin, output: process.stdout });
|
|
12738
14225
|
return new Promise((resolve3) => rl.question(question, (answer) => {
|
|
12739
14226
|
rl.close();
|
|
12740
14227
|
resolve3(answer.trim());
|
|
@@ -13058,8 +14545,8 @@ async function propertiesRunsCommand(opts) {
|
|
|
13058
14545
|
}
|
|
13059
14546
|
}
|
|
13060
14547
|
function parseImportFile(path) {
|
|
13061
|
-
const ext = (0,
|
|
13062
|
-
const raw = (0,
|
|
14548
|
+
const ext = (0, import_node_path15.extname)(path).toLowerCase();
|
|
14549
|
+
const raw = (0, import_node_fs25.readFileSync)(path, "utf-8");
|
|
13063
14550
|
if (ext === ".json") {
|
|
13064
14551
|
const parsed = JSON.parse(raw);
|
|
13065
14552
|
if (!Array.isArray(parsed)) throw new Error("JSON must be an array of objects");
|
|
@@ -13173,11 +14660,556 @@ async function propertiesImportCommand(filePath, opts) {
|
|
|
13173
14660
|
`);
|
|
13174
14661
|
}
|
|
13175
14662
|
|
|
14663
|
+
// src/commands/rules.ts
|
|
14664
|
+
async function rulesListCommand() {
|
|
14665
|
+
banner();
|
|
14666
|
+
const rules = loadAllRules();
|
|
14667
|
+
console.log(source_default.green.bold(" Detection Rules"));
|
|
14668
|
+
console.log(source_default.gray(" " + "\u2500".repeat(70)));
|
|
14669
|
+
console.log(
|
|
14670
|
+
source_default.gray(" ") + source_default.white.bold("ID".padEnd(28)) + source_default.white.bold("Severity".padEnd(12)) + source_default.white.bold("Category".padEnd(12)) + source_default.white.bold("Threshold".padEnd(12)) + source_default.white.bold("Title")
|
|
14671
|
+
);
|
|
14672
|
+
console.log(source_default.gray(" " + "\u2500".repeat(70)));
|
|
14673
|
+
for (const rule of rules) {
|
|
14674
|
+
const sevColor = rule.severity === "critical" ? source_default.red : rule.severity === "high" ? source_default.red : rule.severity === "medium" ? source_default.yellow : source_default.green;
|
|
14675
|
+
console.log(
|
|
14676
|
+
source_default.gray(" ") + source_default.white(rule.id.padEnd(28)) + sevColor(rule.severity.padEnd(12)) + source_default.gray(rule.category.padEnd(12)) + source_default.white(String(rule.threshold).padEnd(12)) + source_default.gray(rule.title)
|
|
14677
|
+
);
|
|
14678
|
+
}
|
|
14679
|
+
console.log();
|
|
14680
|
+
console.log(source_default.gray(` ${rules.length} rule(s) loaded`));
|
|
14681
|
+
console.log(source_default.gray(` Custom rules: /etc/threatcrush/rules.d/*.json`));
|
|
14682
|
+
console.log();
|
|
14683
|
+
}
|
|
14684
|
+
async function rulesShowCommand(ruleId) {
|
|
14685
|
+
banner();
|
|
14686
|
+
const rules = loadAllRules();
|
|
14687
|
+
const rule = rules.find((r) => r.id === ruleId);
|
|
14688
|
+
if (!rule) {
|
|
14689
|
+
console.log(source_default.red(` Rule not found: ${ruleId}`));
|
|
14690
|
+
console.log(source_default.gray(" Run `threatcrush rules list` to see available rules.\n"));
|
|
14691
|
+
return;
|
|
14692
|
+
}
|
|
14693
|
+
console.log(source_default.green.bold(` Rule: ${rule.id}`));
|
|
14694
|
+
console.log(source_default.gray(" " + "\u2500".repeat(60)));
|
|
14695
|
+
console.log(` Title: ${source_default.white(rule.title)}`);
|
|
14696
|
+
console.log(` Description: ${source_default.gray(rule.description)}`);
|
|
14697
|
+
console.log(` Version: ${source_default.gray(rule.version)}`);
|
|
14698
|
+
console.log(` Category: ${source_default.gray(rule.category)}`);
|
|
14699
|
+
console.log(` Severity: ${source_default.yellow(rule.severity)}`);
|
|
14700
|
+
console.log(` Source types: ${source_default.gray(rule.source_types.join(", "))}`);
|
|
14701
|
+
console.log(` Threshold: ${source_default.white(String(rule.threshold))} events in ${source_default.white(String(rule.window_seconds))}s`);
|
|
14702
|
+
console.log(` Cooldown: ${source_default.gray(String(rule.cooldown_seconds))}s`);
|
|
14703
|
+
console.log(` Tags: ${source_default.gray(rule.tags.join(", "))}`);
|
|
14704
|
+
console.log(` Enabled: ${rule.enabled ? source_default.green("yes") : source_default.red("no")}`);
|
|
14705
|
+
if (rule.remediation) {
|
|
14706
|
+
console.log(` Remediation: ${source_default.gray(rule.remediation.description || rule.remediation.action || "none")}`);
|
|
14707
|
+
if (rule.remediation.ttl_seconds) {
|
|
14708
|
+
console.log(` Block TTL: ${source_default.gray(String(rule.remediation.ttl_seconds))}s`);
|
|
14709
|
+
}
|
|
14710
|
+
}
|
|
14711
|
+
console.log();
|
|
14712
|
+
console.log(source_default.gray(" Match condition:"));
|
|
14713
|
+
console.log(source_default.gray(` ${rule.match.field} ${rule.match.operator} "${rule.match.value}"`));
|
|
14714
|
+
console.log();
|
|
14715
|
+
}
|
|
14716
|
+
async function rulesCommand(opts) {
|
|
14717
|
+
const action = opts.action || "list";
|
|
14718
|
+
switch (action) {
|
|
14719
|
+
case "list":
|
|
14720
|
+
case "ls":
|
|
14721
|
+
await rulesListCommand();
|
|
14722
|
+
break;
|
|
14723
|
+
case "show":
|
|
14724
|
+
case "info":
|
|
14725
|
+
if (!opts.id) {
|
|
14726
|
+
console.log(source_default.red(" Rule ID required. Usage: threatcrush rules show <rule-id>\n"));
|
|
14727
|
+
return;
|
|
14728
|
+
}
|
|
14729
|
+
await rulesShowCommand(opts.id);
|
|
14730
|
+
break;
|
|
14731
|
+
default:
|
|
14732
|
+
console.log(source_default.yellow(` Unknown action: ${action}`));
|
|
14733
|
+
console.log(source_default.gray(" Available: list, show\n"));
|
|
14734
|
+
break;
|
|
14735
|
+
}
|
|
14736
|
+
}
|
|
14737
|
+
|
|
14738
|
+
// src/commands/harden.ts
|
|
14739
|
+
var import_node_fs26 = require("fs");
|
|
14740
|
+
var import_node_child_process9 = require("child_process");
|
|
14741
|
+
function tryExec(cmd) {
|
|
14742
|
+
try {
|
|
14743
|
+
return (0, import_node_child_process9.execSync)(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
14744
|
+
} catch {
|
|
14745
|
+
return null;
|
|
14746
|
+
}
|
|
14747
|
+
}
|
|
14748
|
+
function tryRead(path) {
|
|
14749
|
+
try {
|
|
14750
|
+
return (0, import_node_fs26.readFileSync)(path, "utf-8");
|
|
14751
|
+
} catch {
|
|
14752
|
+
return null;
|
|
14753
|
+
}
|
|
14754
|
+
}
|
|
14755
|
+
function checkSshPasswordAuth() {
|
|
14756
|
+
const config = tryRead("/etc/ssh/sshd_config");
|
|
14757
|
+
if (!config) {
|
|
14758
|
+
return {
|
|
14759
|
+
key: "ssh-password-auth",
|
|
14760
|
+
status: "warn",
|
|
14761
|
+
severity: "medium",
|
|
14762
|
+
title: "SSH Password Auth",
|
|
14763
|
+
explanation: "Could not read /etc/ssh/sshd_config to check password authentication setting.",
|
|
14764
|
+
recommendation: "Ensure PasswordAuthentication is set to no in sshd_config."
|
|
14765
|
+
};
|
|
14766
|
+
}
|
|
14767
|
+
const match = config.match(/^\s*PasswordAuthentication\s+(yes|no)/mi);
|
|
14768
|
+
if (!match || match[1] === "yes") {
|
|
14769
|
+
return {
|
|
14770
|
+
key: "ssh-password-auth",
|
|
14771
|
+
status: "fail",
|
|
14772
|
+
severity: "high",
|
|
14773
|
+
title: "SSH Password Authentication Enabled",
|
|
14774
|
+
explanation: "Password authentication is enabled for SSH, making it vulnerable to brute-force attacks.",
|
|
14775
|
+
recommendation: 'Set "PasswordAuthentication no" in /etc/ssh/sshd_config and restart sshd. Use key-based auth instead.'
|
|
14776
|
+
};
|
|
14777
|
+
}
|
|
14778
|
+
return {
|
|
14779
|
+
key: "ssh-password-auth",
|
|
14780
|
+
status: "pass",
|
|
14781
|
+
severity: "high",
|
|
14782
|
+
title: "SSH Password Authentication Disabled",
|
|
14783
|
+
explanation: "Password authentication is disabled for SSH. Key-based auth is enforced."
|
|
14784
|
+
};
|
|
14785
|
+
}
|
|
14786
|
+
function checkSshRootLogin() {
|
|
14787
|
+
const config = tryRead("/etc/ssh/sshd_config");
|
|
14788
|
+
if (!config) {
|
|
14789
|
+
return {
|
|
14790
|
+
key: "ssh-root-login",
|
|
14791
|
+
status: "warn",
|
|
14792
|
+
severity: "high",
|
|
14793
|
+
title: "SSH Root Login",
|
|
14794
|
+
explanation: "Could not read sshd_config.",
|
|
14795
|
+
recommendation: 'Set "PermitRootLogin no" in /etc/ssh/sshd_config.'
|
|
14796
|
+
};
|
|
14797
|
+
}
|
|
14798
|
+
const match = config.match(/^\s*PermitRootLogin\s+(\S+)/mi);
|
|
14799
|
+
if (!match || match[1] === "yes") {
|
|
14800
|
+
return {
|
|
14801
|
+
key: "ssh-root-login",
|
|
14802
|
+
status: "fail",
|
|
14803
|
+
severity: "high",
|
|
14804
|
+
title: "Root SSH Login Enabled",
|
|
14805
|
+
explanation: "Direct root login via SSH is permitted. Attackers frequently target root.",
|
|
14806
|
+
recommendation: 'Set "PermitRootLogin no" or "PermitRootLogin prohibit-password" in /etc/ssh/sshd_config.'
|
|
14807
|
+
};
|
|
14808
|
+
}
|
|
14809
|
+
return {
|
|
14810
|
+
key: "ssh-root-login",
|
|
14811
|
+
status: "pass",
|
|
14812
|
+
severity: "high",
|
|
14813
|
+
title: "Root SSH Login Restricted",
|
|
14814
|
+
explanation: `PermitRootLogin is set to "${match[1]}".`
|
|
14815
|
+
};
|
|
14816
|
+
}
|
|
14817
|
+
function checkSshWeakConfig() {
|
|
14818
|
+
const config = tryRead("/etc/ssh/sshd_config");
|
|
14819
|
+
if (!config) {
|
|
14820
|
+
return {
|
|
14821
|
+
key: "ssh-weak-config",
|
|
14822
|
+
status: "warn",
|
|
14823
|
+
severity: "medium",
|
|
14824
|
+
title: "SSH Configuration",
|
|
14825
|
+
explanation: "Could not read sshd_config."
|
|
14826
|
+
};
|
|
14827
|
+
}
|
|
14828
|
+
const issues = [];
|
|
14829
|
+
if (!/^\s*Protocol\s+2/mi.test(config) && !/^\s*#\s*Protocol/mi.test(config)) {
|
|
14830
|
+
if (/^\s*Protocol\s+1/mi.test(config)) issues.push("Protocol 1 is enabled");
|
|
14831
|
+
}
|
|
14832
|
+
if (/^\s*X11Forwarding\s+yes/mi.test(config)) issues.push("X11 forwarding is enabled");
|
|
14833
|
+
const maxAuth = config.match(/^\s*MaxAuthTries\s+(\d+)/mi);
|
|
14834
|
+
if (maxAuth && parseInt(maxAuth[1]) > 6) issues.push(`MaxAuthTries is high (${maxAuth[1]})`);
|
|
14835
|
+
if (issues.length > 0) {
|
|
14836
|
+
return {
|
|
14837
|
+
key: "ssh-weak-config",
|
|
14838
|
+
status: "warn",
|
|
14839
|
+
severity: "medium",
|
|
14840
|
+
title: "SSH Configuration Weaknesses",
|
|
14841
|
+
explanation: `Found: ${issues.join("; ")}.`,
|
|
14842
|
+
recommendation: "Review and harden sshd_config. Disable unused features."
|
|
14843
|
+
};
|
|
14844
|
+
}
|
|
14845
|
+
return {
|
|
14846
|
+
key: "ssh-weak-config",
|
|
14847
|
+
status: "pass",
|
|
14848
|
+
severity: "medium",
|
|
14849
|
+
title: "SSH Configuration",
|
|
14850
|
+
explanation: "No obvious SSH config weaknesses found."
|
|
14851
|
+
};
|
|
14852
|
+
}
|
|
14853
|
+
function checkAutoUpdates() {
|
|
14854
|
+
const unattended = (0, import_node_fs26.existsSync)("/etc/apt/apt.conf.d/20auto-upgrades") || (0, import_node_fs26.existsSync)("/etc/apt/apt.conf.d/50unattended-upgrades");
|
|
14855
|
+
const dnfAuto = (0, import_node_fs26.existsSync)("/etc/dnf/automatic.conf");
|
|
14856
|
+
if (unattended || dnfAuto) {
|
|
14857
|
+
return {
|
|
14858
|
+
key: "auto-updates",
|
|
14859
|
+
status: "pass",
|
|
14860
|
+
severity: "high",
|
|
14861
|
+
title: "Automatic Security Updates",
|
|
14862
|
+
explanation: "Automatic security updates appear to be configured."
|
|
14863
|
+
};
|
|
14864
|
+
}
|
|
14865
|
+
return {
|
|
14866
|
+
key: "auto-updates",
|
|
14867
|
+
status: "fail",
|
|
14868
|
+
severity: "high",
|
|
14869
|
+
title: "No Automatic Security Updates",
|
|
14870
|
+
explanation: "No automatic security update mechanism detected.",
|
|
14871
|
+
recommendation: "Install and enable unattended-upgrades (Debian/Ubuntu) or dnf-automatic (RHEL/Fedora)."
|
|
14872
|
+
};
|
|
14873
|
+
}
|
|
14874
|
+
function checkFirewallActive() {
|
|
14875
|
+
const ufw = tryExec("ufw status");
|
|
14876
|
+
if (ufw && ufw.includes("active")) {
|
|
14877
|
+
return {
|
|
14878
|
+
key: "firewall-active",
|
|
14879
|
+
status: "pass",
|
|
14880
|
+
severity: "high",
|
|
14881
|
+
title: "Firewall Active (UFW)",
|
|
14882
|
+
explanation: "UFW firewall is active."
|
|
14883
|
+
};
|
|
14884
|
+
}
|
|
14885
|
+
const nft = tryExec("nft list tables");
|
|
14886
|
+
if (nft && nft.trim().length > 0) {
|
|
14887
|
+
return {
|
|
14888
|
+
key: "firewall-active",
|
|
14889
|
+
status: "pass",
|
|
14890
|
+
severity: "high",
|
|
14891
|
+
title: "Firewall Active (nftables)",
|
|
14892
|
+
explanation: "nftables has active tables."
|
|
14893
|
+
};
|
|
14894
|
+
}
|
|
14895
|
+
const ipt = tryExec("iptables -L -n");
|
|
14896
|
+
if (ipt) {
|
|
14897
|
+
const rules = ipt.split("\n").filter((l) => l.trim() && !l.startsWith("Chain") && !l.startsWith("target"));
|
|
14898
|
+
if (rules.length > 0) {
|
|
14899
|
+
return {
|
|
14900
|
+
key: "firewall-active",
|
|
14901
|
+
status: "pass",
|
|
14902
|
+
severity: "high",
|
|
14903
|
+
title: "Firewall Active (iptables)",
|
|
14904
|
+
explanation: `iptables has ${rules.length} rules.`
|
|
14905
|
+
};
|
|
14906
|
+
}
|
|
14907
|
+
}
|
|
14908
|
+
return {
|
|
14909
|
+
key: "firewall-active",
|
|
14910
|
+
status: "fail",
|
|
14911
|
+
severity: "high",
|
|
14912
|
+
title: "No Firewall Detected",
|
|
14913
|
+
explanation: "No active firewall (UFW, nftables, or iptables) detected.",
|
|
14914
|
+
recommendation: "Enable a firewall: `ufw enable` or configure nftables/iptables."
|
|
14915
|
+
};
|
|
14916
|
+
}
|
|
14917
|
+
function checkExposedPorts() {
|
|
14918
|
+
const ss = tryExec("ss -tlnp");
|
|
14919
|
+
if (!ss) {
|
|
14920
|
+
return {
|
|
14921
|
+
key: "exposed-ports",
|
|
14922
|
+
status: "warn",
|
|
14923
|
+
severity: "medium",
|
|
14924
|
+
title: "Exposed Ports",
|
|
14925
|
+
explanation: "Could not check listening ports."
|
|
14926
|
+
};
|
|
14927
|
+
}
|
|
14928
|
+
const riskyPorts = ["3306", "5432", "6379", "27017", "9200", "11211", "2375"];
|
|
14929
|
+
const exposed = [];
|
|
14930
|
+
for (const line of ss.split("\n")) {
|
|
14931
|
+
if (!line.includes("LISTEN")) continue;
|
|
14932
|
+
if (line.includes("0.0.0.0:") || line.includes(":::")) {
|
|
14933
|
+
for (const port of riskyPorts) {
|
|
14934
|
+
if (line.includes(`:${port} `) || line.includes(`:${port} `)) {
|
|
14935
|
+
exposed.push(port);
|
|
14936
|
+
}
|
|
14937
|
+
}
|
|
14938
|
+
}
|
|
14939
|
+
}
|
|
14940
|
+
if (exposed.length > 0) {
|
|
14941
|
+
const portNames = {
|
|
14942
|
+
"3306": "MySQL",
|
|
14943
|
+
"5432": "PostgreSQL",
|
|
14944
|
+
"6379": "Redis",
|
|
14945
|
+
"27017": "MongoDB",
|
|
14946
|
+
"9200": "Elasticsearch",
|
|
14947
|
+
"11211": "Memcached",
|
|
14948
|
+
"2375": "Docker"
|
|
14949
|
+
};
|
|
14950
|
+
const desc = exposed.map((p) => `${portNames[p] || p} (:${p})`).join(", ");
|
|
14951
|
+
return {
|
|
14952
|
+
key: "exposed-ports",
|
|
14953
|
+
status: "fail",
|
|
14954
|
+
severity: "high",
|
|
14955
|
+
title: "Risky Ports Exposed",
|
|
14956
|
+
explanation: `Services exposed on all interfaces: ${desc}.`,
|
|
14957
|
+
recommendation: "Bind database/cache services to 127.0.0.1 only, or restrict with firewall rules."
|
|
14958
|
+
};
|
|
14959
|
+
}
|
|
14960
|
+
return {
|
|
14961
|
+
key: "exposed-ports",
|
|
14962
|
+
status: "pass",
|
|
14963
|
+
severity: "high",
|
|
14964
|
+
title: "No Risky Ports Exposed",
|
|
14965
|
+
explanation: "No common database/cache ports are listening on all interfaces."
|
|
14966
|
+
};
|
|
14967
|
+
}
|
|
14968
|
+
function checkFail2ban() {
|
|
14969
|
+
const checkKey = "fail2ban-present";
|
|
14970
|
+
const sev = "medium";
|
|
14971
|
+
const f2bStatus = tryExec("fail2ban-client status");
|
|
14972
|
+
if (f2bStatus && f2bStatus.includes("Number of jail")) {
|
|
14973
|
+
return {
|
|
14974
|
+
key: checkKey,
|
|
14975
|
+
status: "pass",
|
|
14976
|
+
severity: sev,
|
|
14977
|
+
title: "fail2ban Active",
|
|
14978
|
+
explanation: "fail2ban is installed and running."
|
|
14979
|
+
};
|
|
14980
|
+
}
|
|
14981
|
+
if ((0, import_node_fs26.existsSync)("/etc/fail2ban/fail2ban.conf")) {
|
|
14982
|
+
return {
|
|
14983
|
+
key: checkKey,
|
|
14984
|
+
status: "warn",
|
|
14985
|
+
severity: sev,
|
|
14986
|
+
title: "fail2ban Installed but Not Running",
|
|
14987
|
+
explanation: "fail2ban is installed but does not appear to be running.",
|
|
14988
|
+
recommendation: "Start and enable fail2ban: `systemctl enable --now fail2ban`."
|
|
14989
|
+
};
|
|
14990
|
+
}
|
|
14991
|
+
return {
|
|
14992
|
+
key: checkKey,
|
|
14993
|
+
status: "warn",
|
|
14994
|
+
severity: sev,
|
|
14995
|
+
title: "fail2ban Not Installed",
|
|
14996
|
+
explanation: "fail2ban is not installed. ThreatCrush provides similar protection, but fail2ban adds defense in depth.",
|
|
14997
|
+
recommendation: "Consider installing fail2ban: `apt install fail2ban` or `dnf install fail2ban`."
|
|
14998
|
+
};
|
|
14999
|
+
}
|
|
15000
|
+
function checkWorldWritableDirs() {
|
|
15001
|
+
const sensitive = ["/etc", "/usr", "/var/log", "/boot"];
|
|
15002
|
+
const worldWritable = [];
|
|
15003
|
+
for (const dir of sensitive) {
|
|
15004
|
+
const result = tryExec(`find ${dir} -maxdepth 2 -type d -perm -0002 -not -path '*/tmp*' 2>/dev/null | head -5`);
|
|
15005
|
+
if (result && result.trim()) {
|
|
15006
|
+
worldWritable.push(...result.trim().split("\n"));
|
|
15007
|
+
}
|
|
15008
|
+
}
|
|
15009
|
+
if (worldWritable.length > 0) {
|
|
15010
|
+
return {
|
|
15011
|
+
key: "world-writable-dirs",
|
|
15012
|
+
status: "fail",
|
|
15013
|
+
severity: "medium",
|
|
15014
|
+
title: "World-Writable Directories Found",
|
|
15015
|
+
explanation: `Found ${worldWritable.length} world-writable directories in sensitive locations: ${worldWritable.slice(0, 3).join(", ")}${worldWritable.length > 3 ? "..." : ""}`,
|
|
15016
|
+
recommendation: "Remove world-writable permission: `chmod o-w <dir>`."
|
|
15017
|
+
};
|
|
15018
|
+
}
|
|
15019
|
+
return {
|
|
15020
|
+
key: "world-writable-dirs",
|
|
15021
|
+
status: "pass",
|
|
15022
|
+
severity: "medium",
|
|
15023
|
+
title: "No World-Writable Directories",
|
|
15024
|
+
explanation: "No world-writable directories found in sensitive locations."
|
|
15025
|
+
};
|
|
15026
|
+
}
|
|
15027
|
+
function checkRiskyServices() {
|
|
15028
|
+
const risky = ["telnet", "rsh", "rlogin", "rexec", "tftp"];
|
|
15029
|
+
const found = [];
|
|
15030
|
+
for (const svc of risky) {
|
|
15031
|
+
const result = tryExec(`systemctl is-active ${svc}.socket ${svc}.service 2>/dev/null`);
|
|
15032
|
+
if (result && result.trim() === "active") {
|
|
15033
|
+
found.push(svc);
|
|
15034
|
+
}
|
|
15035
|
+
}
|
|
15036
|
+
if (found.length > 0) {
|
|
15037
|
+
return {
|
|
15038
|
+
key: "risky-services",
|
|
15039
|
+
status: "fail",
|
|
15040
|
+
severity: "critical",
|
|
15041
|
+
title: "Risky Services Running",
|
|
15042
|
+
explanation: `Insecure services are active: ${found.join(", ")}.`,
|
|
15043
|
+
recommendation: `Disable and remove insecure services: \`systemctl disable --now ${found.join(" ")}\`.`
|
|
15044
|
+
};
|
|
15045
|
+
}
|
|
15046
|
+
return {
|
|
15047
|
+
key: "risky-services",
|
|
15048
|
+
status: "pass",
|
|
15049
|
+
severity: "critical",
|
|
15050
|
+
title: "No Risky Services",
|
|
15051
|
+
explanation: "No known insecure services (telnet, rsh, etc.) are running."
|
|
15052
|
+
};
|
|
15053
|
+
}
|
|
15054
|
+
function runAllChecks() {
|
|
15055
|
+
return [
|
|
15056
|
+
checkSshPasswordAuth(),
|
|
15057
|
+
checkSshRootLogin(),
|
|
15058
|
+
checkSshWeakConfig(),
|
|
15059
|
+
checkAutoUpdates(),
|
|
15060
|
+
checkFirewallActive(),
|
|
15061
|
+
checkExposedPorts(),
|
|
15062
|
+
checkFail2ban(),
|
|
15063
|
+
checkWorldWritableDirs(),
|
|
15064
|
+
checkRiskyServices()
|
|
15065
|
+
];
|
|
15066
|
+
}
|
|
15067
|
+
function computeScore(results) {
|
|
15068
|
+
if (results.length === 0) return 100;
|
|
15069
|
+
const weights = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
15070
|
+
let maxScore = 0;
|
|
15071
|
+
let deductions = 0;
|
|
15072
|
+
for (const r of results) {
|
|
15073
|
+
const w = weights[r.severity] || 1;
|
|
15074
|
+
maxScore += w;
|
|
15075
|
+
if (r.status === "fail") deductions += w;
|
|
15076
|
+
else if (r.status === "warn") deductions += w * 0.5;
|
|
15077
|
+
}
|
|
15078
|
+
if (maxScore === 0) return 100;
|
|
15079
|
+
return Math.max(0, Math.round((maxScore - deductions) / maxScore * 100));
|
|
15080
|
+
}
|
|
15081
|
+
async function hardenCommand(opts) {
|
|
15082
|
+
if (!opts.json) {
|
|
15083
|
+
banner();
|
|
15084
|
+
logger.info("Running hardening scan...\n");
|
|
15085
|
+
}
|
|
15086
|
+
const spinner = opts.json ? null : ora({ text: "Scanning system configuration...", color: "green" }).start();
|
|
15087
|
+
const results = runAllChecks();
|
|
15088
|
+
const score = computeScore(results);
|
|
15089
|
+
if (spinner) spinner.succeed("Hardening scan complete\n");
|
|
15090
|
+
if (opts.json) {
|
|
15091
|
+
console.log(JSON.stringify({ score, findings: results }, null, 2));
|
|
15092
|
+
return;
|
|
15093
|
+
}
|
|
15094
|
+
const scoreColor = score >= 80 ? source_default.green : score >= 60 ? source_default.yellow : source_default.red;
|
|
15095
|
+
console.log(` ${source_default.white.bold("Hardening Score:")} ${scoreColor.bold(String(score) + "/100")}`);
|
|
15096
|
+
console.log(source_default.gray(" " + "\u2500".repeat(60)));
|
|
15097
|
+
console.log();
|
|
15098
|
+
const fails = results.filter((r) => r.status === "fail");
|
|
15099
|
+
const warns = results.filter((r) => r.status === "warn");
|
|
15100
|
+
const passes = results.filter((r) => r.status === "pass");
|
|
15101
|
+
if (fails.length > 0) {
|
|
15102
|
+
console.log(source_default.red.bold(" FAIL"));
|
|
15103
|
+
for (const r of fails) {
|
|
15104
|
+
console.log(` ${source_default.red("\u2717")} ${source_default.white.bold(r.title)}`);
|
|
15105
|
+
console.log(` ${source_default.gray(r.explanation)}`);
|
|
15106
|
+
if (r.recommendation) console.log(` ${source_default.yellow("Fix:")} ${r.recommendation}`);
|
|
15107
|
+
console.log();
|
|
15108
|
+
}
|
|
15109
|
+
}
|
|
15110
|
+
if (warns.length > 0) {
|
|
15111
|
+
console.log(source_default.yellow.bold(" WARNING"));
|
|
15112
|
+
for (const r of warns) {
|
|
15113
|
+
console.log(` ${source_default.yellow("!")} ${source_default.white.bold(r.title)}`);
|
|
15114
|
+
console.log(` ${source_default.gray(r.explanation)}`);
|
|
15115
|
+
if (r.recommendation) console.log(` ${source_default.yellow("Fix:")} ${r.recommendation}`);
|
|
15116
|
+
console.log();
|
|
15117
|
+
}
|
|
15118
|
+
}
|
|
15119
|
+
if (passes.length > 0) {
|
|
15120
|
+
console.log(source_default.green.bold(" PASS"));
|
|
15121
|
+
for (const r of passes) {
|
|
15122
|
+
console.log(` ${source_default.green("\u2713")} ${source_default.white(r.title)}`);
|
|
15123
|
+
}
|
|
15124
|
+
console.log();
|
|
15125
|
+
}
|
|
15126
|
+
console.log(source_default.gray(" " + "\u2500".repeat(60)));
|
|
15127
|
+
console.log(` ${source_default.white.bold(`${results.length} checks:`)} ${source_default.green(`${passes.length} pass`)} ${source_default.yellow(`${warns.length} warn`)} ${source_default.red(`${fails.length} fail`)}`);
|
|
15128
|
+
console.log();
|
|
15129
|
+
}
|
|
15130
|
+
|
|
15131
|
+
// src/commands/firewall.ts
|
|
15132
|
+
async function blockCommand(ip, opts) {
|
|
15133
|
+
banner();
|
|
15134
|
+
console.log(source_default.green.bold(" Firewall Block"));
|
|
15135
|
+
console.log(source_default.gray(" " + "\u2500".repeat(50)));
|
|
15136
|
+
let ttlSeconds;
|
|
15137
|
+
if (opts.ttl) {
|
|
15138
|
+
const match = opts.ttl.match(/^(\d+)(s|m|h|d)?$/);
|
|
15139
|
+
if (match) {
|
|
15140
|
+
const value = parseInt(match[1]);
|
|
15141
|
+
const unit = match[2] || "s";
|
|
15142
|
+
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
15143
|
+
ttlSeconds = value * (multipliers[unit] || 1);
|
|
15144
|
+
}
|
|
15145
|
+
}
|
|
15146
|
+
console.log(` Blocking ${source_default.red(ip)}${ttlSeconds ? ` for ${opts.ttl}` : " permanently"}...`);
|
|
15147
|
+
console.log(source_default.gray(" Note: Requires running daemon with CAP_NET_ADMIN"));
|
|
15148
|
+
console.log(source_default.gray(" Configure in /etc/threatcrush/threatcrushd.conf under [remediation]"));
|
|
15149
|
+
console.log();
|
|
15150
|
+
}
|
|
15151
|
+
async function unblockCommand(ip) {
|
|
15152
|
+
banner();
|
|
15153
|
+
console.log(source_default.green.bold(" Firewall Unblock"));
|
|
15154
|
+
console.log(source_default.gray(" " + "\u2500".repeat(50)));
|
|
15155
|
+
console.log(` Unblocking ${source_default.white(ip)}...`);
|
|
15156
|
+
console.log(source_default.gray(" Note: Requires running daemon"));
|
|
15157
|
+
console.log();
|
|
15158
|
+
}
|
|
15159
|
+
async function blocklistCommand() {
|
|
15160
|
+
banner();
|
|
15161
|
+
console.log(source_default.green.bold(" Active Blocklist"));
|
|
15162
|
+
console.log(source_default.gray(" " + "\u2500".repeat(60)));
|
|
15163
|
+
console.log(source_default.gray(" Connect to running daemon for live blocklist data."));
|
|
15164
|
+
console.log(source_default.gray(" Configure via /etc/threatcrush/threatcrushd.conf [remediation]"));
|
|
15165
|
+
console.log();
|
|
15166
|
+
console.log(source_default.gray(" Remediation config options:"));
|
|
15167
|
+
console.log(source_default.gray(" enabled = true"));
|
|
15168
|
+
console.log(source_default.gray(" dry_run = true # Log only, no actual blocks"));
|
|
15169
|
+
console.log(source_default.gray(' default_ttl = "1h" # Default block duration'));
|
|
15170
|
+
console.log(source_default.gray(' min_severity = "high" # Minimum severity to auto-block'));
|
|
15171
|
+
console.log();
|
|
15172
|
+
}
|
|
15173
|
+
async function allowlistCommand(opts) {
|
|
15174
|
+
banner();
|
|
15175
|
+
const action = opts.action || "list";
|
|
15176
|
+
console.log(source_default.green.bold(" Allowlist"));
|
|
15177
|
+
console.log(source_default.gray(" " + "\u2500".repeat(50)));
|
|
15178
|
+
switch (action) {
|
|
15179
|
+
case "list":
|
|
15180
|
+
case "ls":
|
|
15181
|
+
console.log(source_default.gray(" Allowlisted IPs/CIDRs (from config):"));
|
|
15182
|
+
console.log(source_default.gray(" 127.0.0.1"));
|
|
15183
|
+
console.log(source_default.gray(" ::1"));
|
|
15184
|
+
console.log();
|
|
15185
|
+
console.log(source_default.gray(" Add more via /etc/threatcrush/threatcrushd.conf [remediation] allowlist"));
|
|
15186
|
+
break;
|
|
15187
|
+
case "add":
|
|
15188
|
+
if (!opts.value) {
|
|
15189
|
+
console.log(source_default.red(" IP/CIDR required. Usage: threatcrush allowlist add <ip>"));
|
|
15190
|
+
break;
|
|
15191
|
+
}
|
|
15192
|
+
console.log(source_default.green(` Added ${opts.value} to allowlist`));
|
|
15193
|
+
break;
|
|
15194
|
+
case "remove":
|
|
15195
|
+
if (!opts.value) {
|
|
15196
|
+
console.log(source_default.red(" IP/CIDR required. Usage: threatcrush allowlist remove <ip>"));
|
|
15197
|
+
break;
|
|
15198
|
+
}
|
|
15199
|
+
console.log(source_default.green(` Removed ${opts.value} from allowlist`));
|
|
15200
|
+
break;
|
|
15201
|
+
default:
|
|
15202
|
+
console.log(source_default.yellow(` Unknown action: ${action}`));
|
|
15203
|
+
console.log(source_default.gray(" Available: list, add, remove"));
|
|
15204
|
+
}
|
|
15205
|
+
console.log();
|
|
15206
|
+
}
|
|
15207
|
+
|
|
13176
15208
|
// src/index.ts
|
|
13177
15209
|
init_paths();
|
|
13178
15210
|
var PKG_VERSION = "0.1.8";
|
|
13179
15211
|
try {
|
|
13180
|
-
const pkg = JSON.parse((0,
|
|
15212
|
+
const pkg = JSON.parse((0, import_node_fs27.readFileSync)((0, import_node_path16.join)(__dirname, "..", "package.json"), "utf-8"));
|
|
13181
15213
|
PKG_VERSION = pkg.version;
|
|
13182
15214
|
} catch {
|
|
13183
15215
|
}
|
|
@@ -13193,25 +15225,25 @@ ${source_default.dim(" C R U S H")}
|
|
|
13193
15225
|
var API_URL8 = process.env.THREATCRUSH_API_URL || "https://threatcrush.com";
|
|
13194
15226
|
var PKG_NAME = "@profullstack/threatcrush";
|
|
13195
15227
|
var DESKTOP_PKG_NAME = "@profullstack/threatcrush-desktop";
|
|
13196
|
-
var INSTALL_CONFIG_PATH = (0,
|
|
15228
|
+
var INSTALL_CONFIG_PATH = (0, import_node_path16.join)((0, import_node_os8.homedir)(), ".threatcrush", "install.json");
|
|
13197
15229
|
function detectPackageManager() {
|
|
13198
15230
|
try {
|
|
13199
|
-
const npmGlobal = (0,
|
|
15231
|
+
const npmGlobal = (0, import_node_child_process10.execSync)("npm ls -g --depth=0 --json 2>/dev/null", { encoding: "utf-8" });
|
|
13200
15232
|
if (npmGlobal.includes(PKG_NAME)) return "npm";
|
|
13201
15233
|
} catch {
|
|
13202
15234
|
}
|
|
13203
15235
|
try {
|
|
13204
|
-
(0,
|
|
15236
|
+
(0, import_node_child_process10.execSync)("pnpm --version", { stdio: "pipe" });
|
|
13205
15237
|
return "pnpm";
|
|
13206
15238
|
} catch {
|
|
13207
15239
|
}
|
|
13208
15240
|
try {
|
|
13209
|
-
(0,
|
|
15241
|
+
(0, import_node_child_process10.execSync)("yarn --version", { stdio: "pipe" });
|
|
13210
15242
|
return "yarn";
|
|
13211
15243
|
} catch {
|
|
13212
15244
|
}
|
|
13213
15245
|
try {
|
|
13214
|
-
(0,
|
|
15246
|
+
(0, import_node_child_process10.execSync)("bun --version", { stdio: "pipe" });
|
|
13215
15247
|
return "bun";
|
|
13216
15248
|
} catch {
|
|
13217
15249
|
}
|
|
@@ -13219,7 +15251,7 @@ function detectPackageManager() {
|
|
|
13219
15251
|
}
|
|
13220
15252
|
function readInstallConfig() {
|
|
13221
15253
|
try {
|
|
13222
|
-
return JSON.parse((0,
|
|
15254
|
+
return JSON.parse((0, import_node_fs27.readFileSync)(INSTALL_CONFIG_PATH, "utf-8"));
|
|
13223
15255
|
} catch {
|
|
13224
15256
|
return {};
|
|
13225
15257
|
}
|
|
@@ -13257,7 +15289,7 @@ function packageLooksInstalled(pm, pkgName) {
|
|
|
13257
15289
|
yarn: `yarn global list --pattern ${pkgName}`,
|
|
13258
15290
|
bun: `bun pm ls -g`
|
|
13259
15291
|
};
|
|
13260
|
-
const output = (0,
|
|
15292
|
+
const output = (0, import_node_child_process10.execSync)(listCommands[pm] || listCommands.npm, {
|
|
13261
15293
|
encoding: "utf-8",
|
|
13262
15294
|
stdio: ["pipe", "pipe", "pipe"]
|
|
13263
15295
|
});
|
|
@@ -13331,6 +15363,24 @@ program2.command("pentest").description("Penetration test URLs and APIs").argume
|
|
|
13331
15363
|
program2.command("status").description("Show daemon status and loaded modules").action(async () => {
|
|
13332
15364
|
await statusCommand();
|
|
13333
15365
|
});
|
|
15366
|
+
program2.command("rules").description("Manage detection rules").argument("[action]", "list | show", "list").argument("[id]", "Rule ID (for show)").action(async (action, id) => {
|
|
15367
|
+
await rulesCommand({ action, id });
|
|
15368
|
+
});
|
|
15369
|
+
program2.command("harden").description("Run hardening security scan").option("--json", "Output results as JSON").action(async (opts) => {
|
|
15370
|
+
await hardenCommand(opts);
|
|
15371
|
+
});
|
|
15372
|
+
program2.command("block").description("Block an IP address via the firewall").argument("<ip>", "IP address to block").option("--ttl <duration>", "Block duration (e.g. 1h, 30m, 1d)").action(async (ip, opts) => {
|
|
15373
|
+
await blockCommand(ip, opts);
|
|
15374
|
+
});
|
|
15375
|
+
program2.command("unblock").description("Unblock an IP address").argument("<ip>", "IP address to unblock").action(async (ip) => {
|
|
15376
|
+
await unblockCommand(ip);
|
|
15377
|
+
});
|
|
15378
|
+
program2.command("blocklist").description("Show active firewall blocklist").action(async () => {
|
|
15379
|
+
await blocklistCommand();
|
|
15380
|
+
});
|
|
15381
|
+
program2.command("allowlist").description("Manage IP allowlist").argument("[action]", "list | add | remove", "list").argument("[value]", "IP/CIDR to add or remove").action(async (action, value) => {
|
|
15382
|
+
await allowlistCommand({ action, value });
|
|
15383
|
+
});
|
|
13334
15384
|
program2.command("start").description("Start the ThreatCrush daemon in the background").action(async () => {
|
|
13335
15385
|
console.log(LOGO2);
|
|
13336
15386
|
await daemonStart();
|
|
@@ -13351,7 +15401,7 @@ program2.command("uninstall-service").description("Remove the threatcrushd syste
|
|
|
13351
15401
|
program2.command("logs").description("Tail daemon logs").action(async () => {
|
|
13352
15402
|
console.log(LOGO2);
|
|
13353
15403
|
const logPath = PATHS.logFile;
|
|
13354
|
-
if (!(0,
|
|
15404
|
+
if (!(0, import_node_fs27.existsSync)(logPath)) {
|
|
13355
15405
|
console.log(source_default.yellow(` No log file found at ${logPath}`));
|
|
13356
15406
|
console.log(source_default.dim(" Start the daemon first with `threatcrush start`\n"));
|
|
13357
15407
|
return;
|
|
@@ -13359,7 +15409,7 @@ program2.command("logs").description("Tail daemon logs").action(async () => {
|
|
|
13359
15409
|
console.log(source_default.green(` Tailing ${logPath}...
|
|
13360
15410
|
`));
|
|
13361
15411
|
console.log(source_default.gray(" Press Ctrl+C to stop\n"));
|
|
13362
|
-
(0,
|
|
15412
|
+
(0, import_node_child_process10.execSync)(`tail -f ${logPath}`, { stdio: "inherit" });
|
|
13363
15413
|
});
|
|
13364
15414
|
program2.command("activate").description("Activate your license key").action(async () => {
|
|
13365
15415
|
console.log(LOGO2);
|
|
@@ -13409,7 +15459,7 @@ program2.command("update").description("Update ThreatCrush CLI and installed bun
|
|
|
13409
15459
|
for (const cmd of commands2) {
|
|
13410
15460
|
console.log(source_default.green(` \u2192 ${cmd}
|
|
13411
15461
|
`));
|
|
13412
|
-
(0,
|
|
15462
|
+
(0, import_node_child_process10.execSync)(cmd, { stdio: "inherit" });
|
|
13413
15463
|
}
|
|
13414
15464
|
console.log(source_default.green("\n \u2713 Modules updated successfully!\n"));
|
|
13415
15465
|
} catch (err) {
|
|
@@ -13435,7 +15485,7 @@ program2.command("update").description("Update ThreatCrush CLI and installed bun
|
|
|
13435
15485
|
for (const cmd of commands) {
|
|
13436
15486
|
console.log(source_default.green(` \u2192 ${cmd}
|
|
13437
15487
|
`));
|
|
13438
|
-
(0,
|
|
15488
|
+
(0, import_node_child_process10.execSync)(cmd, { stdio: "inherit" });
|
|
13439
15489
|
}
|
|
13440
15490
|
console.log(source_default.green("\n \u2713 ThreatCrush updated successfully!\n"));
|
|
13441
15491
|
if (installMode === "desktop") {
|
|
@@ -13479,7 +15529,7 @@ program2.command("remove").description("Uninstall ThreatCrush and the installed
|
|
|
13479
15529
|
for (const cmd of commands) {
|
|
13480
15530
|
console.log(source_default.green(` \u2192 ${cmd}
|
|
13481
15531
|
`));
|
|
13482
|
-
(0,
|
|
15532
|
+
(0, import_node_child_process10.execSync)(cmd, { stdio: "inherit" });
|
|
13483
15533
|
}
|
|
13484
15534
|
console.log(source_default.green("\n \u2713 ThreatCrush has been uninstalled.\n"));
|
|
13485
15535
|
console.log(source_default.dim(" We're sorry to see you go! \u{1F44B}\n"));
|
|
@@ -13556,10 +15606,10 @@ storeCmd.command("search <query>").description("Search for modules in the store"
|
|
|
13556
15606
|
});
|
|
13557
15607
|
storeCmd.command("publish <url>").description("Publish a module from a git URL or web URL").action(async (url) => {
|
|
13558
15608
|
console.log(LOGO2);
|
|
13559
|
-
const configPath = (0,
|
|
15609
|
+
const configPath = (0, import_node_path16.join)((0, import_node_os8.homedir)(), ".threatcrush", "config.json");
|
|
13560
15610
|
let email = "";
|
|
13561
15611
|
try {
|
|
13562
|
-
const config = JSON.parse((0,
|
|
15612
|
+
const config = JSON.parse((0, import_node_fs27.readFileSync)(configPath, "utf-8"));
|
|
13563
15613
|
email = config.email || "";
|
|
13564
15614
|
} catch {
|
|
13565
15615
|
}
|
|
@@ -13576,9 +15626,9 @@ storeCmd.command("publish <url>").description("Publish a module from a git URL o
|
|
|
13576
15626
|
return;
|
|
13577
15627
|
}
|
|
13578
15628
|
try {
|
|
13579
|
-
const dir = (0,
|
|
13580
|
-
if (!(0,
|
|
13581
|
-
(0,
|
|
15629
|
+
const dir = (0, import_node_path16.join)((0, import_node_os8.homedir)(), ".threatcrush");
|
|
15630
|
+
if (!(0, import_node_fs27.existsSync)(dir)) (0, import_node_fs27.mkdirSync)(dir, { recursive: true });
|
|
15631
|
+
(0, import_node_fs27.writeFileSync)(configPath, JSON.stringify({ email }, null, 2));
|
|
13582
15632
|
console.log(source_default.dim(` Saved email to ${configPath}`));
|
|
13583
15633
|
} catch {
|
|
13584
15634
|
}
|