@daemonitor/plugins 1.1.36 → 1.1.37
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.
|
@@ -16,7 +16,7 @@ const MAX_READ = 5 * 1024 * 1024;
|
|
|
16
16
|
// $http_cf_connecting_ip is present it is logged AFTER $remote_addr (the CF
|
|
17
17
|
// edge), so "last IP" is the visitor, not the edge; when it's absent there
|
|
18
18
|
// is a single IP ($remote_addr) and "last" == "only".
|
|
19
|
-
const REQ = /"([A-Z]+)\s+([^ "]+)[^"]*"\s+"?(\d{3})
|
|
19
|
+
const REQ = /"([A-Z]+)\s+([^ "]+)[^"]*"\s+"?(\d{3})(?:"?\s+(\d+|-)\s+"([^"]*)")?/;
|
|
20
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
21
|
function parseLine(line) {
|
|
22
22
|
const rq = REQ.exec(line);
|
|
@@ -34,7 +34,8 @@ function parseLine(line) {
|
|
|
34
34
|
// origin address — there is no site name to report and "" is correct.
|
|
35
35
|
const first = tokens[0] || "";
|
|
36
36
|
const host = first !== "-" && !IP_TOKEN.test(first) ? first : "";
|
|
37
|
-
|
|
37
|
+
const bytes = rq[4] && rq[4] !== "-" ? Number(rq[4]) : -1;
|
|
38
|
+
return { host, ip, method: rq[1], path: rq[2], status: Number(rq[3]), bytes, referer: rq[5] || "", ua: "" };
|
|
38
39
|
}
|
|
39
40
|
const SUSPICIOUS_QUERY = /(?:^|[?&])(c|k|cmd|exec|e|q|f|file|dir|download|0|1|a)=/i;
|
|
40
41
|
const BASE64_BLOB = /[A-Za-z0-9+/]{40,}={0,2}/;
|
|
@@ -44,6 +45,12 @@ const BATCH_PATH = /^\/wp-json\/batch\/v1|rest_route=\/?batch\/v1/i;
|
|
|
44
45
|
// wp-admin paths that answer 2xx to anonymous callers, so a 2xx on them proves
|
|
45
46
|
// nothing about being logged in. Everything else under /wp-admin/ redirects a
|
|
46
47
|
// logged-out visitor to the login form.
|
|
48
|
+
// The omnisend-integration mu-plugin's wp_send_json_error "Security check
|
|
49
|
+
// failed" gzips to exactly 79 bytes; success gzips to 77. Matching on size is
|
|
50
|
+
// the only signal the access log carries, so a change to that message or to
|
|
51
|
+
// nginx's gzip settings silences this rule.
|
|
52
|
+
const SUBSCRIBE_FAIL_BYTES = 79;
|
|
53
|
+
const SUBSCRIBE_REFERER = /\/subscribe\/?(?:[?#]|$)/;
|
|
47
54
|
const ANON_ADMIN = /^\/wp-admin\/(?:admin-ajax|admin-post|load-styles|load-scripts|install|setup-config)\.php$|^\/wp-admin\/(?:css|js|images|fonts)\//i;
|
|
48
55
|
// Read new bytes since last offset, advancing state. Handles rotation (file
|
|
49
56
|
// shrank → restart at 0) and partial trailing lines (buffered in remainder).
|
|
@@ -115,6 +122,7 @@ export function analyze(lines, cfg, windowSec, st) {
|
|
|
115
122
|
const adminOkIps = new Set(); // ip that then loaded an authed wp-admin page (2xx)
|
|
116
123
|
const xmlrpcByIp = new Map(); // ip → POST xmlrpc count
|
|
117
124
|
const webshell = new Map(); // pathNoQuery → sample
|
|
125
|
+
const subscribeFail = new Map(); // ip → "Security check failed" replies
|
|
118
126
|
let total = 0;
|
|
119
127
|
for (const line of lines) {
|
|
120
128
|
const e = parseLine(line);
|
|
@@ -165,6 +173,10 @@ export function analyze(lines, cfg, windowSec, st) {
|
|
|
165
173
|
// every WP site is probed constantly for my.php / academy.php / etc., and
|
|
166
174
|
// those hits 404 (not there) or 403 (WAF-blocked). Alerting on the probes
|
|
167
175
|
// is pure noise; alert only when the shell actually RAN.
|
|
176
|
+
if (e.method === "POST" && pathNoQuery === "/wp-admin/admin-ajax.php" && e.bytes === SUBSCRIBE_FAIL_BYTES && SUBSCRIBE_REFERER.test(e.referer)) {
|
|
177
|
+
subscribeFail.set(e.ip, (subscribeFail.get(e.ip) || 0) + 1);
|
|
178
|
+
noteHost(e.ip, e.host);
|
|
179
|
+
}
|
|
168
180
|
const executed = e.status >= 200 && e.status < 300;
|
|
169
181
|
const isUploadsPhp = UPLOADS_PHP.test(pathNoQuery);
|
|
170
182
|
const isPluginShell = PLUGIN_PHP.test(pathNoQuery) && (SUSPICIOUS_QUERY.test(query) || BASE64_BLOB.test(query));
|
|
@@ -217,6 +229,14 @@ export function analyze(lines, cfg, windowSec, st) {
|
|
|
217
229
|
});
|
|
218
230
|
}
|
|
219
231
|
}
|
|
232
|
+
for (const [ip, count] of subscribeFail) {
|
|
233
|
+
findings.push({
|
|
234
|
+
rule: "subscribe-nonce-fail", key: `subscribe-nonce:${ip}`, severity: "warning", ip,
|
|
235
|
+
path: "/wp-admin/admin-ajax.php",
|
|
236
|
+
title: "Subscribe form rejected a visitor",
|
|
237
|
+
detail: `${ip}: "Security check failed" on /subscribe/ (${count}×)${onSites(ip)}, a real signup was turned away`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
220
240
|
const counts = {
|
|
221
241
|
total,
|
|
222
242
|
batchV1: [...batchIps.values()].reduce((a, b) => a + b, 0),
|
|
@@ -224,6 +244,7 @@ export function analyze(lines, cfg, windowSec, st) {
|
|
|
224
244
|
batchBlocked,
|
|
225
245
|
xmlrpcPosts: [...xmlrpcByIp.values()].reduce((a, b) => a + b, 0),
|
|
226
246
|
webshellHits: [...webshell.values()].reduce((a, b) => a + b.count, 0),
|
|
247
|
+
subscribeFail: [...subscribeFail.values()].reduce((a, b) => a + b, 0),
|
|
227
248
|
loginSuccess: st?.hideLogin ? 0 : [...loginOkIps].filter((ip) => adminOkIps.has(ip)).length,
|
|
228
249
|
loginRedirectsAlways: !!st?.hideLogin,
|
|
229
250
|
};
|
|
@@ -276,6 +297,6 @@ export function createLogWatchPlugin() {
|
|
|
276
297
|
refreshTimer = null;
|
|
277
298
|
states.clear();
|
|
278
299
|
};
|
|
279
|
-
return createMonitoringPlugin("logwatch", "logwatch", "Access-log security watcher (WP2Shell / webshell / xmlrpc)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
300
|
+
return createMonitoringPlugin("logwatch", "logwatch", "Access-log security watcher (WP2Shell / webshell / xmlrpc / subscribe form)", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
280
301
|
}
|
|
281
302
|
export default createLogWatchPlugin;
|