@daemonitor/plugins 1.1.23 → 1.1.26

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.
@@ -4,6 +4,39 @@ import { promisify } from "util";
4
4
  import { hostname } from "os";
5
5
  const execAsync = promisify(exec);
6
6
  const EXEC_OPTS = { maxBuffer: 32 * 1024 * 1024 }; // containers.length can be large
7
+ /**
8
+ * Reduce a healthcheck's captured output to the part that says what went wrong.
9
+ *
10
+ * Healthchecks are overwhelmingly `curl`, and curl writes its transfer meter to
11
+ * the same buffer as the response — so the raw text is mostly columns of rates
12
+ * and `--:--:--` padding, with the answer somewhere inside. Taking the first 300
13
+ * characters, as this used to, returns the meter header every single time.
14
+ *
15
+ * curl also rewrites the meter line in place with \r while the body streams in,
16
+ * so the response can land INSIDE a rate row rather than on its own line. That
17
+ * rules out dropping noisy lines wholesale; the meter tokens have to come out
18
+ * and whatever is left is the message.
19
+ */
20
+ export function cleanHealthOutput(raw) {
21
+ const s = String(raw || "");
22
+ // When curl itself failed it says so in one line, and that line is the entire
23
+ // answer: exit 7 is connection refused, 28 is timeout, 22 is an HTTP error.
24
+ const curlErr = s.match(/curl:\s*\(\d+\)[^\r\n]*/);
25
+ if (curlErr)
26
+ return curlErr[0].trim().slice(0, 300);
27
+ const stripped = s
28
+ .replace(/%\s*Total[\s\S]*?Dload\s+Upload\s+Total\s+Spent\s+Left\s+Speed/g, " ")
29
+ .replace(/%\s*Total[^\r\n]*/g, " ")
30
+ .replace(/Dload[^\r\n]*Speed/g, " ")
31
+ .replace(/(?:--:--:--|\d+:\d{2}:\d{2})/g, " ")
32
+ .replace(/\s+/g, " ")
33
+ // what remains of the meter is runs of bare counters
34
+ .replace(/(?:\s\d+(?:\.\d+)?[kKMG]?){3,}(?=\s|$)/g, " ")
35
+ .replace(/\s+/g, " ")
36
+ .trim();
37
+ // A failing command puts its error last, after whatever it managed to print.
38
+ return stripped.length > 300 ? "…" + stripped.slice(-300) : stripped;
39
+ }
7
40
  const HEALTH_RE = /\((healthy|unhealthy|health: starting|starting)\)/i;
8
41
  // Previous cumulative NetIO/BlockIO totals per container id, to derive a
9
42
  // per-second rate across polls. Module-level (one collect per host per cycle).
