@daemonitor/plugins 1.1.0 → 1.1.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.
@@ -0,0 +1,36 @@
1
+ // Aggregate named service processes (nginx, php-fpm, db, …) from `ps` output.
2
+ // Used for host processes (OSPlugin) and per-container processes (LxcPlugin via
3
+ // `lxc exec … ps`). MEM (rss) is exact; CPU is `ps` %cpu, which is the average
4
+ // over each process's lifetime — a rough indicator for long-running daemons.
5
+ // The command whose output parseProcesses() expects (headerless: comm rss pcpu).
6
+ export const PS_CMD = "ps -eo comm=,rss=,pcpu=";
7
+ const WATCH = [
8
+ { key: "nginx", test: (c) => c.startsWith("nginx") },
9
+ { key: "php-fpm", test: (c) => /^php-?fpm/i.test(c) },
10
+ { key: "mysql", test: (c) => c.startsWith("mysqld") || c.startsWith("mariadbd") },
11
+ { key: "postgres", test: (c) => c.startsWith("postgres") },
12
+ { key: "redis", test: (c) => c.startsWith("redis-server") || c.startsWith("redis") },
13
+ { key: "node", test: (c) => c.startsWith("node") },
14
+ ];
15
+ export function parseProcesses(out) {
16
+ const agg = new Map();
17
+ for (const line of String(out || "").split("\n")) {
18
+ // Last two whitespace-separated tokens are rss + pcpu; the rest is comm
19
+ // (which may itself contain spaces/slashes, e.g. node process titles).
20
+ const m = line.trim().match(/^(.+?)\s+(\d+)\s+([\d.]+)$/);
21
+ if (!m)
22
+ continue;
23
+ const comm = m[1].trim();
24
+ const w = WATCH.find((x) => x.test(comm));
25
+ if (!w)
26
+ continue;
27
+ const cur = agg.get(w.key) || { name: w.key, count: 0, cpu: 0, mem: 0 };
28
+ cur.count++;
29
+ cur.cpu += Number(m[3]) || 0;
30
+ cur.mem += (Number(m[2]) || 0) * 1024; // rss KiB -> bytes
31
+ agg.set(w.key, cur);
32
+ }
33
+ return Array.from(agg.values())
34
+ .map((p) => ({ ...p, cpu: Math.round(p.cpu * 10) / 10 }))
35
+ .sort((a, b) => b.mem - a.mem);
36
+ }
@@ -4,6 +4,9 @@ import { createDockerPlugin } from "./monitoring/DockerPlugin";
4
4
  import { createLxcPlugin } from "./monitoring/LxcPlugin";
5
5
  import { createOSPlugin } from "./monitoring/OSPlugin";
6
6
  import { createWebsitePlugin } from "./monitoring/WebsitePlugin";
7
+ import { createWordpressPlugin } from "./monitoring/WordpressPlugin";
8
+ import { createLogWatchPlugin } from "./monitoring/LogWatchPlugin";
9
+ import { createNetScanPlugin } from "./monitoring/NetScanPlugin";
7
10
  // In future, implement other plugin factory functions using the same pattern
8
11
  // import { createOSPlugin } from "./monitoring/OSPlugin"
9
12
  // import { createMongoDBPlugin } from "./monitoring/MongoDBPlugin"
