@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
- return { host, ip, method: rq[1], path: rq[2], status: Number(rq[3]), ua: "" };
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.36",
3
+ "version": "1.1.37",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",