@glassmkr/crucible 0.7.1 → 0.8.1
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/alerts/__tests__/rules.test.d.ts +1 -0
- package/dist/alerts/__tests__/rules.test.js +437 -0
- package/dist/alerts/__tests__/rules.test.js.map +1 -0
- package/dist/alerts/rules.d.ts +8 -0
- package/dist/alerts/rules.js +175 -34
- package/dist/alerts/rules.js.map +1 -1
- package/dist/api.d.ts +2 -0
- package/dist/api.js +7 -0
- package/dist/api.js.map +1 -0
- package/dist/collect/__tests__/dmi.test.d.ts +1 -0
- package/dist/collect/__tests__/dmi.test.js +133 -0
- package/dist/collect/__tests__/dmi.test.js.map +1 -0
- package/dist/collect/__tests__/ipmi.test.js +47 -1
- package/dist/collect/__tests__/ipmi.test.js.map +1 -1
- package/dist/collect/__tests__/thermal.test.d.ts +1 -0
- package/dist/collect/__tests__/thermal.test.js +224 -0
- package/dist/collect/__tests__/thermal.test.js.map +1 -0
- package/dist/collect/dmi.d.ts +19 -0
- package/dist/collect/dmi.js +118 -0
- package/dist/collect/dmi.js.map +1 -0
- package/dist/collect/ipmi.d.ts +27 -2
- package/dist/collect/ipmi.js +90 -2
- package/dist/collect/ipmi.js.map +1 -1
- package/dist/collect/thermal.d.ts +10 -0
- package/dist/collect/thermal.js +232 -0
- package/dist/collect/thermal.js.map +1 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +51 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/__tests__/capability.test.d.ts +1 -0
- package/dist/lib/__tests__/capability.test.js +87 -0
- package/dist/lib/__tests__/capability.test.js.map +1 -0
- package/dist/lib/__tests__/vendor-sensors.test.d.ts +1 -0
- package/dist/lib/__tests__/vendor-sensors.test.js +49 -0
- package/dist/lib/__tests__/vendor-sensors.test.js.map +1 -0
- package/dist/lib/capability.d.ts +21 -0
- package/dist/lib/capability.js +110 -0
- package/dist/lib/capability.js.map +1 -0
- package/dist/lib/cpu-thermal-chips.d.ts +2 -0
- package/dist/lib/cpu-thermal-chips.js +28 -0
- package/dist/lib/cpu-thermal-chips.js.map +1 -0
- package/dist/lib/types.d.ts +58 -0
- package/dist/lib/vendor-sensors.d.ts +27 -0
- package/dist/lib/vendor-sensors.js +63 -0
- package/dist/lib/vendor-sensors.js.map +1 -0
- package/dist/notify/telegram.js +1 -1
- package/dist/notify/telegram.js.map +1 -1
- package/package.json +16 -1
- package/rule-ids.json +29 -0
- package/.dockerignore +0 -13
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -24
- package/.github/ISSUE_TEMPLATE/no_data.md +0 -26
- package/.github/workflows/docker.yml +0 -53
- package/.github/workflows/publish.yml +0 -25
- package/Dockerfile +0 -59
- package/config/collector.example.yaml +0 -43
- package/docker-compose.yml +0 -26
- package/scripts/sign-release.sh +0 -29
- package/src/__tests__/cli.test.ts +0 -74
- package/src/__tests__/reboot-marker.test.ts +0 -122
- package/src/alerts/evaluator.ts +0 -15
- package/src/alerts/rules.ts +0 -283
- package/src/alerts/state.ts +0 -92
- package/src/cli.ts +0 -112
- package/src/collect/__tests__/ipmi.test.ts +0 -96
- package/src/collect/__tests__/smart.test.ts +0 -68
- package/src/collect/__tests__/system.test.ts +0 -29
- package/src/collect/__tests__/zfs.test.ts +0 -72
- package/src/collect/conntrack.ts +0 -27
- package/src/collect/cpu.ts +0 -92
- package/src/collect/disks.ts +0 -91
- package/src/collect/fd.ts +0 -31
- package/src/collect/io-errors.ts +0 -23
- package/src/collect/io-latency.ts +0 -103
- package/src/collect/ipmi.ts +0 -207
- package/src/collect/memory.ts +0 -30
- package/src/collect/network.ts +0 -193
- package/src/collect/ntp.ts +0 -114
- package/src/collect/os-alerts.ts +0 -43
- package/src/collect/raid.ts +0 -40
- package/src/collect/security.ts +0 -268
- package/src/collect/smart.ts +0 -72
- package/src/collect/system.ts +0 -32
- package/src/collect/systemd.ts +0 -33
- package/src/collect/zfs.ts +0 -66
- package/src/config.ts +0 -65
- package/src/index.ts +0 -221
- package/src/lib/__tests__/parse.test.ts +0 -28
- package/src/lib/exec.ts +0 -16
- package/src/lib/parse.ts +0 -29
- package/src/lib/reboot-marker.ts +0 -88
- package/src/lib/types.ts +0 -226
- package/src/lib/version-check.ts +0 -39
- package/src/lib/version.ts +0 -33
- package/src/metrics-server.ts +0 -123
- package/src/notify/email.ts +0 -69
- package/src/notify/slack.ts +0 -47
- package/src/notify/telegram.ts +0 -65
- package/src/push/forge.ts +0 -109
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -12
package/src/collect/ntp.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { run } from "../lib/exec.js";
|
|
2
|
-
|
|
3
|
-
export interface NtpData {
|
|
4
|
-
synced: boolean;
|
|
5
|
-
offset_seconds: number;
|
|
6
|
-
source: string;
|
|
7
|
-
daemon_running: boolean;
|
|
8
|
-
daemon_name: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Check whether a systemd unit is active. Returns false for missing units, not-found, etc.
|
|
12
|
-
async function isUnitActive(unit: string): Promise<boolean> {
|
|
13
|
-
const out = await run("systemctl", ["is-active", unit], 3000);
|
|
14
|
-
return out?.trim() === "active";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// Detect which time-sync daemon unit is currently active on the host, if any.
|
|
18
|
-
// Returns "" when none are. We check the common names in order of preference.
|
|
19
|
-
async function detectActiveDaemon(): Promise<string> {
|
|
20
|
-
const candidates = ["chrony", "chronyd", "systemd-timesyncd", "ntp", "ntpsec", "ntpd"];
|
|
21
|
-
for (const unit of candidates) {
|
|
22
|
-
if (await isUnitActive(unit)) return unit;
|
|
23
|
-
}
|
|
24
|
-
return "";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export async function collectNtp(): Promise<NtpData> {
|
|
28
|
-
// The authoritative "is the daemon running" check is systemctl is-active,
|
|
29
|
-
// not any derived flag from timedatectl. This catches daemon crashes and
|
|
30
|
-
// manual stops where the kernel clock is still synced.
|
|
31
|
-
const daemonName = await detectActiveDaemon();
|
|
32
|
-
const daemonRunning = daemonName !== "";
|
|
33
|
-
|
|
34
|
-
// Try timedatectl first (works for systemd-timesyncd and records the kernel
|
|
35
|
-
// NTPSynchronized flag regardless of which daemon set it).
|
|
36
|
-
const tdctl = await run("timedatectl", ["show", "--property=NTPSynchronized", "--value"]);
|
|
37
|
-
if (tdctl !== null && (tdctl.trim() === "yes" || tdctl.trim() === "no")) {
|
|
38
|
-
const synced = tdctl.trim() === "yes";
|
|
39
|
-
|
|
40
|
-
let offset = 0;
|
|
41
|
-
try {
|
|
42
|
-
const tsStatus = await run("timedatectl", ["timesync-status"]);
|
|
43
|
-
if (tsStatus) {
|
|
44
|
-
const match = tsStatus.match(/Offset:\s*([+-]?\d+\.?\d*)(us|ms|s)/);
|
|
45
|
-
if (match) {
|
|
46
|
-
const val = parseFloat(match[1]);
|
|
47
|
-
const unit = match[2];
|
|
48
|
-
if (unit === "us") offset = val / 1_000_000;
|
|
49
|
-
else if (unit === "ms") offset = val / 1000;
|
|
50
|
-
else offset = val;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
} catch { /* offset stays 0 */ }
|
|
54
|
-
|
|
55
|
-
// Prefer an explicitly detected daemon name; fall back to systemd-timesyncd
|
|
56
|
-
// since timedatectl is most commonly the timesyncd frontend.
|
|
57
|
-
const source = daemonName || "systemd-timesyncd";
|
|
58
|
-
return { synced, offset_seconds: offset, source, daemon_running: daemonRunning, daemon_name: daemonName };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Chrony tracking. Validate Leap status so we do not misread error text.
|
|
62
|
-
const chronyOut = await run("chronyc", ["tracking"]);
|
|
63
|
-
if (chronyOut) {
|
|
64
|
-
const leapMatch = chronyOut.match(/Leap status\s*:\s*(.+)/);
|
|
65
|
-
if (leapMatch) {
|
|
66
|
-
const synced = leapMatch[1].trim() === "Normal";
|
|
67
|
-
let offset = 0;
|
|
68
|
-
const offsetMatch = chronyOut.match(/Last offset\s*:\s*([+-]?\d+\.?\d*)\s*seconds/);
|
|
69
|
-
if (offsetMatch) offset = parseFloat(offsetMatch[1]);
|
|
70
|
-
return {
|
|
71
|
-
synced,
|
|
72
|
-
offset_seconds: Math.abs(offset),
|
|
73
|
-
source: daemonName || "chrony",
|
|
74
|
-
daemon_running: daemonRunning,
|
|
75
|
-
daemon_name: daemonName,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ntpq peer table. Header check avoids false positives on error messages.
|
|
81
|
-
const ntpqOut = await run("ntpq", ["-pn"]);
|
|
82
|
-
if (ntpqOut) {
|
|
83
|
-
const hasHeader = ntpqOut.split("\n").some((line) => line.includes("remote"));
|
|
84
|
-
if (hasHeader) {
|
|
85
|
-
const synced = ntpqOut.split("\n").some((line) => line.startsWith("*"));
|
|
86
|
-
let offset = 0;
|
|
87
|
-
const selectedLine = ntpqOut.split("\n").find((line) => line.startsWith("*"));
|
|
88
|
-
if (selectedLine) {
|
|
89
|
-
const fields = selectedLine.trim().split(/\s+/);
|
|
90
|
-
if (fields.length >= 9) {
|
|
91
|
-
const rawOffset = parseFloat(fields[8]);
|
|
92
|
-
if (!isNaN(rawOffset)) offset = Math.abs(rawOffset) / 1000;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return {
|
|
96
|
-
synced,
|
|
97
|
-
offset_seconds: offset,
|
|
98
|
-
source: daemonName || "ntpd",
|
|
99
|
-
daemon_running: daemonRunning,
|
|
100
|
-
daemon_name: daemonName,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// No usable probe output. If systemd still reports a daemon as active, trust that;
|
|
106
|
-
// otherwise report fully down.
|
|
107
|
-
return {
|
|
108
|
-
synced: false,
|
|
109
|
-
offset_seconds: 0,
|
|
110
|
-
source: daemonName || "none",
|
|
111
|
-
daemon_running: daemonRunning,
|
|
112
|
-
daemon_name: daemonName,
|
|
113
|
-
};
|
|
114
|
-
}
|
package/src/collect/os-alerts.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { run } from "../lib/exec.js";
|
|
2
|
-
import { readProcFile } from "../lib/parse.js";
|
|
3
|
-
import { readdirSync, readFileSync } from "fs";
|
|
4
|
-
import type { OsAlerts } from "../lib/types.js";
|
|
5
|
-
|
|
6
|
-
export async function collectOsAlerts(): Promise<OsAlerts> {
|
|
7
|
-
// OOM kills
|
|
8
|
-
let oomKills = 0;
|
|
9
|
-
const dmesg = await run("dmesg", ["--level=err,crit", "--since", "5 min ago"]);
|
|
10
|
-
if (dmesg) {
|
|
11
|
-
oomKills = (dmesg.match(/Out of memory/gi) || []).length;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// Zombie processes
|
|
15
|
-
let zombies = 0;
|
|
16
|
-
try {
|
|
17
|
-
const pids = readdirSync("/proc").filter((f) => /^\d+$/.test(f));
|
|
18
|
-
for (const pid of pids) {
|
|
19
|
-
try {
|
|
20
|
-
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
21
|
-
// Field 3 is the state character
|
|
22
|
-
const state = stat.split(" ")[2];
|
|
23
|
-
if (state === "Z") zombies++;
|
|
24
|
-
} catch { /* process disappeared */ }
|
|
25
|
-
}
|
|
26
|
-
} catch { /* /proc not readable */ }
|
|
27
|
-
|
|
28
|
-
// Time drift (simple: check if chrony/ntp reports drift)
|
|
29
|
-
let timeDriftMs = 0;
|
|
30
|
-
const chrony = await run("chronyc", ["tracking"]);
|
|
31
|
-
if (chrony) {
|
|
32
|
-
const match = chrony.match(/System time\s*:\s*([\d.]+)\s*seconds\s*(slow|fast)/);
|
|
33
|
-
if (match) {
|
|
34
|
-
timeDriftMs = parseFloat(match[1]) * 1000;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return {
|
|
39
|
-
oom_kills_recent: oomKills,
|
|
40
|
-
zombie_processes: zombies,
|
|
41
|
-
time_drift_ms: Math.round(timeDriftMs * 100) / 100,
|
|
42
|
-
};
|
|
43
|
-
}
|
package/src/collect/raid.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { readProcFile } from "../lib/parse.js";
|
|
2
|
-
import type { RaidInfo } from "../lib/types.js";
|
|
3
|
-
|
|
4
|
-
export async function collectRaid(): Promise<RaidInfo[]> {
|
|
5
|
-
const raw = readProcFile("/proc/mdstat");
|
|
6
|
-
if (!raw) return [];
|
|
7
|
-
|
|
8
|
-
const results: RaidInfo[] = [];
|
|
9
|
-
const lines = raw.split("\n");
|
|
10
|
-
|
|
11
|
-
for (let i = 0; i < lines.length; i++) {
|
|
12
|
-
const match = lines[i].match(/^(md\d+)\s*:\s*(\w+)\s+(\w+)\s+(.*)/);
|
|
13
|
-
if (!match) continue;
|
|
14
|
-
|
|
15
|
-
const device = match[1];
|
|
16
|
-
const status = match[2]; // "active" or "inactive"
|
|
17
|
-
const level = match[3]; // "raid1", "raid5", etc.
|
|
18
|
-
const disksPart = match[4];
|
|
19
|
-
|
|
20
|
-
// Parse component disks (e.g., "sda1[0] sdb1[1]")
|
|
21
|
-
const disks = (disksPart.match(/\w+\[\d+\]/g) || []).map((d) => d.replace(/\[\d+\]/, ""));
|
|
22
|
-
|
|
23
|
-
// Check next line for degraded status (e.g., "[UU_]" means one drive missing)
|
|
24
|
-
const statusLine = lines[i + 1] || "";
|
|
25
|
-
const bracketMatch = statusLine.match(/\[([U_]+)\]/);
|
|
26
|
-
const degraded = bracketMatch ? bracketMatch[1].includes("_") : false;
|
|
27
|
-
|
|
28
|
-
const failedDisks: string[] = [];
|
|
29
|
-
if (degraded && bracketMatch) {
|
|
30
|
-
const pattern = bracketMatch[1];
|
|
31
|
-
pattern.split("").forEach((c, idx) => {
|
|
32
|
-
if (c === "_" && disks[idx]) failedDisks.push(disks[idx]);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
results.push({ device, level, status, degraded, disks, failed_disks: failedDisks });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return results;
|
|
40
|
-
}
|
package/src/collect/security.ts
DELETED
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
import { run } from "../lib/exec.js";
|
|
2
|
-
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
3
|
-
|
|
4
|
-
export interface SshSecurityStatus {
|
|
5
|
-
permitRootLogin: string;
|
|
6
|
-
passwordAuthentication: string;
|
|
7
|
-
rootPasswordExposed: boolean;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface FirewallStatus {
|
|
11
|
-
active: boolean;
|
|
12
|
-
source: string;
|
|
13
|
-
details: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface SecurityUpdateStatus {
|
|
17
|
-
distro: string;
|
|
18
|
-
pendingCount: number;
|
|
19
|
-
available: boolean;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface VulnerabilityStatus {
|
|
23
|
-
name: string;
|
|
24
|
-
status: string;
|
|
25
|
-
mitigated: boolean;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface KernelRebootStatus {
|
|
29
|
-
running: string;
|
|
30
|
-
installed: string;
|
|
31
|
-
needsReboot: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface AutoUpdateStatus {
|
|
35
|
-
configured: boolean;
|
|
36
|
-
mechanism: string;
|
|
37
|
-
details: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface SecurityData {
|
|
41
|
-
ssh: SshSecurityStatus | null;
|
|
42
|
-
firewall: FirewallStatus;
|
|
43
|
-
pending_updates: SecurityUpdateStatus | null;
|
|
44
|
-
kernel_vulns: VulnerabilityStatus[];
|
|
45
|
-
kernel_reboot: KernelRebootStatus | null;
|
|
46
|
-
auto_updates: AutoUpdateStatus;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function collectSecurity(): Promise<SecurityData> {
|
|
50
|
-
const [ssh, firewall, pendingUpdates, kernelVulns, kernelReboot, autoUpdates] = await Promise.all([
|
|
51
|
-
checkSshConfig(),
|
|
52
|
-
checkFirewall(),
|
|
53
|
-
checkSecurityUpdates(),
|
|
54
|
-
checkKernelVulnerabilities(),
|
|
55
|
-
checkKernelReboot(),
|
|
56
|
-
checkAutoUpdates(),
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
return { ssh, firewall, pending_updates: pendingUpdates, kernel_vulns: kernelVulns, kernel_reboot: kernelReboot, auto_updates: autoUpdates };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// === SSH ===
|
|
63
|
-
|
|
64
|
-
async function checkSshConfig(): Promise<SshSecurityStatus | null> {
|
|
65
|
-
// Prefer sshd -T (resolves includes and match blocks)
|
|
66
|
-
const output = await run("sshd", ["-T"], 5000);
|
|
67
|
-
if (output) {
|
|
68
|
-
const getVal = (key: string): string => {
|
|
69
|
-
const line = output.split("\n").find((l) => l.startsWith(key + " "));
|
|
70
|
-
return line ? line.split(" ")[1].trim() : "";
|
|
71
|
-
};
|
|
72
|
-
const permitRootLogin = getVal("permitrootlogin");
|
|
73
|
-
const passwordAuth = getVal("passwordauthentication");
|
|
74
|
-
const rootPasswordExposed = permitRootLogin === "yes" && passwordAuth !== "no";
|
|
75
|
-
return { permitRootLogin, passwordAuthentication: passwordAuth, rootPasswordExposed };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Fallback: parse sshd_config directly
|
|
79
|
-
try {
|
|
80
|
-
const config = readFileSync("/etc/ssh/sshd_config", "utf-8");
|
|
81
|
-
const lines = config.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
82
|
-
const find = (key: string): string | null => {
|
|
83
|
-
const line = lines.find((l) => l.toLowerCase().startsWith(key.toLowerCase()));
|
|
84
|
-
return line ? line.split(/\s+/)[1] : null;
|
|
85
|
-
};
|
|
86
|
-
const permitRootLogin = find("PermitRootLogin") || "prohibit-password";
|
|
87
|
-
const passwordAuth = find("PasswordAuthentication") || "yes";
|
|
88
|
-
const rootPasswordExposed = permitRootLogin.toLowerCase() === "yes" && passwordAuth.toLowerCase() !== "no";
|
|
89
|
-
return { permitRootLogin, passwordAuthentication: passwordAuth, rootPasswordExposed };
|
|
90
|
-
} catch {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// === Firewall ===
|
|
96
|
-
|
|
97
|
-
async function checkFirewall(): Promise<FirewallStatus> {
|
|
98
|
-
// UFW: if installed, its status is authoritative (ignores Docker iptables chains)
|
|
99
|
-
const ufw = await run("ufw", ["status"], 5000);
|
|
100
|
-
if (ufw && ufw.includes("Status:")) {
|
|
101
|
-
const active = ufw.includes("Status: active");
|
|
102
|
-
return { active, source: "ufw", details: active ? "UFW is active" : "UFW is inactive" };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// firewalld: if installed, its status is authoritative
|
|
106
|
-
const fwd = await run("firewall-cmd", ["--state"], 5000);
|
|
107
|
-
if (fwd) {
|
|
108
|
-
if (fwd.trim() === "running") {
|
|
109
|
-
return { active: true, source: "firewalld", details: "firewalld is running" };
|
|
110
|
-
}
|
|
111
|
-
if (fwd.includes("not running") || fwd.includes("dead")) {
|
|
112
|
-
return { active: false, source: "firewalld", details: "firewalld is not running" };
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// nftables (only if no managed firewall found)
|
|
117
|
-
const nft = await run("nft", ["list", "ruleset"], 5000);
|
|
118
|
-
if (nft) {
|
|
119
|
-
const ruleLines = nft.split("\n").filter((l) => l.trim().match(/^\s*(meta|ip |ip6 |tcp |udp |ct |drop|reject|accept)/));
|
|
120
|
-
if (ruleLines.length > 0) {
|
|
121
|
-
return { active: true, source: "nftables", details: `${ruleLines.length} nftables rules` };
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// iptables fallback: filter out Docker/container chains to avoid false positives
|
|
126
|
-
const ipt = await run("iptables", ["-L", "-n"], 5000);
|
|
127
|
-
if (ipt) {
|
|
128
|
-
const lines = ipt.split("\n").filter((l) =>
|
|
129
|
-
l.trim() &&
|
|
130
|
-
!l.startsWith("Chain ") &&
|
|
131
|
-
!l.startsWith("target ") &&
|
|
132
|
-
!l.includes("DOCKER") &&
|
|
133
|
-
!l.includes("docker") &&
|
|
134
|
-
!l.includes("br-") &&
|
|
135
|
-
!l.includes("f2b-")
|
|
136
|
-
);
|
|
137
|
-
if (lines.length > 0) return { active: true, source: "iptables", details: `${lines.length} user iptables rules` };
|
|
138
|
-
if (ipt.includes("policy DROP") || ipt.includes("policy REJECT")) {
|
|
139
|
-
return { active: true, source: "iptables", details: "Default policy is DROP/REJECT" };
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return { active: false, source: "none", details: "No firewall detected (checked ufw, firewalld, nftables, iptables)" };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// === Pending Security Updates ===
|
|
147
|
-
|
|
148
|
-
async function checkSecurityUpdates(): Promise<SecurityUpdateStatus | null> {
|
|
149
|
-
let osRelease = "";
|
|
150
|
-
try { osRelease = readFileSync("/etc/os-release", "utf-8").toLowerCase(); } catch { return null; }
|
|
151
|
-
|
|
152
|
-
if (osRelease.includes("debian") || osRelease.includes("ubuntu") || osRelease.includes("mint")) {
|
|
153
|
-
const output = await run("bash", ["-c", 'apt list --upgradable 2>/dev/null | grep -i "security" | wc -l'], 30000);
|
|
154
|
-
if (output) {
|
|
155
|
-
const count = parseInt(output.trim()) || 0;
|
|
156
|
-
return { distro: osRelease.includes("ubuntu") ? "ubuntu" : "debian", pendingCount: count, available: true };
|
|
157
|
-
}
|
|
158
|
-
return { distro: "debian", pendingCount: 0, available: false };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (osRelease.includes("rhel") || osRelease.includes("rocky") || osRelease.includes("alma") || osRelease.includes("fedora") || osRelease.includes("centos")) {
|
|
162
|
-
const cmd = existsSync("/usr/bin/dnf") ? "dnf" : "yum";
|
|
163
|
-
const output = await run("bash", ["-c", `${cmd} updateinfo list security --available 2>/dev/null | grep -c "/"`], 60000);
|
|
164
|
-
if (output) {
|
|
165
|
-
const count = parseInt(output.trim()) || 0;
|
|
166
|
-
const distro = osRelease.includes("rocky") ? "rocky" : osRelease.includes("alma") ? "alma" : osRelease.includes("fedora") ? "fedora" : "rhel";
|
|
167
|
-
return { distro, pendingCount: count, available: true };
|
|
168
|
-
}
|
|
169
|
-
return { distro: "rhel", pendingCount: 0, available: false };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// === Kernel Vulnerabilities ===
|
|
176
|
-
|
|
177
|
-
function checkKernelVulnerabilities(): VulnerabilityStatus[] {
|
|
178
|
-
const vulnDir = "/sys/devices/system/cpu/vulnerabilities";
|
|
179
|
-
if (!existsSync(vulnDir)) return [];
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
const files = readdirSync(vulnDir);
|
|
183
|
-
return files.map((file) => {
|
|
184
|
-
try {
|
|
185
|
-
const status = readFileSync(`${vulnDir}/${file}`, "utf-8").trim();
|
|
186
|
-
const mitigated = status.includes("Not affected") || status.includes("Mitigation:");
|
|
187
|
-
return { name: file, status, mitigated };
|
|
188
|
-
} catch {
|
|
189
|
-
return { name: file, status: "unknown", mitigated: true };
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
} catch {
|
|
193
|
-
return [];
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// === Kernel Reboot ===
|
|
198
|
-
|
|
199
|
-
async function checkKernelReboot(): Promise<KernelRebootStatus | null> {
|
|
200
|
-
const running = (await run("uname", ["-r"]))?.trim();
|
|
201
|
-
if (!running) return null;
|
|
202
|
-
|
|
203
|
-
// Method 1: reboot-required flag (Debian/Ubuntu)
|
|
204
|
-
if (existsSync("/var/run/reboot-required")) {
|
|
205
|
-
// Filter to versioned images only (e.g. linux-image-6.8.0-107-generic),
|
|
206
|
-
// excluding metapackages like linux-image-generic, linux-image-virtual.
|
|
207
|
-
const installed = (await run("bash", ["-c", 'dpkg -l "linux-image-*" 2>/dev/null | grep "^ii" | awk \'{print $2}\' | grep "linux-image-[0-9]" | sed "s/linux-image-//" | sort -V | tail -1']))?.trim() || "unknown";
|
|
208
|
-
return { running, installed, needsReboot: true };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Method 2: Compare packages (Debian/Ubuntu)
|
|
212
|
-
// Same filter: only versioned images, no metapackages.
|
|
213
|
-
const debPkg = (await run("bash", ["-c", 'dpkg -l "linux-image-*" 2>/dev/null | grep "^ii" | awk \'{print $2}\' | grep "linux-image-[0-9]" | sed "s/linux-image-//" | sort -V | tail -1']))?.trim();
|
|
214
|
-
if (debPkg) {
|
|
215
|
-
return { running, installed: debPkg, needsReboot: debPkg !== running };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Method 3: RPM-based
|
|
219
|
-
const rpmPkg = (await run("bash", ["-c", 'rpm -q kernel --queryformat "%{VERSION}-%{RELEASE}.%{ARCH}\\n" 2>/dev/null | sort -V | tail -1']))?.trim();
|
|
220
|
-
if (rpmPkg) {
|
|
221
|
-
return { running, installed: rpmPkg, needsReboot: rpmPkg !== running };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// === Auto Updates ===
|
|
228
|
-
|
|
229
|
-
async function checkAutoUpdates(): Promise<AutoUpdateStatus> {
|
|
230
|
-
// Debian/Ubuntu: unattended-upgrades
|
|
231
|
-
const uuInstalled = await run("bash", ["-c", 'dpkg -l unattended-upgrades 2>/dev/null | grep "^ii"'], 5000);
|
|
232
|
-
if (uuInstalled) {
|
|
233
|
-
// Check config file
|
|
234
|
-
const autoConf = "/etc/apt/apt.conf.d/20auto-upgrades";
|
|
235
|
-
let configEnabled = false;
|
|
236
|
-
if (existsSync(autoConf)) {
|
|
237
|
-
const content = readFileSync(autoConf, "utf-8");
|
|
238
|
-
configEnabled = content.includes('Update-Package-Lists "1"') && content.includes('Unattended-Upgrade "1"');
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Check systemd service state
|
|
242
|
-
const serviceEnabled = (await run("bash", ["-c", "systemctl is-enabled unattended-upgrades 2>/dev/null"], 5000))?.trim() === "enabled";
|
|
243
|
-
const serviceActive = (await run("bash", ["-c", "systemctl is-active unattended-upgrades 2>/dev/null"], 5000))?.trim() === "active";
|
|
244
|
-
|
|
245
|
-
if (configEnabled && serviceEnabled) {
|
|
246
|
-
return { configured: true, mechanism: "unattended-upgrades", details: serviceActive ? "Installed, enabled, and running" : "Installed and enabled (service not active)" };
|
|
247
|
-
}
|
|
248
|
-
if (!configEnabled && !serviceEnabled) {
|
|
249
|
-
return { configured: false, mechanism: "unattended-upgrades", details: "Installed but not configured and service disabled" };
|
|
250
|
-
}
|
|
251
|
-
if (!serviceEnabled) {
|
|
252
|
-
return { configured: false, mechanism: "unattended-upgrades", details: "Installed and configured but service disabled" };
|
|
253
|
-
}
|
|
254
|
-
return { configured: false, mechanism: "unattended-upgrades", details: "Installed but not enabled in 20auto-upgrades" };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// RHEL/Rocky/Alma: dnf-automatic
|
|
258
|
-
const dnfAuto = await run("bash", ["-c", "rpm -q dnf-automatic 2>/dev/null"], 5000);
|
|
259
|
-
if (dnfAuto && !dnfAuto.includes("not installed")) {
|
|
260
|
-
const timerActive = await run("bash", ["-c", "systemctl is-enabled dnf-automatic-install.timer 2>/dev/null || systemctl is-enabled dnf-automatic.timer 2>/dev/null"], 5000);
|
|
261
|
-
if (timerActive && timerActive.includes("enabled")) {
|
|
262
|
-
return { configured: true, mechanism: "dnf-automatic", details: "Installed and timer enabled" };
|
|
263
|
-
}
|
|
264
|
-
return { configured: false, mechanism: "dnf-automatic", details: "Installed but timer not enabled" };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return { configured: false, mechanism: "none", details: "No automatic security update mechanism detected" };
|
|
268
|
-
}
|
package/src/collect/smart.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { run } from "../lib/exec.js";
|
|
2
|
-
import { readdirSync } from "fs";
|
|
3
|
-
import type { SmartInfo } from "../lib/types.js";
|
|
4
|
-
|
|
5
|
-
export async function collectSmart(): Promise<SmartInfo[]> {
|
|
6
|
-
// Find block devices
|
|
7
|
-
const devices: string[] = [];
|
|
8
|
-
try {
|
|
9
|
-
const entries = readdirSync("/sys/block");
|
|
10
|
-
for (const entry of entries) {
|
|
11
|
-
if (entry.startsWith("sd") || entry.startsWith("nvme") || entry.startsWith("hd")) {
|
|
12
|
-
devices.push(`/dev/${entry}`);
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
} catch {
|
|
16
|
-
return [];
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const results: SmartInfo[] = [];
|
|
20
|
-
for (const device of devices) {
|
|
21
|
-
const output = await run("smartctl", ["--json", "--all", device]);
|
|
22
|
-
if (!output) continue;
|
|
23
|
-
|
|
24
|
-
try {
|
|
25
|
-
const info = parseSmartctlJson(JSON.parse(output), device);
|
|
26
|
-
results.push(info);
|
|
27
|
-
} catch {
|
|
28
|
-
// Failed to parse, skip this device
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return results;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function parseSmartctlJson(data: Record<string, unknown> & {
|
|
36
|
-
model_name?: string;
|
|
37
|
-
model_family?: string;
|
|
38
|
-
smart_status?: { passed?: boolean };
|
|
39
|
-
temperature?: { current?: number };
|
|
40
|
-
power_on_time?: { hours?: number };
|
|
41
|
-
nvme_smart_health_information_log?: { percentage_used?: number; temperature?: number };
|
|
42
|
-
ata_smart_attributes?: { table?: Array<{ id?: number; name?: string; raw?: { value?: number } }> };
|
|
43
|
-
}, device: string): SmartInfo {
|
|
44
|
-
const info: SmartInfo = {
|
|
45
|
-
device,
|
|
46
|
-
model: data.model_name || data.model_family || "unknown",
|
|
47
|
-
health: data.smart_status?.passed ? "PASSED" : "FAILED",
|
|
48
|
-
temperature_c: data.temperature?.current,
|
|
49
|
-
power_on_hours: data.power_on_time?.hours,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
// NVMe specific
|
|
53
|
-
if (data.nvme_smart_health_information_log) {
|
|
54
|
-
const nvme = data.nvme_smart_health_information_log;
|
|
55
|
-
info.percentage_used = nvme.percentage_used;
|
|
56
|
-
info.temperature_c = nvme.temperature;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// SATA specific
|
|
60
|
-
if (data.ata_smart_attributes?.table) {
|
|
61
|
-
for (const attr of data.ata_smart_attributes.table) {
|
|
62
|
-
if (attr.id === 5 || attr.name === "Reallocated_Sector_Ct") {
|
|
63
|
-
info.reallocated_sectors = attr.raw?.value || 0;
|
|
64
|
-
}
|
|
65
|
-
if (attr.id === 197 || attr.name === "Current_Pending_Sector") {
|
|
66
|
-
info.pending_sectors = attr.raw?.value || 0;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return info;
|
|
72
|
-
}
|
package/src/collect/system.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { hostname } from "os";
|
|
2
|
-
import { readProcFile } from "../lib/parse.js";
|
|
3
|
-
import { run } from "../lib/exec.js";
|
|
4
|
-
import type { SystemInfo } from "../lib/types.js";
|
|
5
|
-
|
|
6
|
-
// Matches KEY=value with optional surrounding double quotes. Handles both
|
|
7
|
-
// `ID=ubuntu` and `ID="rocky"` styles found in the wild.
|
|
8
|
-
export function readOsReleaseField(osRelease: string, key: string): string | undefined {
|
|
9
|
-
const m = osRelease.match(new RegExp(`^${key}=("?)(.+?)\\1$`, "m"));
|
|
10
|
-
return m ? m[2].toLowerCase() : undefined;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function collectSystem(): Promise<SystemInfo> {
|
|
14
|
-
const osRelease = readProcFile("/etc/os-release") || "";
|
|
15
|
-
const osName = osRelease.match(/PRETTY_NAME="(.+?)"/)?.[1] || "Unknown";
|
|
16
|
-
const os_id = readOsReleaseField(osRelease, "ID");
|
|
17
|
-
const os_id_like = readOsReleaseField(osRelease, "ID_LIKE");
|
|
18
|
-
const kernel = (await run("uname", ["-r"]))?.trim() || "unknown";
|
|
19
|
-
const uptimeRaw = readProcFile("/proc/uptime") || "0";
|
|
20
|
-
const uptimeSeconds = Math.floor(parseFloat(uptimeRaw.split(" ")[0]));
|
|
21
|
-
const ip = (await run("hostname", ["-I"]))?.trim().split(" ")[0] || "unknown";
|
|
22
|
-
|
|
23
|
-
return {
|
|
24
|
-
hostname: hostname(),
|
|
25
|
-
ip,
|
|
26
|
-
os: osName,
|
|
27
|
-
...(os_id ? { os_id } : {}),
|
|
28
|
-
...(os_id_like ? { os_id_like } : {}),
|
|
29
|
-
kernel,
|
|
30
|
-
uptime_seconds: uptimeSeconds,
|
|
31
|
-
};
|
|
32
|
-
}
|
package/src/collect/systemd.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { run } from "../lib/exec.js";
|
|
2
|
-
|
|
3
|
-
export interface SystemdData {
|
|
4
|
-
failed_units: string[];
|
|
5
|
-
failed_count: number;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
// Units commonly in failed state by design or misconfiguration
|
|
9
|
-
const DEFAULT_EXCLUDES = [
|
|
10
|
-
"systemd-networkd-wait-online.service",
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
export async function collectSystemd(extraExcludes: string[] = []): Promise<SystemdData> {
|
|
14
|
-
const output = await run("systemctl", [
|
|
15
|
-
"list-units", "--type=service", "--state=failed", "--no-legend", "--plain",
|
|
16
|
-
]);
|
|
17
|
-
|
|
18
|
-
if (!output || output.trim() === "") {
|
|
19
|
-
return { failed_units: [], failed_count: 0 };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const excludes = new Set([...DEFAULT_EXCLUDES, ...extraExcludes]);
|
|
23
|
-
const units: string[] = [];
|
|
24
|
-
|
|
25
|
-
for (const line of output.trim().split("\n")) {
|
|
26
|
-
const unit = line.trim().split(/\s+/)[0];
|
|
27
|
-
if (unit && unit.endsWith(".service") && !excludes.has(unit)) {
|
|
28
|
-
units.push(unit);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return { failed_units: units, failed_count: units.length };
|
|
33
|
-
}
|
package/src/collect/zfs.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { run } from "../lib/exec.js";
|
|
2
|
-
import type { ZfsData, ZfsPool } from "../lib/types.js";
|
|
3
|
-
|
|
4
|
-
export async function collectZfs(): Promise<ZfsData | null> {
|
|
5
|
-
// Check if zpool is installed
|
|
6
|
-
const zpoolPath = await run("which", ["zpool"], 3000);
|
|
7
|
-
if (!zpoolPath || !zpoolPath.trim()) return null;
|
|
8
|
-
|
|
9
|
-
const zpoolStatus = await run("zpool", ["status"], 10000);
|
|
10
|
-
if (!zpoolStatus || !zpoolStatus.trim()) return null;
|
|
11
|
-
|
|
12
|
-
const pools = parseZpoolStatus(zpoolStatus);
|
|
13
|
-
if (pools.length === 0) return null;
|
|
14
|
-
return { pools };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function parseZpoolStatus(zpoolStatus: string): ZfsPool[] {
|
|
18
|
-
const pools: ZfsPool[] = [];
|
|
19
|
-
let current: ZfsPool | null = null;
|
|
20
|
-
|
|
21
|
-
for (const line of zpoolStatus.split("\n")) {
|
|
22
|
-
const poolMatch = line.match(/^\s*pool:\s*(.+)/);
|
|
23
|
-
if (poolMatch) {
|
|
24
|
-
current = {
|
|
25
|
-
name: poolMatch[1].trim(),
|
|
26
|
-
state: "UNKNOWN",
|
|
27
|
-
errors_text: "",
|
|
28
|
-
};
|
|
29
|
-
pools.push(current);
|
|
30
|
-
continue;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (!current) continue;
|
|
34
|
-
|
|
35
|
-
const stateMatch = line.match(/^\s*state:\s*(.+)/);
|
|
36
|
-
if (stateMatch) {
|
|
37
|
-
current.state = stateMatch[1].trim();
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const errorsMatch = line.match(/^\s*errors:\s*(.+)/);
|
|
42
|
-
if (errorsMatch) {
|
|
43
|
-
current.errors_text = errorsMatch[1].trim();
|
|
44
|
-
continue;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Parse scrub info
|
|
48
|
-
if (line.includes("scan:")) {
|
|
49
|
-
if (line.includes("none requested")) {
|
|
50
|
-
current.scrub_never_run = true;
|
|
51
|
-
} else {
|
|
52
|
-
const repairMatch = line.match(/scrub repaired (\S+) in .* with (\d+) errors/);
|
|
53
|
-
if (repairMatch) {
|
|
54
|
-
current.scrub_repaired = repairMatch[1];
|
|
55
|
-
current.scrub_errors = parseInt(repairMatch[2]) || 0;
|
|
56
|
-
}
|
|
57
|
-
const dateMatch = line.match(/on (.+)$/);
|
|
58
|
-
if (dateMatch) {
|
|
59
|
-
current.last_scrub_date = dateMatch[1].trim();
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return pools;
|
|
66
|
-
}
|