@@ -19,6 +22,9 @@ export default {
19
22
  Lxc: createLxcPlugin,
20
23
  OS: createOSPlugin,
21
24
  Website: createWebsitePlugin,
25
+ Wordpress: createWordpressPlugin,
26
+ Logwatch: createLogWatchPlugin,
27
+ Netscan: createNetScanPlugin,
22
28
  // CloudflarePlugin: createCloudflarePlugin,
23
29
  // EwelinkPlugin: createEwelinkPlugin,
24
30
  // MongoDBPlugin: createMongoDBPlugin,
@@ -0,0 +1,212 @@
1
+ import { stat, open } from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
4
+ // Cap a single tail read so a rotated-in giant file (or fromStart on a large
5
+ // log) can't blow up memory — read at most the last MAX_READ bytes.
6
+ const MAX_READ = 5 * 1024 * 1024;
7
+ // Field-position TOLERANT parsing. Log layouts vary a lot across these hosts:
8
+ // • standard combined: `1.2.3.4 - - [t] "GET / HTTP/1.1" 200 ...`
9
+ // • vhost-first: `example.com - 1.2.3.4 - - [t] "GET / HTTP/1.1" 200`
10
+ // • CF combined_with_host: `host - <edge_ip> - <cf_connecting_ip> - - [t] "GET .." "200"`
11
+ // So neither the IP column NOR the status quoting is fixed. We therefore:
12
+ // 1. pull the request + status from the quoted "$request" run (status may be
13
+ // bare or quoted → allow an optional leading quote), and
14
+ // 2. take the LAST IP token BEFORE the `[timestamp]` as the client. That is
15
+ // the real requester in every layout here: when Cloudflare's
16
+ // $http_cf_connecting_ip is present it is logged AFTER $remote_addr (the CF
17
+ // edge), so "last IP" is the visitor, not the edge; when it's absent there
18
+ // is a single IP ($remote_addr) and "last" == "only".
19
+ const REQ = /"([A-Z]+)\s+([^ "]+)[^"]*"\s+"?(\d{3})/;
20
+ const IP_TOKEN = /^(?:(?:\d{1,3}\.){3}\d{1,3}|(?:[0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F]{0,4})$/;
21
+ function parseLine(line) {
22
+ const rq = REQ.exec(line);
23
+ if (!rq)
24
+ return null;
25
+ const br = line.indexOf("[");
26
+ const pre = br > 0 ? line.slice(0, br) : line;
27
+ const tokens = pre.split(/\s+/);
28
+ const ips = tokens.filter((t) => IP_TOKEN.test(t));
29
+ const ip = ips.length ? ips[ips.length - 1] : (tokens.find(Boolean) || "");
30
+ return { ip, method: rq[1], path: rq[2], status: Number(rq[3]), ua: "" };
31
+ }
32
+ const SUSPICIOUS_QUERY = /(?:^|[?&])(c|k|cmd|exec|e|q|f|file|dir|download|0|1|a)=/i;
33
+ const BASE64_BLOB = /[A-Za-z0-9+/]{40,}={0,2}/;
34
+ const UPLOADS_PHP = /\/wp-content\/uploads\/.*\.php$/i;
35
+ const PLUGIN_PHP = /\/wp-content\/(?:plugins|mu-plugins)\/.*\.php$/i;
36
+ const BATCH_PATH = /^\/wp-json\/batch\/v1|rest_route=\/?batch\/v1/i;
37
+ // Read new bytes since last offset, advancing state. Handles rotation (file
38
+ // shrank → restart at 0) and partial trailing lines (buffered in remainder).
39
+ async function readNew(source, st) {
40
+ let size;
41
+ try {
42
+ size = (await stat(source.path)).size;
43
+ }
44
+ catch {
45
+ return []; // file missing this cycle; try again next tick
46
+ }
47
+ if (!st.inited) {
48
+ st.offset = source.fromStart ? 0 : size;
49
+ st.remainder = "";
50
+ st.inited = true;
51
+ }
52
+ if (size < st.offset) {
53
+ // Truncated or rotated in place.
54
+ st.offset = 0;
55
+ st.remainder = "";
56
+ }
57
+ if (size <= st.offset)
58
+ return []; // nothing new
59
+ let start = st.offset;
60
+ if (size - start > MAX_READ)
61
+ start = size - MAX_READ; // bound the read
62
+ const len = size - start;
63
+ const buf = Buffer.allocUnsafe(len);
64
+ const fh = await open(source.path, "r");
65
+ try {
66
+ await fh.read(buf, 0, len, start);
67
+ }
68
+ finally {
69
+ await fh.close();
70
+ }
71
+ st.offset = size;
72
+ const text = st.remainder + buf.toString("utf8");
73
+ const lines = text.split("\n");
74
+ st.remainder = lines.pop() ?? ""; // last element is a partial line (no newline)
75
+ return lines;
76
+ }
77
+ function analyze(lines, cfg, windowSec) {
78
+ const burstThreshold = cfg.xmlrpcBurstThreshold ?? 20;
79
+ const batchIps = new Map(); // ip → count of batch/v1 → 207
80
+ const loginOkIps = new Set(); // ip with POST wp-login → 302
81
+ const xmlrpcByIp = new Map(); // ip → POST xmlrpc count
82
+ const webshell = new Map(); // pathNoQuery → sample
83
+ let total = 0;
84
+ for (const line of lines) {
85
+ const e = parseLine(line);
86
+ if (!e)
87
+ continue;
88
+ total++;
89
+ const qIdx = e.path.indexOf("?");
90
+ const pathNoQuery = qIdx === -1 ? e.path : e.path.slice(0, qIdx);
91
+ const query = qIdx === -1 ? "" : e.path.slice(qIdx + 1);
92
+ if ((BATCH_PATH.test(e.path) || pathNoQuery === "/wp-json/batch/v1") && e.status === 207) {
93
+ batchIps.set(e.ip, (batchIps.get(e.ip) || 0) + 1);
94
+ }
95
+ if (pathNoQuery === "/wp-login.php" && e.method === "POST" && e.status === 302) {
96
+ loginOkIps.add(e.ip);
97
+ }
98
+ if (pathNoQuery === "/xmlrpc.php" && e.method === "POST") {
99
+ xmlrpcByIp.set(e.ip, (xmlrpcByIp.get(e.ip) || 0) + 1);
100
+ }
101
+ // A .php under uploads is never legitimate; under a plugin dir it's only
102
+ // suspicious with a command-style or base64 query (webshell invocation).
103
+ // BUT only a SUCCESSFUL (2xx) response means the PHP actually executed —
104
+ // every WP site is probed constantly for my.php / academy.php / etc., and
105
+ // those hits 404 (not there) or 403 (WAF-blocked). Alerting on the probes
106
+ // is pure noise; alert only when the shell actually RAN.
107
+ const executed = e.status >= 200 && e.status < 300;
108
+ const isUploadsPhp = UPLOADS_PHP.test(pathNoQuery);
109
+ const isPluginShell = PLUGIN_PHP.test(pathNoQuery) && (SUSPICIOUS_QUERY.test(query) || BASE64_BLOB.test(query));
110
+ if ((isUploadsPhp || isPluginShell) && executed) {
111
+ const cur = webshell.get(pathNoQuery) || { ip: e.ip, count: 0 };
112
+ cur.count++;
113
+ webshell.set(pathNoQuery, cur);
114
+ }
115
+ }
116
+ const findings = [];
117
+ // Correlation is the highest-signal event — flag it before the standalone
118
+ // batch/v1 finding so it leads.
119
+ for (const [ip, count] of batchIps) {
120
+ if (loginOkIps.has(ip)) {
121
+ findings.push({
122
+ rule: "batch-then-login", key: `batch-login:${ip}`, severity: "error", ip,
123
+ path: "/wp-json/batch/v1",
124
+ title: "WP2Shell: exploit + admin login",
125
+ detail: `${ip}: batch/v1 exploit (${count}×) followed by a successful wp-login (HTTP 302) — likely account takeover`,
126
+ });
127
+ }
128
+ else {
129
+ findings.push({
130
+ rule: "wp-batch-207", key: `batchv1:${ip}`, severity: "error", ip,
131
+ path: "/wp-json/batch/v1",
132
+ title: "WP2Shell batch/v1 exploit",
133
+ detail: `${ip}: POST /wp-json/batch/v1 → 207 (${count}×) — near-zero false positives`,
134
+ });
135
+ }
136
+ }
137
+ for (const [p, v] of webshell) {
138
+ findings.push({
139
+ rule: "webshell-exec", key: `webshell:${p}`, severity: "error", ip: v.ip, path: p,
140
+ title: "Webshell execution",
141
+ detail: `${v.ip} invoked ${p} (${v.count}×) — executable PHP in an upload/plugin path`,
142
+ });
143
+ }
144
+ for (const [ip, count] of xmlrpcByIp) {
145
+ if (count >= burstThreshold) {
146
+ findings.push({
147
+ rule: "xmlrpc-burst", key: `xmlrpc:${ip}`, severity: count >= burstThreshold * 5 ? "error" : "warning", ip,
148
+ path: "/xmlrpc.php",
149
+ title: "xmlrpc.php brute-force / amplification",
150
+ detail: `${ip}: ${count} POST /xmlrpc.php in ~${windowSec}s (threshold ${burstThreshold})`,
151
+ });
152
+ }
153
+ }
154
+ const counts = {
155
+ total,
156
+ batchV1: [...batchIps.values()].reduce((a, b) => a + b, 0),
157
+ xmlrpcPosts: [...xmlrpcByIp.values()].reduce((a, b) => a + b, 0),
158
+ webshellHits: [...webshell.values()].reduce((a, b) => a + b.count, 0),
159
+ loginSuccess: loginOkIps.size,
160
+ };
161
+ return { findings, counts };
162
+ }
163
+ function slug(s) {
164
+ return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
165
+ }
166
+ export function createLogWatchPlugin() {
167
+ let refreshTimer = null;
168
+ const states = new Map();
169
+ const sourcesOf = (plugin) => {
170
+ const cfg = (plugin.config || {});
171
+ if (Array.isArray(cfg.sources) && cfg.sources.length)
172
+ return cfg.sources;
173
+ if (cfg.path)
174
+ return [{ name: cfg.name, path: cfg.path, fromStart: cfg.fromStart }];
175
+ return [];
176
+ };
177
+ const refreshFn = async (plugin) => {
178
+ const cfg = (plugin.config || {});
179
+ const windowSec = Math.round((cfg.refreshInterval || 60000) / 1000);
180
+ for (const source of sourcesOf(plugin)) {
181
+ if (!source?.path)
182
+ continue;
183
+ const label = source.name || path.basename(source.path);
184
+ const uid = `log-${slug(source.name || source.path)}`;
185
+ let st = states.get(source.path);
186
+ if (!st) {
187
+ st = { offset: 0, remainder: "", inited: false };
188
+ states.set(source.path, st);
189
+ }
190
+ try {
191
+ const lines = await readNew(source, st);
192
+ const { findings, counts } = analyze(lines, cfg, windowSec);
193
+ await plugin.send({ name: label, source: label, path: source.path, window: windowSec, counts, findings }, uid);
194
+ }
195
+ catch (e) {
196
+ await plugin.send({ name: label, source: label, path: source.path, error: String(e?.message || e).slice(0, 160), findings: [] }, uid);
197
+ }
198
+ }
199
+ };
200
+ const monitorFn = async (plugin) => {
201
+ await refreshFn(plugin);
202
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
203
+ };
204
+ const teardownFn = async () => {
205
+ if (refreshTimer)
206
+ clearInterval(refreshTimer);
207
+ refreshTimer = null;
208
+ states.clear();
209
+ };
210
+ return createMonitoringPlugin("logwatch", "logwatch", "Access-log security watcher (WP2Shell / webshell / xmlrpc)", async () => { }, monitorFn, refreshFn, teardownFn);
211
+ }
212
+ export default createLogWatchPlugin;
@@ -0,0 +1,77 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
4
+ // LAN device reachability monitor. Pings a configured list of devices (the
5
+ // office infrastructure — switch, printers, APs, extenders, PBX, router, NAS,
6
+ // phones — plus any hosts worth watching) and reports up/down + latency for
7
+ // each. No credentials needed: pure ICMP. Richer per-device metrics (printer
8
+ // toner/pages, switch ports) come from the companion SNMP plugin.
9
+ //
10
+ // One client_state per device (type "netdevice", uid net-<slug>), so each shows
11
+ // as its own service and can be grouped (e.g. under "New York Office").
12
+ const pexec = promisify(execFile);
13
+ function slug(s) {
14
+ return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
15
+ }
16
+ // Ping via the system `ping` (works unprivileged in a host-network container).
17
+ // Returns reachability + average RTT in ms. Never throws — an unreachable host
18
+ // exits non-zero, which we read as down.
19
+ async function pingHost(ip, count, timeout) {
20
+ try {
21
+ const { stdout } = await pexec("ping", ["-c", String(count), "-W", String(timeout), ip], {
22
+ timeout: (timeout * count + 2) * 1000,
23
+ });
24
+ // "rtt min/avg/max/mdev = 0.3/0.5/0.7/0.1 ms" (iputils) or a single "time=x ms"
25
+ const avg = stdout.match(/=\s*[\d.]+\/([\d.]+)\//);
26
+ const single = stdout.match(/time[=<]\s*([\d.]+)/);
27
+ const lossM = stdout.match(/([\d.]+)%\s*packet loss/);
28
+ const latency = avg ? parseFloat(avg[1]) : single ? parseFloat(single[1]) : undefined;
29
+ const loss = lossM ? parseFloat(lossM[1]) : undefined;
30
+ // 100% loss can still exit 0 on some ping builds — treat as down.
31
+ if (loss === 100)
32
+ return { up: false, latency: undefined, loss };
33
+ return { up: true, latency: latency != null ? Math.round(latency * 10) / 10 : undefined, loss };
34
+ }
35
+ catch {
36
+ return { up: false };
37
+ }
38
+ }
39
+ export function createNetScanPlugin() {
40
+ let refreshTimer = null;
41
+ const devicesOf = (plugin) => {
42
+ const cfg = (plugin.config || {});
43
+ return Array.isArray(cfg.devices) ? cfg.devices.filter((d) => d?.ip) : [];
44
+ };
45
+ const refreshFn = async (plugin) => {
46
+ const cfg = (plugin.config || {});
47
+ const count = cfg.pingCount || 2;
48
+ const timeout = cfg.pingTimeout || 2;
49
+ const devices = devicesOf(plugin);
50
+ // Ping all devices concurrently — a scan of ~dozens stays well under the
51
+ // refresh interval.
52
+ await Promise.all(devices.map(async (d) => {
53
+ const r = await pingHost(d.ip, count, timeout);
54
+ await plugin.send({
55
+ name: d.name,
56
+ ip: d.ip,
57
+ type: d.type || "host",
58
+ mac: d.mac,
59
+ up: r.up,
60
+ latency: r.latency,
61
+ loss: r.loss,
62
+ warnMs: d.warnMs ?? 200,
63
+ }, `net-${slug(d.name || d.ip)}`);
64
+ }));
65
+ };
66
+ const monitorFn = async (plugin) => {
67
+ await refreshFn(plugin);
68
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 30000);
69
+ };
70
+ const teardownFn = async () => {
71
+ if (refreshTimer)
72
+ clearInterval(refreshTimer);
73
+ refreshTimer = null;
74
+ };
75
+ return createMonitoringPlugin("netscan", "netscan", "LAN device reachability (ping) monitor", async () => { }, monitorFn, refreshFn, teardownFn);
76
+ }
77
+ export default createNetScanPlugin;
@@ -1,6 +1,87 @@
1
1
  import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
2
2
  import { arch, cpus, freemem, hostname, loadavg, networkInterfaces, platform, release, totalmem, type, uptime, } from "os";
3
3
  import { readFileSync } from "fs";
4
+ import { execSync } from "child_process";
5
+ import { parseProcesses, PS_CMD } from "../../lib/processes";
6
+ // Named service processes (nginx / php-fpm / db / …) running on the host.
7
+ function collectProcesses() {
8
+ try {
9
+ return parseProcesses(execSync(PS_CMD, { encoding: "utf8", timeout: 5000 }));
10
+ }
11
+ catch {
12
+ return [];
13
+ }
14
+ }
15
+ // Pseudo / virtual filesystems that aren't real drives.
16
+ const PSEUDO_FS = new Set([
17
+ "tmpfs", "devtmpfs", "squashfs", "overlay", "overlayfs", "aufs", "proc", "sysfs",
18
+ "cgroup", "cgroup2", "mqueue", "debugfs", "tracefs", "devpts", "ramfs", "nsfs",
19
+ "binfmt_misc", "configfs", "pstore", "efivarfs", "autofs", "fusectl", "securityfs",
20
+ "hugetlbfs", "none", "fuse.gvfsd-fuse", "fuse.portal", "fuse.snapfuse",
21
+ ]);
22
+ // Real, physical/attached drives only. Shells out to `df` and filters out
23
+ // pseudo filesystems, loop/snap mounts, and bind-mount duplicates.
24
+ function collectDisks() {
25
+ try {
26
+ const darwin = platform() === "darwin";
27
+ // Linux: `-T` gives the fstype column; bytes via --block-size=1.
28
+ // macOS: no fstype flag, `-l` restricts to local filesystems.
29
+ const cmd = darwin ? "df -kl" : "df -PT --block-size=1";
30
+ const out = execSync(cmd, { encoding: "utf8", timeout: 5000 });
31
+ const raw = [];
32
+ for (const line of out.split("\n").slice(1)) {
33
+ const p = line.trim().split(/\s+/);
34
+ if (p.length < 6)
35
+ continue;
36
+ let device, fstype, total, used, free, mount;
37
+ if (darwin) {
38
+ // Filesystem 1K-blocks Used Avail Capacity iused ifree %iused Mounted-on
39
+ device = p[0];
40
+ fstype = "";
41
+ total = (Number(p[1]) || 0) * 1024;
42
+ used = (Number(p[2]) || 0) * 1024;
43
+ free = (Number(p[3]) || 0) * 1024;
44
+ mount = p.slice(8).join(" ");
45
+ if (!device.startsWith("/dev/"))
46
+ continue;
47
+ // macOS/APFS spawns many synthetic /dev/diskNsM volumes. The real
48
+ // main-disk usage lives on the Data volume (the "/" snapshot reads
49
+ // near-empty); real external drives mount under /Volumes. Drop the
50
+ // rest (/System/Volumes/*, CoreSimulator, etc.).
51
+ if (mount === "/System/Volumes/Data")
52
+ mount = "/";
53
+ else if (mount !== "/" && !mount.startsWith("/Volumes/"))
54
+ continue;
55
+ }
56
+ else {
57
+ // Filesystem Type 1B-blocks Used Available Capacity Mounted-on
58
+ device = p[0];
59
+ fstype = p[1];
60
+ total = Number(p[2]) || 0;
61
+ used = Number(p[3]) || 0;
62
+ free = Number(p[4]) || 0;
63
+ mount = p.slice(6).join(" ");
64
+ if (PSEUDO_FS.has(fstype))
65
+ continue;
66
+ }
67
+ if (total <= 0 || device === "overlay" || device === "none" || device.startsWith("/dev/loop"))
68
+ continue;
69
+ raw.push({ device, fstype, mount, total, used, free, pct: total ? Math.round((used / total) * 100) : 0 });
70
+ }
71
+ // Dedupe by mount, keeping the fullest entry — collapses bind mounts, and on
72
+ // macOS picks the real Data volume over the near-empty read-only "/" snapshot.
73
+ const byMount = new Map();
74
+ for (const d of raw) {
75
+ const cur = byMount.get(d.mount);
76
+ if (!cur || d.used > cur.used)
77
+ byMount.set(d.mount, d);
78
+ }
79
+ return Array.from(byMount.values()).sort((a, b) => b.pct - a.pct);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
4
85
  // True available memory: on Linux, os.freemem() returns MemFree, which excludes
5
86
  // reclaimable page cache and makes hosts look near-full. MemAvailable is the
6
87
  // kernel's estimate of memory obtainable without swapping. Fall back to freemem()
@@ -19,10 +100,39 @@ function availableMem() {
19
100
  }
20
101
  return freemem();
21
102
  }
22
- // Host OS metrics: cpu (from cpus().times), memory, load, uptime, addresses.
23
- // Matches the frontend `os` fleetAdapter case.
103
+ // Cumulative rx/tx byte counters summed across real interfaces (Linux
104
+ // /proc/net/dev). Loopback and virtual bridges (docker/veth/lxc/br-) are skipped
105
+ // so the figure reflects actual host network traffic, not container-to-host
106
+ // chatter. Returns null off Linux (rate is then omitted, not faked).
107
+ function readNetTotals() {
108
+ if (platform() !== "linux")
109
+ return null;
110
+ try {
111
+ const data = readFileSync("/proc/net/dev", "utf8");
112
+ let rx = 0, tx = 0;
113
+ for (const line of data.split("\n")) {
114
+ const m = line.match(/^\s*([^:]+):\s*(.*)$/);
115
+ if (!m)
116
+ continue;
117
+ const iface = m[1].trim();
118
+ if (iface === "lo" || /^(veth|docker|br-|lxcbr|virbr|tap|tun)/.test(iface))
119
+ continue;
120
+ const cols = m[2].trim().split(/\s+/).map(Number);
121
+ rx += cols[0] || 0; // column 1 = rx bytes
122
+ tx += cols[8] || 0; // column 9 = tx bytes
123
+ }
124
+ return { rx, tx };
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ // Host OS metrics: cpu (from cpus().times), memory, disk, load, uptime,
131
+ // addresses, and network throughput. Matches the frontend `os` fleetAdapter case.
24
132
  export function createOSPlugin() {
25
133
  let refreshTimer = null;
134
+ // Previous cumulative net counters, to derive a per-second rate across polls.
135
+ let prevNet = null;
26
136
  const collect = () => {
27
137
  const interfaces = networkInterfaces();
28
138
  const addresses = {};
@@ -33,11 +143,33 @@ export function createOSPlugin() {
33
143
  if (found)
34
144
  addresses[key] = { address: found.address, netmask: found.netmask, mac: found.mac };
35
145
  }
146
+ // Network throughput: bytes/sec since the previous sample. The first poll
147
+ // has no baseline, so rate is omitted (undefined) rather than a bogus 0.
148
+ let network;
149
+ const totals = readNetTotals();
150
+ if (totals) {
151
+ const now = Date.now();
152
+ if (prevNet) {
153
+ const dt = (now - prevNet.ts) / 1000;
154
+ if (dt > 0) {
155
+ network = {
156
+ rxBytes: totals.rx,
157
+ txBytes: totals.tx,
158
+ rxRate: Math.max(0, Math.round((totals.rx - prevNet.rx) / dt)),
159
+ txRate: Math.max(0, Math.round((totals.tx - prevNet.tx) / dt)),
160
+ };
161
+ }
162
+ }
163
+ prevNet = { rx: totals.rx, tx: totals.tx, ts: now };
164
+ }
36
165
  return {
37
166
  addresses,
38
167
  totalmem: totalmem(),
39
168
  freemem: freemem(),
40
169
  available: availableMem(),
170
+ disks: collectDisks(),
171
+ processes: collectProcesses(),
172
+ network,
41
173
  loadavg: loadavg(),
42
174
  uptime: uptime(),
43
175
  hostname: hostname(),
@@ -1,12 +1,95 @@
1
+ import * as http from "node:http";
2
+ import * as https from "node:https";
3
+ import { URL } from "node:url";
1
4
  import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
5
+ // Low-level GET that can hit one host (`target`) while presenting a different
6
+ // Host header. fetch() can't do this — Host is a forbidden header per the
7
+ // Fetch spec and is silently dropped — so this uses Node's http/https
8
+ // directly, which honors it.
9
+ function rawGet(target, opts) {
10
+ return new Promise((resolve) => {
11
+ const start = Date.now();
12
+ let u;
13
+ try {
14
+ u = new URL(target);
15
+ }
16
+ catch {
17
+ resolve({ status: 0, body: "", duration: 0, error: `Invalid origin URL: ${target}` });
18
+ return;
19
+ }
20
+ for (const [k, v] of Object.entries(opts.query || {}))
21
+ u.searchParams.set(k, v);
22
+ const headers = { ...(opts.headers || {}) };
23
+ if (opts.host)
24
+ headers.Host = opts.host;
25
+ const lib = u.protocol === "http:" ? http : https;
26
+ const req = lib.request({
27
+ hostname: u.hostname,
28
+ port: u.port || (u.protocol === "http:" ? 80 : 443),
29
+ path: `${u.pathname}${u.search}`,
30
+ method: "GET",
31
+ headers,
32
+ // Explicit servername pins TLS/SNI to the real connection
33
+ // target. Without it, Node derives SNI from headers.Host,
34
+ // which would validate the cert against the WRONG hostname
35
+ // (the public one, not the origin actually being dialed).
36
+ ...(u.protocol === "https:" ? { servername: u.hostname, rejectUnauthorized: !opts.insecure } : {}),
37
+ }, (res) => {
38
+ let body = "";
39
+ res.on("data", (c) => { body += c; });
40
+ res.on("end", () => resolve({ status: res.statusCode || 0, body, duration: Date.now() - start }));
41
+ });
42
+ req.on("error", (err) => resolve({ status: 0, body: "", duration: Date.now() - start, error: err.message }));
43
+ const timeoutMs = opts.timeout || 10000;
44
+ req.setTimeout(timeoutMs, () => {
45
+ req.destroy();
46
+ resolve({ status: 0, body: "", duration: Date.now() - start, error: "timeout" });
47
+ });
48
+ req.end();
49
+ });
50
+ }
51
+ function evaluate(body, status, expected, unexpected, expectedStatusCode) {
52
+ // Report WHICH strings matched/went missing, not just booleans. For security
53
+ // checks (injected-script IOCs, webshell markers, defacement canaries) the
54
+ // alert is only actionable if it names the offending string — "IOC _ea_s
55
+ // matched" vs. an opaque "check failed". `unexpectedStrings` doubles as an
56
+ // IOC list; `expectedStrings` doubles as an integrity canary (its absence =
57
+ // possible defacement). Kept as substring matches (no regex) to preserve the
58
+ // existing config contract.
59
+ const missingExpected = expected.filter((s) => body.indexOf(s) === -1);
60
+ const matchedUnexpected = unexpected.filter((s) => body.indexOf(s) !== -1);
61
+ const hasAllExpectedStrings = missingExpected.length === 0;
62
+ const hasAnyUnexpectedStrings = matchedUnexpected.length > 0;
63
+ const statusOk = expectedStatusCode ? status === expectedStatusCode : status >= 200 && status < 400;
64
+ return { ok: statusOk && hasAllExpectedStrings && !hasAnyUnexpectedStrings, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected };
65
+ }
66
+ async function checkOrigin(ep) {
67
+ const oc = ep.origin;
68
+ const r = await rawGet(oc.url, {
69
+ host: oc.host,
70
+ headers: oc.headers,
71
+ query: oc.query,
72
+ insecure: oc.insecure,
73
+ timeout: oc.timeout ?? ep.timeout,
74
+ });
75
+ if (r.error) {
76
+ return { url: oc.url, host: oc.host, status: r.status, duration: r.duration, ok: false, error: r.error };
77
+ }
78
+ const { ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected } = evaluate(r.body, r.status, oc.expectedStrings ?? ep.expectedStrings ?? [], oc.unexpectedStrings ?? ep.unexpectedStrings ?? [], oc.expectedStatusCode ?? ep.expectedStatusCode);
79
+ return { url: oc.url, host: oc.host, status: r.status, duration: r.duration, ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected };
80
+ }
2
81
  // HTTP uptime/content checks. Emits one client_state per endpoint, matching the
3
82
  // frontend `web`/`website` fleetAdapter case ({ url, name, ok, status, duration }).
83
+ // Optionally two-tiered: the public URL (through Cloudflare/CDN) plus a direct
84
+ // origin check, nested under `origin`, so a CDN masking an origin outage still
85
+ // surfaces as a warning even while the public site keeps serving fine.
4
86
  export function createWebsitePlugin() {
5
87
  let refreshTimer = null;
6
88
  const check = async (plugin, ep) => {
7
89
  const start = Date.now();
8
90
  const label = ep.name || ep.url;
9
91
  const uid = `web-${(ep.name || ep.url).replace(/[^a-zA-Z0-9_.-]+/g, "-")}`;
92
+ let publicResult;
10
93
  try {
11
94
  const ctrl = new AbortController();
12
95
  const t = ep.timeout ? setTimeout(() => ctrl.abort(), ep.timeout) : null;
@@ -15,44 +98,98 @@ export function createWebsitePlugin() {
15
98
  clearTimeout(t);
16
99
  const body = await res.text();
17
100
  const duration = Date.now() - start;
18
- const expected = ep.expectedStrings || [];
19
- const unexpected = ep.unexpectedStrings || [];
20
- const hasAllExpected = expected.every((s) => body.indexOf(s) !== -1);
21
- const hasAnyUnexpected = unexpected.some((s) => body.indexOf(s) !== -1);
22
- const statusOk = ep.expectedStatusCode ? res.status === ep.expectedStatusCode : res.ok;
23
- const ok = statusOk && hasAllExpected && !hasAnyUnexpected;
24
- await plugin.send({
101
+ const { ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected } = evaluate(body, res.status, ep.expectedStrings || [], ep.unexpectedStrings || [], ep.expectedStatusCode);
102
+ publicResult = {
25
103
  name: label,
26
104
  url: res.url || ep.url,
27
105
  status: res.status,
28
106
  duration,
29
107
  ok,
30
108
  redirected: res.redirected,
31
- hasAllExpectedStrings: hasAllExpected,
32
- hasAnyUnexpectedStrings: hasAnyUnexpected,
33
- }, uid);
109
+ hasAllExpectedStrings,
110
+ hasAnyUnexpectedStrings,
111
+ missingExpected,
112
+ matchedUnexpected,
113
+ };
34
114
  }
35
115
  catch (err) {
36
- await plugin.send({
116
+ publicResult = {
37
117
  name: label,
38
118
  url: ep.url,
39
119
  status: 0,
40
120
  duration: Date.now() - start,
41
121
  ok: false,
42
122
  error: err.message,
43
- }, uid);
123
+ };
44
124
  }
125
+ const origin = ep.origin?.url ? await checkOrigin(ep) : undefined;
126
+ // `ok` stays the PUBLIC tier's own result — don't collapse it with
127
+ // origin.ok. The two tiers are different failure modes with different
128
+ // severity (public down = real outage; origin-only down = CDN masking
129
+ // a backend problem, still worth a warning). Collapsing them into one
130
+ // boolean loses that distinction downstream (ingest branches on
131
+ // `ok`/`origin.ok` separately to assign error vs warning).
132
+ await plugin.send({ ...publicResult, origin }, uid);
45
133
  };
46
- const endpointsOf = (plugin) => {
134
+ const localEndpointsOf = (plugin) => {
47
135
  const cfg = plugin.config || {};
48
136
  if (Array.isArray(cfg.endpoints))
49
137
  return cfg.endpoints;
50
- if (cfg.url)
51
- return [{ name: cfg.name, url: cfg.url, expectedStrings: cfg.expectedStrings, unexpectedStrings: cfg.unexpectedStrings }];
138
+ if (cfg.url) {
139
+ return [{
140
+ name: cfg.name,
141
+ url: cfg.url,
142
+ expectedStrings: cfg.expectedStrings,
143
+ unexpectedStrings: cfg.unexpectedStrings,
144
+ expectedStatusCode: cfg.expectedStatusCode,
145
+ timeout: cfg.timeout,
146
+ origin: cfg.origin,
147
+ }];
148
+ }
52
149
  return [];
53
150
  };
151
+ // Server-managed endpoints: when the config carries a `configUrl` (the
152
+ // daemonitor /api/monitors/for-client endpoint) and this client's
153
+ // `systemKey`, monitors edited in the UI are pulled from there. The last
154
+ // successful fetch is cached so a transient network/API failure keeps the
155
+ // previous set running instead of silently dropping every check.
156
+ let lastRemote = null;
157
+ const remoteEndpointsOf = async (plugin) => {
158
+ const cfg = plugin.config || {};
159
+ const configUrl = cfg.configUrl;
160
+ const systemKey = cfg.systemKey;
161
+ if (!configUrl || !systemKey)
162
+ return null;
163
+ try {
164
+ const res = await fetch(configUrl, { headers: { "x-system-key": systemKey } });
165
+ if (!res.ok)
166
+ return lastRemote;
167
+ const body = await res.json();
168
+ const eps = Array.isArray(body?.endpoints) ? body.endpoints : [];
169
+ lastRemote = eps;
170
+ return eps;
171
+ }
172
+ catch {
173
+ return lastRemote;
174
+ }
175
+ };
176
+ const endpointsOf = async (plugin) => {
177
+ const local = localEndpointsOf(plugin);
178
+ const remote = await remoteEndpointsOf(plugin);
179
+ if (remote == null)
180
+ return local;
181
+ // Merge by name/url: server-managed monitors win, local-only ones
182
+ // (defined directly in the config file) are kept alongside.
183
+ const key = (e) => `${e.name || ""}|${e.url}`;
184
+ const merged = new Map();
185
+ for (const e of local)
186
+ merged.set(key(e), e);
187
+ for (const e of remote)
188
+ merged.set(key(e), e);
189
+ return [...merged.values()];
190
+ };
54
191
  const refreshFn = async (plugin) => {
55
- const eps = endpointsOf(plugin);
192
+ const eps = await endpointsOf(plugin);
56
193
  for (const ep of eps) {
57
194
  if (ep?.url)
58
195
  await check(plugin, ep);
@@ -0,0 +1,248 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { readdir } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
6
+ // WordPress security-posture plugin. Runs on the WP host (where the daemonitor
7
+ // client already runs) and polls the state that the Archpaper intrusions moved
8
+ // through: admin accounts, page statuses, must-use plugins, and the web user's
9
+ // crontab. It emits raw observations only — severity and the new-admin /
10
+ // admin-demotion DIFFS are decided server-side in the ingest handler
11
+ // (clientstate/update.put.ts, case "wordpress"), so an agent restart can't reset
12
+ // the baseline and let a change slip through.
13
+ //
14
+ // Collection is via WP-CLI + filesystem + crontab (no DB credentials, no extra
15
+ // npm dependency) — mirroring MongoDBPlugin's shell-out pattern. Every collector
16
+ // is best-effort and isolated: one failing (e.g. crontab needs root) degrades
17
+ // that one field to null, it never kills the poll.
18
+ const pexec = promisify(execFile);
19
+ // Service-account naming patterns seen in the Archpaper intrusions. Any admin
20
+ // whose login/email contains one of these (case-insensitive substring) is
21
+ // flagged for escalation even before the new-admin diff fires.
22
+ const DEFAULT_SERVICE_PATTERNS = [
23
+ "wpsvc", "wpengine", "bot", "svc", "researchlabs", "wordpress-svc",
24
+ ];
25
+ const DEFAULT_CRITICAL_PAGES = ["login", "home", "index", "front-page"];
26
+ function slug(s) {
27
+ return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
28
+ }
29
+ const MAXBUF = 8 * 1024 * 1024;
30
+ // Run a command in the site's context — inside its LXC container when `lxc` is
31
+ // set (via `sudo -n lxc exec <c> -- …`), else on the host (optionally as
32
+ // `runAs`). Returns stdout, or throws — callers isolate their own failures.
33
+ async function execInSite(site, argv, timeout = 15000) {
34
+ if (site.lxc) {
35
+ const lxcBin = site.lxcBin || "lxc";
36
+ const { stdout } = await pexec("sudo", ["-n", lxcBin, "exec", site.lxc, "--", ...argv], { timeout, maxBuffer: MAXBUF });
37
+ return stdout;
38
+ }
39
+ if (site.runAs) {
40
+ const { stdout } = await pexec("sudo", ["-n", "-u", site.runAs, ...argv], { timeout, maxBuffer: MAXBUF });
41
+ return stdout;
42
+ }
43
+ const { stdout } = await pexec(argv[0], argv.slice(1), { timeout, maxBuffer: MAXBUF });
44
+ return stdout;
45
+ }
46
+ // Run `wp <args>` in the site's context. Adds:
47
+ // --skip-plugins/--skip-themes so a plugin/theme's PHP notices don't get
48
+ // printed onto stdout ahead of the JSON and break the parse;
49
+ // --url when known (fixes wp-config reads of $_SERVER['SERVER_NAME']; also
50
+ // correct for multisite);
51
+ // --allow-root when running via `lxc exec` (that lands as root in the
52
+ // container, and WP-CLI refuses root without it).
53
+ async function wp(site, args, timeout = 15000) {
54
+ const bin = site.wpCli || "wp";
55
+ const full = [...args, `--path=${site.path}`, "--skip-plugins", "--skip-themes"];
56
+ if (site.url)
57
+ full.push(`--url=${site.url}`);
58
+ if (site.lxc)
59
+ full.push("--allow-root");
60
+ return execInSite(site, [bin, ...full], timeout);
61
+ }
62
+ function parseJsonSafe(s, fallback) {
63
+ try {
64
+ const v = JSON.parse(s);
65
+ return v == null ? fallback : v;
66
+ }
67
+ catch {
68
+ return fallback;
69
+ }
70
+ }
71
+ async function collectSite(site) {
72
+ const label = site.name || path.basename(site.path || "") || "wordpress";
73
+ const servicePatterns = (site.servicePatterns || DEFAULT_SERVICE_PATTERNS).map((s) => s.toLowerCase());
74
+ const criticalPages = (site.criticalPages || DEFAULT_CRITICAL_PAGES).map((s) => s.toLowerCase());
75
+ const out = { name: label, site: label, url: site.url };
76
+ // 1. Current administrators (the rogue-admin / demotion signal set).
77
+ try {
78
+ const raw = await wp(site, ["user", "list", "--role=administrator", "--fields=ID,user_login,user_email,user_registered", "--format=json"]);
79
+ const admins = parseJsonSafe(raw, []).map((u) => ({
80
+ id: String(u.ID ?? ""),
81
+ login: String(u.user_login ?? ""),
82
+ email: String(u.user_email ?? ""),
83
+ registered: u.user_registered ?? null,
84
+ }));
85
+ out.admins = admins;
86
+ // Service-account-pattern admins — flagged locally so they surface even on
87
+ // the very first poll, before the server-side new-admin diff has a baseline.
88
+ out.suspiciousAdmins = admins
89
+ .filter((a) => servicePatterns.some((p) => a.login.toLowerCase().includes(p) || a.email.toLowerCase().includes(p)))
90
+ .map((a) => ({ login: a.login, email: a.email, reason: "service-account naming pattern" }));
91
+ }
92
+ catch (e) {
93
+ out.adminsError = String(e?.message || e).slice(0, 160);
94
+ }
95
+ // 2. WP core version (posture; latest-version comparison is done elsewhere).
96
+ try {
97
+ out.wpVersion = (await wp(site, ["core", "version"])).trim();
98
+ }
99
+ catch (e) {
100
+ out.wpVersionError = String(e?.message || e).slice(0, 160);
101
+ }
102
+ // 3. Trashed pages/posts — attackers hid the login/home page by trashing it.
103
+ try {
104
+ const raw = await wp(site, ["post", "list", "--post_status=trash", "--post_type=page,post", "--fields=ID,post_title,post_name,post_type", "--format=json"]);
105
+ const trashed = parseJsonSafe(raw, []).map((p) => ({
106
+ id: String(p.ID ?? ""),
107
+ title: String(p.post_title ?? ""),
108
+ slug: String(p.post_name ?? ""),
109
+ type: String(p.post_type ?? ""),
110
+ }));
111
+ out.trashedPages = trashed;
112
+ out.trashedCriticalPages = trashed.filter((p) => criticalPages.includes(p.slug.toLowerCase()) || criticalPages.includes(p.title.toLowerCase()));
113
+ }
114
+ catch (e) {
115
+ out.trashedError = String(e?.message || e).slice(0, 160);
116
+ }
117
+ // 4. mu-plugins auto-load with no UI to disable them — a favorite persistence
118
+ // spot. Report every .php and which ones aren't in the allowlist. Read via the
119
+ // container when the site is LXC (the path is inside the container's fs).
120
+ try {
121
+ const dir = path.join(site.path, "wp-content", "mu-plugins");
122
+ let entries;
123
+ if (site.lxc) {
124
+ // `ls -1` inside the container; empty/no-dir → treat as no mu-plugins.
125
+ const out2 = await execInSite(site, ["ls", "-1", dir], 8000).catch(() => "");
126
+ entries = out2.split("\n").map((s) => s.trim()).filter(Boolean);
127
+ }
128
+ else {
129
+ entries = await readdir(dir);
130
+ }
131
+ const php = entries.filter((f) => f.toLowerCase().endsWith(".php"));
132
+ out.muPlugins = php;
133
+ if (site.muPluginsAllowlist) {
134
+ const allow = new Set(site.muPluginsAllowlist);
135
+ out.unknownMuPlugins = php.filter((f) => !allow.has(f));
136
+ }
137
+ }
138
+ catch {
139
+ // no mu-plugins dir = fine; leave muPlugins undefined
140
+ }
141
+ // 5. Web user's crontab — www-data should almost never have one; the attacker
142
+ // used it to re-drop webshells every 5 min. Non-empty = alert. `crontab -u`
143
+ // needs privilege: try direct first, then `sudo -n` (the agent user typically
144
+ // has passwordless sudo). "no crontab for <user>" is the HEALTHY empty case,
145
+ // not an error — anything else (still denied) is a real collection failure.
146
+ const cronUser = site.cronUser || "www-data";
147
+ const readCron = async () => {
148
+ // LXC: the web user's crontab lives INSIDE the container (that's where the
149
+ // persistence would be dropped), so read it there.
150
+ if (site.lxc) {
151
+ try {
152
+ return await execInSite(site, ["crontab", "-l", "-u", cronUser], 8000);
153
+ }
154
+ catch (e2) {
155
+ const msg = String(e2?.stderr || e2?.message || "");
156
+ // "no crontab for" = empty (healthy). "cannot use this program" /
157
+ // "not allowed to use" = the user is denied crontab entirely
158
+ // (cron.deny / not in cron.allow) — that's GOOD posture (can't be used
159
+ // for persistence), not a collection failure. Treat both as clean.
160
+ if (/no crontab for|cannot use this program|not allowed to use/i.test(msg))
161
+ return "";
162
+ throw e2;
163
+ }
164
+ }
165
+ try {
166
+ return (await pexec("crontab", ["-l", "-u", cronUser], { timeout: 8000 })).stdout;
167
+ }
168
+ catch (e) {
169
+ const msg = String(e?.stderr || e?.message || "");
170
+ if (/no crontab for/i.test(msg))
171
+ return ""; // empty is fine
172
+ if (/privileged|not permitted|not allowed|denied/i.test(msg)) {
173
+ // Retry under sudo; may itself throw "no crontab for" (empty) → catch.
174
+ try {
175
+ return (await pexec("sudo", ["-n", "crontab", "-l", "-u", cronUser], { timeout: 8000 })).stdout;
176
+ }
177
+ catch (e2) {
178
+ if (/no crontab for/i.test(String(e2?.stderr || e2?.message || "")))
179
+ return "";
180
+ throw e2;
181
+ }
182
+ }
183
+ throw e;
184
+ }
185
+ };
186
+ try {
187
+ const stdout = await readCron();
188
+ const lines = stdout.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
189
+ out.webUserCrontab = lines.join("\n") || null;
190
+ out.hasWebUserCrontab = lines.length > 0;
191
+ out.cronUser = cronUser;
192
+ }
193
+ catch (e) {
194
+ out.cronError = String(e?.stderr || e?.message || e).slice(0, 160);
195
+ }
196
+ return out;
197
+ }
198
+ export function createWordpressPlugin() {
199
+ let refreshTimer = null;
200
+ const sitesOf = (plugin) => {
201
+ const cfg = (plugin.config || {});
202
+ if (Array.isArray(cfg.sites) && cfg.sites.length)
203
+ return cfg.sites;
204
+ if (cfg.path) {
205
+ return [{
206
+ name: cfg.name,
207
+ path: cfg.path,
208
+ url: cfg.url,
209
+ wpCli: cfg.wpCli,
210
+ runAs: cfg.runAs,
211
+ lxc: cfg.lxc,
212
+ lxcBin: cfg.lxcBin,
213
+ cronUser: cfg.cronUser,
214
+ muPluginsAllowlist: cfg.muPluginsAllowlist,
215
+ criticalPages: cfg.criticalPages,
216
+ servicePatterns: cfg.servicePatterns,
217
+ }];
218
+ }
219
+ return [];
220
+ };
221
+ const refreshFn = async (plugin) => {
222
+ const sites = sitesOf(plugin);
223
+ for (const site of sites) {
224
+ if (!site?.path)
225
+ continue;
226
+ try {
227
+ const data = await collectSite(site);
228
+ const uid = `wp-${slug(site.name || site.url || site.path)}`;
229
+ await plugin.send(data, uid);
230
+ }
231
+ catch (e) {
232
+ const uid = `wp-${slug(site.name || site.url || site.path)}`;
233
+ await plugin.send({ name: site.name || site.path, error: String(e?.message || e).slice(0, 160) }, uid);
234
+ }
235
+ }
236
+ };
237
+ const monitorFn = async (plugin) => {
238
+ await refreshFn(plugin);
239
+ refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 300000);
240
+ };
241
+ const teardownFn = async () => {
242
+ if (refreshTimer)
243
+ clearInterval(refreshTimer);
244
+ refreshTimer = null;
245
+ };
246
+ return createMonitoringPlugin("wordpress", "wordpress", "WordPress security-posture monitoring plugin", async () => { }, monitorFn, refreshFn, teardownFn);
247
+ }
248
+ export default createWordpressPlugin;
package/package.json CHANGED
@@ -1,54 +1,50 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
- "main": "index.ts",
6
+ "main": "dist/index.js",
7
7
  "type": "module",
8
8
  "scripts": {
9
9
  "build": "tsc",
10
- "test": "echo \"Error: no test specified\" && exit 1"
10
+ "test": "echo \"Error: no test specified\" && exit 1",
11
+ "prepublishOnly": "npm run build"
11
12
  },
12
13
  "files": [
13
14
  "dist",
14
15
  "types"
15
16
  ],
16
17
  "exports": {
17
- ".": "./index.ts",
18
- "./lib/*": "./lib/*.ts",
19
- "./plugins/*": "./plugins/*.ts"
18
+ ".": {
19
+ "types": "./types/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./lib/*": "./dist/lib/*.js",
23
+ "./plugins/*": "./dist/plugins/*.js"
20
24
  },
21
25
  "publishConfig": {
22
- "access": "public",
23
- "main": "dist/index.js",
24
- "types": "types/index.d.ts",
25
- "exports": {
26
- ".": {
27
- "types": "./types/index.d.ts",
28
- "default": "./dist/index.js"
29
- },
30
- "./lib/*": "./dist/lib/*.js",
31
- "./plugins/*": "./dist/plugins/*.js"
32
- }
26
+ "access": "public"
33
27
  },
34
28
  "author": "",
35
29
  "license": "ISC",
36
30
  "packageManager": "pnpm@8.2.0",
37
31
  "devDependencies": {
32
+ "@types/node": "^26.1.1",
38
33
  "nuxt": "^3.15.4",
34
+ "typescript": "^5.8.3",
39
35
  "vue": "^3.5.13",
40
36
  "vue-router": "^4.5.0"
41
37
  },
42
38
  "dependencies": {
39
+ "@daemonitor/common": "^1.0.0",
43
40
  "dotenv": "^16.0.3",
44
41
  "ewelink-api": "^3.1.1",
45
42
  "node-machine-id": "^1.1.12",
46
43
  "ohmyfetch": "^0.4.21",
44
+ "pm2": "^6.0.5",
47
45
  "string-to-color": "^2.2.2",
48
46
  "ts-node": "^10.9.1",
49
- "@daemonitor/common": "^1.0.0",
50
- "tsconfig-paths": "^4.2.0",
51
- "pm2": "^6.0.5"
47
+ "tsconfig-paths": "^4.2.0"
52
48
  },
53
49
  "repository": {
54
50
  "type": "git",
@@ -1 +0,0 @@
1
- export default defineNuxtConfig({});
@@ -1,84 +0,0 @@
1
- import { MonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
- /**
3
- * CloudflarePlugin - Receives push updates from Cloudflare Workers
4
- *
5
- * This plugin receives data directly from a client's Cloudflare Worker
6
- * rather than polling for it. The plugin sets up a monitoring endpoint,
7
- * and the Cloudflare Worker pushes data to this endpoint on a schedule.
8
- */
9
- export class CloudflarePlugin extends MonitoringPlugin {
10
- lastReportedData = null;
11
- lastReportTime = 0;
12
- reportingKey;
13
- maxDataAge;
14
- constructor() {
15
- super("cloudflare", "Cloudflare", "Cloudflare Deployment Monitoring Plugin (Push Mode)");
16
- this.reportingKey = this.config.reportingKey;
17
- this.maxDataAge = this.config.maxDataAge || 5 * 60 * 1000; // Default 5 minutes
18
- }
19
- async setup() {
20
- if (!this.reportingKey) {
21
- console.error("Cloudflare reporting key not provided");
22
- return Promise.reject("Reporting key not provided");
23
- }
24
- // Register this instance to receive push updates
25
- try {
26
- // Normally this would make an API call to register this plugin instance
27
- // to receive push updates from Cloudflare Workers with this reporting key
28
- console.log(`Cloudflare plugin ready to receive push updates for key: ${this.reportingKey}`);
29
- return Promise.resolve();
30
- }
31
- catch (error) {
32
- console.error("Failed to register for Cloudflare push updates:", error);
33
- return Promise.reject("Registration failed");
34
- }
35
- }
36
- /**
37
- * This method is called when data is pushed from a Cloudflare Worker
38
- * It would typically be called by a webhook or socket connection
39
- */
40
- async receiveUpdate(data, key) {
41
- // Verify the reporting key matches
42
- if (key !== this.reportingKey) {
43
- console.error("Invalid reporting key for Cloudflare update");
44
- return;
45
- }
46
- this.lastReportedData = {
47
- ...data,
48
- receivedAt: Date.now()
49
- };
50
- this.lastReportTime = Date.now();
51
- // Forward the data to the monitoring system
52
- await this.send({
53
- ...data,
54
- timestamp: Date.now(),
55
- status: "ok"
56
- });
57
- }
58
- // This method checks if data is still fresh
59
- async refresh() {
60
- const now = Date.now();
61
- const dataAge = now - this.lastReportTime;
62
- // If we have data and it's still fresh, do nothing
63
- if (this.lastReportedData && dataAge < this.maxDataAge) {
64
- return;
65
- }
66
- // If data is stale or doesn't exist, report error status
67
- await this.send({
68
- timestamp: now,
69
- error: `No data received in the last ${dataAge / 60000} minutes`,
70
- status: "error",
71
- lastReportTime: this.lastReportTime
72
- });
73
- }
74
- async monitor() {
75
- // Set up interval just to check for stale data
76
- this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 60000 // Default to 1 minute
77
- );
78
- }
79
- async teardown() {
80
- // Unregister from receiving push updates
81
- clearInterval(this.refreshTimer);
82
- return Promise.resolve();
83
- }
84
- }
@@ -1,63 +0,0 @@
1
- import ewelink from "ewelink-api";
2
- import * as dotenv from "dotenv";
3
- import { MonitoringPlugin } from "~/lib/MonitoringPlugin";
4
- dotenv.config();
5
- export class EwelinkPlugin extends MonitoringPlugin {
6
- connection;
7
- devices;
8
- constructor() {
9
- super("ewelink", "Ewelink", "Ewelink Addon");
10
- this.name = "Ewelink";
11
- this.description = "Ewelink Addon";
12
- }
13
- async setup() {
14
- if (!process.env.EWELINK_EMAIL || !process.env.EWELINK_PASSWORD) {
15
- console.error("EWELINK_EMAIL and EWELINK_PASSWORD environment variables must be set");
16
- return Promise.reject();
17
- }
18
- this.connection = new ewelink({
19
- email: process.env.EWELINK_EMAIL,
20
- password: process.env.EWELINK_PASSWORD
21
- });
22
- this.refresh().then(() => {
23
- });
24
- return Promise.resolve();
25
- }
26
- async get(key) {
27
- let [deviceName, propertiesString] = key.split(":");
28
- deviceName = deviceName.toLowerCase();
29
- const properties = propertiesString.split(",");
30
- const device = this.devices?.find((d) => d.name.toLowerCase() === deviceName);
31
- if (!device) {
32
- return Promise.reject();
33
- }
34
- const values = {};
35
- for (const property of properties) {
36
- values[property] = device?.params[property];
37
- }
38
- return Promise.resolve(values);
39
- }
40
- async refresh() {
41
- /* get all devices */
42
- return this.connection.getDevices().then((devices) => {
43
- if (Array.isArray(devices) && (Symbol.iterator in Object(devices))) {
44
- this.devices = devices;
45
- for (const device of this.devices) {
46
- this.send(device, device.deviceid);
47
- }
48
- }
49
- else {
50
- console.error("EwelinkPlugin: Invalid response from getDevices()", devices);
51
- }
52
- });
53
- }
54
- async monitor() {
55
- this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 30000);
56
- }
57
- async teardown() {
58
- clearInterval(this.refreshTimer);
59
- }
60
- async render() {
61
- return this.devices;
62
- }
63
- }
@@ -1,30 +0,0 @@
1
- import { MonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
- import { spawn } from "node:child_process";
3
- export class MongoDBPlugin extends MonitoringPlugin {
4
- childProcess;
5
- constructor() {
6
- super("mongodb", "mongodb", "MongoDB Monitoring Plugin");
7
- }
8
- async setup() {
9
- // no setup required
10
- return Promise.resolve();
11
- }
12
- async refresh() {
13
- // get mongodb db.serverStatus
14
- }
15
- async monitor() {
16
- // this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 5000)
17
- this.childProcess = spawn("mongostat", ["--json", "mongodb://localhost:27017", "10"]);
18
- this.childProcess.stdout.on("data", (input) => {
19
- const jsonObject = JSON.parse(input.toString());
20
- if (!jsonObject["localhost:27017"]) {
21
- return;
22
- }
23
- const data = jsonObject["localhost:27017"];
24
- this.send(data, "mongodb-localhost:27017");
25
- });
26
- }
27
- async teardown() {
28
- this.childProcess.kill();
29
- }
30
- }
package/index.ts DELETED
@@ -1,11 +0,0 @@
1
- // Export core plugin utilities
2
- export { PluginConfigProvider } from "./lib/PluginConfigProvider"
3
- export { PluginManager } from "./lib/PluginManager"
4
- export { PluginLoader } from "./lib/PluginLoader"
5
-
6
- // Only export creation functions, not types
7
- export { createBasePlugin } from "./lib/BasePlugin"
8
- export { createMonitoringPlugin } from "./lib/MonitoringPlugin"
9
-
10
- // Export the PM2 plugin factory function
11
- export { createPM2Plugin } from "./plugins/monitoring/PM2Plugin"