@daemonitor/plugins 1.1.41 → 1.1.42

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.
@@ -42,13 +42,6 @@ const BASE64_BLOB = /[A-Za-z0-9+/]{40,}={0,2}/;
42
42
  const UPLOADS_PHP = /\/wp-content\/uploads\/.*\.php$/i;
43
43
  const PLUGIN_PHP = /\/wp-content\/(?:plugins|mu-plugins)\/.*\.php$/i;
44
44
  const BATCH_PATH = /^\/wp-json\/batch\/v1|rest_route=\/?batch\/v1/i;
45
- // The omnisend-integration mu-plugin's wp_send_json_error "Security check
46
- // failed" logs as 79 bytes direct from Cloudflare and 90 through cloudflared
47
- // (since 2026-10-28); success logs as 77 and 88. Matching on size is the only
48
- // signal the access log carries, so a change to that message, to nginx's gzip
49
- // settings or to the proxy in front silences this rule.
50
- const SUBSCRIBE_FAIL_BYTES = new Set([79, 90]);
51
- const SUBSCRIBE_REFERER = /\/subscribe\/?(?:[?#]|$)/;
52
45
  // wp-admin paths that answer 2xx to anonymous callers, so a 2xx on them proves
53
46
  // nothing about being logged in. Everything else under /wp-admin/ redirects a
54
47
  // logged-out visitor to the login form.
@@ -93,7 +86,7 @@ async function readNew(source, st) {
93
86
  st.remainder = lines.pop() ?? ""; // last element is a partial line (no newline)
94
87
  return lines;
95
88
  }
96
- export function analyze(lines, cfg, windowSec, st) {
89
+ export function analyze(lines, cfg, windowSec, st, rules = []) {
97
90
  const burstThreshold = cfg.xmlrpcBurstThreshold ?? 20;
98
91
  const batchIps = new Map(); // ip → count of batch/v1 → 207
99
92
  // key (ip, or path for webshells) → the vhosts it was seen against. Only
@@ -123,7 +116,7 @@ export function analyze(lines, cfg, windowSec, st) {
123
116
  const adminOkIps = new Set(); // ip that then loaded an authed wp-admin page (2xx)
124
117
  const xmlrpcByIp = new Map(); // ip → POST xmlrpc count
125
118
  const webshell = new Map(); // pathNoQuery → sample
126
- const subscribeFail = new Map(); // ip → "Security check failed" replies
119
+ const compiled = rules.map((rule) => ({ rule, referer: rule.referer ? new RegExp(rule.referer) : null, hits: new Map() }));
127
120
  let total = 0;
128
121
  for (const line of lines) {
129
122
  const e = parseLine(line);
@@ -174,8 +167,18 @@ export function analyze(lines, cfg, windowSec, st) {
174
167
  // every WP site is probed constantly for my.php / academy.php / etc., and
175
168
  // those hits 404 (not there) or 403 (WAF-blocked). Alerting on the probes
176
169
  // is pure noise; alert only when the shell actually RAN.
177
- if (e.method === "POST" && pathNoQuery === "/wp-admin/admin-ajax.php" && SUBSCRIBE_FAIL_BYTES.has(e.bytes) && SUBSCRIBE_REFERER.test(e.referer)) {
178
- subscribeFail.set(e.ip, (subscribeFail.get(e.ip) || 0) + 1);
170
+ for (const { rule, referer, hits } of compiled) {
171
+ if (rule.method && rule.method !== e.method)
172
+ continue;
173
+ if (rule.path && rule.path !== pathNoQuery)
174
+ continue;
175
+ if (rule.status && !rule.status.includes(e.status))
176
+ continue;
177
+ if (rule.bytes && !rule.bytes.includes(e.bytes))
178
+ continue;
179
+ if (referer && !referer.test(e.referer))
180
+ continue;
181
+ hits.set(e.ip, (hits.get(e.ip) || 0) + 1);
179
182
  noteHost(e.ip, e.host);
180
183
  }
181
184
  const executed = e.status >= 200 && e.status < 300;
@@ -230,13 +233,16 @@ export function analyze(lines, cfg, windowSec, st) {
230
233
  });
231
234
  }
232
235
  }
233
- for (const [ip, count] of subscribeFail) {
234
- findings.push({
235
- rule: "subscribe-nonce-fail", key: `subscribe-nonce:${ip}`, severity: "warning", ip,
236
- path: "/wp-admin/admin-ajax.php",
237
- title: "Subscribe form rejected a visitor",
238
- detail: `${ip}: "Security check failed" on /subscribe/ (${count}×)${onSites(ip)}, a real signup was turned away`,
239
- });
236
+ for (const { rule, hits } of compiled) {
237
+ for (const [ip, count] of hits) {
238
+ const title = rule.title || rule.name;
239
+ findings.push({
240
+ rule: rule.name, key: `rule:${rule.name}:${ip}`, severity: rule.severity || "warning", ip,
241
+ path: rule.path,
242
+ title,
243
+ detail: `${ip}: ${title} (${count}×)${onSites(ip)}`,
244
+ });
245
+ }
240
246
  }
241
247
  const counts = {
242
248
  total,
@@ -245,7 +251,7 @@ export function analyze(lines, cfg, windowSec, st) {
245
251
  batchBlocked,
246
252
  xmlrpcPosts: [...xmlrpcByIp.values()].reduce((a, b) => a + b, 0),
247
253
  webshellHits: [...webshell.values()].reduce((a, b) => a + b.count, 0),
248
- subscribeFail: [...subscribeFail.values()].reduce((a, b) => a + b, 0),
254
+ ruleHits: compiled.reduce((n, { hits }) => n + [...hits.values()].reduce((a, b) => a + b, 0), 0),
249
255
  loginSuccess: st?.hideLogin ? 0 : [...loginOkIps].filter((ip) => adminOkIps.has(ip)).length,
250
256
  loginRedirectsAlways: !!st?.hideLogin,
251
257
  };
@@ -262,7 +268,7 @@ export function createLogWatchPlugin() {
262
268
  if (Array.isArray(cfg.sources) && cfg.sources.length)
263
269
  return cfg.sources;
264
270
  if (cfg.path)
265
- return [{ name: cfg.name, path: cfg.path, fromStart: cfg.fromStart }];
271
+ return [{ name: cfg.name, path: cfg.path, fromStart: cfg.fromStart, rules: cfg.rules }];
266
272
  return [];
267
273
  };
268
274
  const refreshFn = async (plugin) => {
@@ -280,7 +286,7 @@ export function createLogWatchPlugin() {
280
286
  }
281
287
  try {
282
288
  const lines = await readNew(source, st);
283
- const { findings, counts } = analyze(lines, cfg, windowSec, st);
289
+ const { findings, counts } = analyze(lines, cfg, windowSec, st, source.rules);
284
290
  await plugin.send({ name: label, source: label, path: source.path, window: windowSec, counts, findings }, uid);
285
291
  }
286
292
  catch (e) {
@@ -298,6 +304,6 @@ export function createLogWatchPlugin() {
298
304
  refreshTimer = null;
299
305
  states.clear();
300
306
  };
301
- return createMonitoringPlugin("logwatch", "logwatch", "Access-log security watcher (WP2Shell / webshell / xmlrpc / subscribe form)", async () => { }, monitorFn, refreshFn, teardownFn);
307
+ return createMonitoringPlugin("logwatch", "logwatch", "Access-log security watcher (WP2Shell / webshell / xmlrpc / custom rules)", async () => { }, monitorFn, refreshFn, teardownFn);
302
308
  }
303
309
  export default createLogWatchPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.41",
3
+ "version": "1.1.42",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",