@@ -148,11 +181,19 @@ async function collectContainers(bin) {
148
181
  if (!h)
149
182
  continue;
150
183
  c.healthFailingStreak = Number(h.FailingStreak) || 0;
151
- const last = Array.isArray(h.Log) && h.Log.length ? h.Log[h.Log.length - 1] : null;
152
- if (last?.Output) {
153
- // Healthcheck output is often multi-line and ends in a newline; collapse
154
- // it so it fits an alert line, and keep the head where the error is.
155
- c.healthOutput = String(last.Output).replace(/\s+/g, " ").trim().slice(0, 300);
184
+ const log = Array.isArray(h.Log) ? h.Log : [];
185
+ // Docker keeps the last five probes, and the newest one can be a PASS while
186
+ // the container is still marked unhealthy — the status lags a recovery, and
187
+ // a flapping check alternates. Quoting that pass is how an alert ends up
188
+ // giving {"status":"ok"} as the reason something is broken. Report the most
189
+ // recent probe that actually failed.
190
+ const entry = [...log].reverse().find((e) => e && e.ExitCode !== 0) || log[log.length - 1] || null;
191
+ if (entry && typeof entry.ExitCode === "number")
192
+ c.healthExitCode = entry.ExitCode;
193
+ if (entry?.Output) {
194
+ const cleaned = cleanHealthOutput(entry.Output);
195
+ if (cleaned)
196
+ c.healthOutput = cleaned;
156
197
  }
157
198
  }
158
199
  catch (err) {
@@ -24,16 +24,27 @@ function parseLine(line) {
24
24
  return null;
25
25
  const br = line.indexOf("[");
26
26
  const pre = br > 0 ? line.slice(0, br) : line;
27
- const tokens = pre.split(/\s+/);
27
+ const tokens = pre.split(/\s+/).filter(Boolean);
28
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: "" };
29
+ const ip = ips.length ? ips[ips.length - 1] : (tokens[0] || "");
30
+ // A host in the leading field is what lets a SHARED access log (one file for
31
+ // every vhost, as on the misc box) still say WHICH site was hit. A plain
32
+ // combined line leads with the client IP instead, so only a non-IP first
33
+ // token counts. When $host is itself an IP — a request made straight to the
34
+ // origin address — there is no site name to report and "" is correct.
35
+ const first = tokens[0] || "";
36
+ const host = first !== "-" && !IP_TOKEN.test(first) ? first : "";
37
+ return { host, ip, method: rq[1], path: rq[2], status: Number(rq[3]), ua: "" };
31
38
  }
32
39
  const SUSPICIOUS_QUERY = /(?:^|[?&])(c|k|cmd|exec|e|q|f|file|dir|download|0|1|a)=/i;
33
40
  const BASE64_BLOB = /[A-Za-z0-9+/]{40,}={0,2}/;
34
41
  const UPLOADS_PHP = /\/wp-content\/uploads\/.*\.php$/i;
35
42
  const PLUGIN_PHP = /\/wp-content\/(?:plugins|mu-plugins)\/.*\.php$/i;
36
43
  const BATCH_PATH = /^\/wp-json\/batch\/v1|rest_route=\/?batch\/v1/i;
44
+ // wp-admin paths that answer 2xx to anonymous callers, so a 2xx on them proves
45
+ // nothing about being logged in. Everything else under /wp-admin/ redirects a
46
+ // logged-out visitor to the login form.
47
+ const ANON_ADMIN = /^\/wp-admin\/(?:admin-ajax|admin-post|load-styles|load-scripts|install|setup-config)\.php$|^\/wp-admin\/(?:css|js|images|fonts)\//i;
37
48
  // Read new bytes since last offset, advancing state. Handles rotation (file
38
49
  // shrank → restart at 0) and partial trailing lines (buffered in remainder).
39
50
  async function readNew(source, st) {
@@ -74,10 +85,34 @@ async function readNew(source, st) {
74
85
  st.remainder = lines.pop() ?? ""; // last element is a partial line (no newline)
75
86
  return lines;
76
87
  }
77
- function analyze(lines, cfg, windowSec) {
88
+ export function analyze(lines, cfg, windowSec, st) {
78
89
  const burstThreshold = cfg.xmlrpcBurstThreshold ?? 20;
79
90
  const batchIps = new Map(); // ip → count of batch/v1 → 207
80
- const loginOkIps = new Set(); // ip with POST wp-login → 302
91
+ // key (ip, or path for webshells) → the vhosts it was seen against. Only
92
+ // populated when the log format carries $host; findings degrade to the old
93
+ // wording when it doesn't.
94
+ const hostsByKey = new Map();
95
+ const noteHost = (key, host) => {
96
+ if (!host)
97
+ return;
98
+ const set = hostsByKey.get(key) || new Set();
99
+ set.add(host);
100
+ hostsByKey.set(key, set);
101
+ };
102
+ // " on a.com, b.com" — capped so a scanner sweeping 30 vhosts can't blow past
103
+ // the 200-char detail limit the ingest applies.
104
+ const onSites = (key) => {
105
+ const set = hostsByKey.get(key);
106
+ if (!set?.size)
107
+ return "";
108
+ const list = [...set];
109
+ const shown = list.slice(0, 3).join(", ");
110
+ return ` on ${shown}${list.length > 3 ? ` +${list.length - 3} more` : ""}`;
111
+ };
112
+ let batchAttempts = 0; // every batch/v1 request, whatever the status
113
+ let batchBlocked = 0; // ...of which nginx/WAF rejected outright
114
+ const loginOkIps = new Set(); // ip with POST wp-login → 302 (WEAK on its own)
115
+ const adminOkIps = new Set(); // ip that then loaded an authed wp-admin page (2xx)
81
116
  const xmlrpcByIp = new Map(); // ip → POST xmlrpc count
82
117
  const webshell = new Map(); // pathNoQuery → sample
83
118
  let total = 0;
@@ -89,14 +124,40 @@ function analyze(lines, cfg, windowSec) {
89
124
  const qIdx = e.path.indexOf("?");
90
125
  const pathNoQuery = qIdx === -1 ? e.path : e.path.slice(0, qIdx);
91
126
  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);
127
+ // Counting every batch/v1 attempt (not just the 207s) keeps attack pressure
128
+ // visible on the dashboard once an origin rule starts answering 403 — the
129
+ // ALERT still fires only on 207, which is the status that means WordPress
130
+ // actually executed the batch.
131
+ if (BATCH_PATH.test(e.path) || pathNoQuery === "/wp-json/batch/v1") {
132
+ batchAttempts++;
133
+ if (e.status === 207) {
134
+ batchIps.set(e.ip, (batchIps.get(e.ip) || 0) + 1);
135
+ noteHost(e.ip, e.host);
136
+ }
137
+ else if (e.status === 403 || e.status === 406 || e.status === 429) {
138
+ batchBlocked++;
139
+ }
140
+ }
141
+ // A 302 on POST /wp-login.php is the classic "login succeeded" tell, but it
142
+ // is only meaningful where a FAILED login looks different. On a site that
143
+ // hides or blocks wp-login.php every response is a 302 — right password,
144
+ // wrong password, no password, plain GET. Calibrate against that first.
145
+ if (pathNoQuery === "/wp-login.php" && e.method === "GET" && e.status >= 300 && e.status < 400) {
146
+ if (st)
147
+ st.hideLogin = true;
94
148
  }
95
149
  if (pathNoQuery === "/wp-login.php" && e.method === "POST" && e.status === 302) {
96
150
  loginOkIps.add(e.ip);
97
151
  }
152
+ // Corroboration: a session that actually authenticated goes on to load a
153
+ // wp-admin screen and gets a 2xx. An unauthenticated caller is bounced with
154
+ // a 3xx. Skip the wp-admin paths that answer 2xx to anonymous callers.
155
+ if (pathNoQuery.startsWith("/wp-admin/") && !ANON_ADMIN.test(pathNoQuery) && e.status >= 200 && e.status < 300) {
156
+ adminOkIps.add(e.ip);
157
+ }
98
158
  if (pathNoQuery === "/xmlrpc.php" && e.method === "POST") {
99
159
  xmlrpcByIp.set(e.ip, (xmlrpcByIp.get(e.ip) || 0) + 1);
160
+ noteHost(e.ip, e.host);
100
161
  }
101
162
  // A .php under uploads is never legitimate; under a plugin dir it's only
102
163
  // suspicious with a command-style or base64 query (webshell invocation).
@@ -111,18 +172,23 @@ function analyze(lines, cfg, windowSec) {
111
172
  const cur = webshell.get(pathNoQuery) || { ip: e.ip, count: 0 };
112
173
  cur.count++;
113
174
  webshell.set(pathNoQuery, cur);
175
+ noteHost(pathNoQuery, e.host);
114
176
  }
115
177
  }
116
178
  const findings = [];
117
179
  // Correlation is the highest-signal event — flag it before the standalone
118
180
  // batch/v1 finding so it leads.
181
+ // Only call it a takeover when the login status is trustworthy for this site
182
+ // AND the IP went on to load an admin screen successfully. Either half alone
183
+ // produced a false "account takeover" page on archpaper.com, 2026-08-23.
184
+ const authed = (ip) => !st?.hideLogin && loginOkIps.has(ip) && adminOkIps.has(ip);
119
185
  for (const [ip, count] of batchIps) {
120
- if (loginOkIps.has(ip)) {
186
+ if (authed(ip)) {
121
187
  findings.push({
122
188
  rule: "batch-then-login", key: `batch-login:${ip}`, severity: "error", ip,
123
189
  path: "/wp-json/batch/v1",
124
190
  title: "WP2Shell: exploit + admin login",
125
- detail: `${ip}: batch/v1 exploit (${count}×) followed by a successful wp-login (HTTP 302) — likely account takeover`,
191
+ detail: `${ip}: batch/v1 exploit (${count}×)${onSites(ip)}, then a wp-login 302 AND an authenticated wp-admin page (2xx) — likely account takeover`,
126
192
  });
127
193
  }
128
194
  else {
@@ -130,7 +196,7 @@ function analyze(lines, cfg, windowSec) {
130
196
  rule: "wp-batch-207", key: `batchv1:${ip}`, severity: "error", ip,
131
197
  path: "/wp-json/batch/v1",
132
198
  title: "WP2Shell batch/v1 exploit",
133
- detail: `${ip}: POST /wp-json/batch/v1 → 207 (${count}×) — near-zero false positives`,
199
+ detail: `${ip}: POST /wp-json/batch/v1 → 207 (${count}×)${onSites(ip)} — near-zero false positives`,
134
200
  });
135
201
  }
136
202
  }
@@ -138,7 +204,7 @@ function analyze(lines, cfg, windowSec) {
138
204
  findings.push({
139
205
  rule: "webshell-exec", key: `webshell:${p}`, severity: "error", ip: v.ip, path: p,
140
206
  title: "Webshell execution",
141
- detail: `${v.ip} invoked ${p} (${v.count}×) — executable PHP in an upload/plugin path`,
207
+ detail: `${v.ip} invoked ${p} (${v.count}×)${onSites(p)} — executable PHP in an upload/plugin path`,
142
208
  });
143
209
  }
144
210
  for (const [ip, count] of xmlrpcByIp) {
@@ -147,16 +213,19 @@ function analyze(lines, cfg, windowSec) {
147
213
  rule: "xmlrpc-burst", key: `xmlrpc:${ip}`, severity: count >= burstThreshold * 5 ? "error" : "warning", ip,
148
214
  path: "/xmlrpc.php",
149
215
  title: "xmlrpc.php brute-force / amplification",
150
- detail: `${ip}: ${count} POST /xmlrpc.php in ~${windowSec}s (threshold ${burstThreshold})`,
216
+ detail: `${ip}: ${count} POST /xmlrpc.php in ~${windowSec}s${onSites(ip)} (threshold ${burstThreshold})`,
151
217
  });
152
218
  }
153
219
  }
154
220
  const counts = {
155
221
  total,
156
222
  batchV1: [...batchIps.values()].reduce((a, b) => a + b, 0),
223
+ batchAttempts,
224
+ batchBlocked,
157
225
  xmlrpcPosts: [...xmlrpcByIp.values()].reduce((a, b) => a + b, 0),
158
226
  webshellHits: [...webshell.values()].reduce((a, b) => a + b.count, 0),
159
- loginSuccess: loginOkIps.size,
227
+ loginSuccess: st?.hideLogin ? 0 : [...loginOkIps].filter((ip) => adminOkIps.has(ip)).length,
228
+ loginRedirectsAlways: !!st?.hideLogin,
160
229
  };
161
230
  return { findings, counts };
162
231
  }
