@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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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 (
|
|
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}×)
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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"
|