@daemonitor/plugins 1.1.40 → 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.
@@ -45,12 +45,6 @@ const BATCH_PATH = /^\/wp-json\/batch\/v1|rest_route=\/?batch\/v1/i;
45
45
  // wp-admin paths that answer 2xx to anonymous callers, so a 2xx on them proves
46
46
  // nothing about being logged in. Everything else under /wp-admin/ redirects a
47
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\/?(?:[?#]|$)/;
54
48
  const ANON_ADMIN = /^\/wp-admin\/(?:admin-ajax|admin-post|load-styles|load-scripts|install|setup-config)\.php$|^\/wp-admin\/(?:css|js|images|fonts)\//i;
55
49
  // Read new bytes since last offset, advancing state. Handles rotation (file
56
50
  // shrank → restart at 0) and partial trailing lines (buffered in remainder).
@@ -92,7 +86,7 @@ async function readNew(source, st) {
92
86
  st.remainder = lines.pop() ?? ""; // last element is a partial line (no newline)
93
87
  return lines;
94
88
  }
95
- export function analyze(lines, cfg, windowSec, st) {
89
+ export function analyze(lines, cfg, windowSec, st, rules = []) {
96
90
  const burstThreshold = cfg.xmlrpcBurstThreshold ?? 20;
97
91
  const batchIps = new Map(); // ip → count of batch/v1 → 207
98
92
  // key (ip, or path for webshells) → the vhosts it was seen against. Only
@@ -122,7 +116,7 @@ export function analyze(lines, cfg, windowSec, st) {
122
116
  const adminOkIps = new Set(); // ip that then loaded an authed wp-admin page (2xx)
123
117
  const xmlrpcByIp = new Map(); // ip → POST xmlrpc count
124
118
  const webshell = new Map(); // pathNoQuery → sample
125
- 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() }));
126
120
  let total = 0;
127
121
  for (const line of lines) {
128
122
  const e = parseLine(line);
@@ -173,8 +167,18 @@ export function analyze(lines, cfg, windowSec, st) {
173
167
  // every WP site is probed constantly for my.php / academy.php / etc., and
174
168
  // those hits 404 (not there) or 403 (WAF-blocked). Alerting on the probes
175
169
  // 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);
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);
178
182
  noteHost(e.ip, e.host);
179
183
  }
180
184
  const executed = e.status >= 200 && e.status < 300;
@@ -229,13 +233,16 @@ export function analyze(lines, cfg, windowSec, st) {
229
233
  });
230
234
  }
231
235
  }
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
- });
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
+ }
239
246
  }
240
247
  const counts = {
241
248
  total,
@@ -244,7 +251,7 @@ export function analyze(lines, cfg, windowSec, st) {
244
251
  batchBlocked,
245
252
  xmlrpcPosts: [...xmlrpcByIp.values()].reduce((a, b) => a + b, 0),
246
253
  webshellHits: [...webshell.values()].reduce((a, b) => a + b.count, 0),
247
- 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),
248
255
  loginSuccess: st?.hideLogin ? 0 : [...loginOkIps].filter((ip) => adminOkIps.has(ip)).length,
249
256
  loginRedirectsAlways: !!st?.hideLogin,
250
257
  };
@@ -261,7 +268,7 @@ export function createLogWatchPlugin() {
261
268
  if (Array.isArray(cfg.sources) && cfg.sources.length)
262
269
  return cfg.sources;
263
270
  if (cfg.path)
264
- return [{ name: cfg.name, path: cfg.path, fromStart: cfg.fromStart }];
271
+ return [{ name: cfg.name, path: cfg.path, fromStart: cfg.fromStart, rules: cfg.rules }];
265
272
  return [];
266
273
  };
267
274
  const refreshFn = async (plugin) => {
@@ -279,7 +286,7 @@ export function createLogWatchPlugin() {
279
286
  }
280
287
  try {
281
288
  const lines = await readNew(source, st);
282
- const { findings, counts } = analyze(lines, cfg, windowSec, st);
289
+ const { findings, counts } = analyze(lines, cfg, windowSec, st, source.rules);
283
290
  await plugin.send({ name: label, source: label, path: source.path, window: windowSec, counts, findings }, uid);
284
291
  }
285
292
  catch (e) {
@@ -297,6 +304,6 @@ export function createLogWatchPlugin() {
297
304
  refreshTimer = null;
298
305
  states.clear();
299
306
  };
300
- 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);
301
308
  }
302
309
  export default createLogWatchPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daemonitor/plugins",
3
- "version": "1.1.40",
3
+ "version": "1.1.42",
4
4
  "description": "daemonitor plugins",
5
5
  "types": "types/index.d.ts",
6
6
  "main": "dist/index.js",