@@ -189,7 +258,7 @@ export function createLogWatchPlugin() {
189
258
  }
190
259
  try {
191
260
  const lines = await readNew(source, st);
192
- const { findings, counts } = analyze(lines, cfg, windowSec);
261
+ const { findings, counts } = analyze(lines, cfg, windowSec, st);
193
262
  await plugin.send({ name: label, source: label, path: source.path, window: windowSec, counts, findings }, uid);
194
263
  }
195
264
  catch (e) {
@@ -1,7 +1,12 @@
1
1
  import * as http from "node:http";
2
2
  import * as https from "node:https";
3
+ import * as net from "node:net";
3
4
  import { URL } from "node:url";
4
5
  import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
6
+ // URL keeps IPv6 hosts bracketed ("[::1]"); net.isIP wants them bare.
7
+ function isIpLiteral(hostname) {
8
+ return net.isIP(hostname.replace(/^\[|\]$/g, "")) !== 0;
9
+ }
5
10
  // Low-level GET that can hit one host (`target`) while presenting a different
6
11
  // Host header. fetch() can't do this — Host is a forbidden header per the
7
12
  // Fetch spec and is silently dropped — so this uses Node's http/https
@@ -33,7 +38,20 @@ function rawGet(target, opts) {
33
38
  // target. Without it, Node derives SNI from headers.Host,
34
39
  // which would validate the cert against the WRONG hostname
35
40
  // (the public one, not the origin actually being dialed).
36
- ...(u.protocol === "https:" ? { servername: u.hostname, rejectUnauthorized: !opts.insecure } : {}),
41
+ //
42
+ // SNI must be a DNS name: RFC 6066 forbids IP literals, and
43
+ // Node/bun throw ERR_INVALID_ARG_VALUE rather than ignoring
44
+ // one, which kills the whole agent at startup. When the origin
45
+ // is dialed by IP there is no name to pin, so omit servername
46
+ // and let validation match the IP against the cert's IP SANs —
47
+ // which is the right check for an IP target, and is what the
48
+ // Host header override is deliberately kept out of.
49
+ ...(u.protocol === "https:"
50
+ ? {
51
+ ...(isIpLiteral(u.hostname) ? {} : { servername: u.hostname }),
52
+ rejectUnauthorized: !opts.insecure,
53
+ }
54
+ : {}),
37
55
  }, (res) => {
38
56
  let body = "";
39
57
  res.on("data", (c) => { body += c; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.23",
3
+ "version": "1.1.26",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",
@@ -49,6 +49,9 @@
49
49
  "url": "https://github.com/daemonitor/plugins/issues"
50
50
  },
51
51
  "homepage": "https://github.com/daemonitor/plugins#readme",
52
+ "overrides": {
53
+ "js-yaml": "^4.3.1"
54
+ },
52
55
  "scripts": {
53
56
  "build": "tsc",
54
57
  "test": "echo \"Error: no test specified\" && exit 1"