@daemonitor/plugins 1.1.41 → 1.1.43
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.
|
@@ -32,8 +32,8 @@ export function reportStart(today) {
|
|
|
32
32
|
}
|
|
33
33
|
export function reportUrl(account, start, end, bySite = false) {
|
|
34
34
|
const q = new URLSearchParams({ dateRange: "CUSTOM", reportingTimeZone: "ACCOUNT_TIME_ZONE", dimensions: "DATE" });
|
|
35
|
-
// DOMAIN_NAME, not OWNED_SITE_DOMAIN_NAME: the owned-site rows nest (
|
|
36
|
-
// contains www.
|
|
35
|
+
// DOMAIN_NAME, not OWNED_SITE_DOMAIN_NAME: the owned-site rows nest (example.com
|
|
36
|
+
// contains www.example.com and jobs.example.com), so they double count.
|
|
37
37
|
if (bySite)
|
|
38
38
|
q.append("dimensions", "DOMAIN_NAME");
|
|
39
39
|
for (const [k, v] of [["startDate", start], ["endDate", end]]) {
|
|
@@ -183,7 +183,7 @@ export function createAdsensePlugin() {
|
|
|
183
183
|
}
|
|
184
184
|
catch { /* totals only */ }
|
|
185
185
|
return {
|
|
186
|
-
// The account's own name
|
|
186
|
+
// The account's own name labels the service.
|
|
187
187
|
// It overrides the plugin's instance name, which only says "AdSense".
|
|
188
188
|
...(account.displayName ? { name: account.displayName } : {}),
|
|
189
189
|
accountId: account.name,
|
|
@@ -102,7 +102,7 @@ export function pagesRequest(startDate, exclude = []) {
|
|
|
102
102
|
const bareHost = (h) => h.trim().toLowerCase().replace(/^www\./, "");
|
|
103
103
|
/**
|
|
104
104
|
* A pages report -> the most viewed pages per host. `/a` and `/a/` add up, and a
|
|
105
|
-
* site name most titles end in (" |
|
|
105
|
+
* site name most titles end in (" | Example Site") comes off.
|
|
106
106
|
*/
|
|
107
107
|
export function parsePages(report) {
|
|
108
108
|
const rows = (report?.rows || []).map((r) => ({
|
|
@@ -28,7 +28,7 @@ function parseLine(line) {
|
|
|
28
28
|
const ips = tokens.filter((t) => IP_TOKEN.test(t));
|
|
29
29
|
const ip = ips.length ? ips[ips.length - 1] : (tokens[0] || "");
|
|
30
30
|
// A host in the leading field is what lets a SHARED access log (one file for
|
|
31
|
-
// every vhost
|
|
31
|
+
// every vhost) still say WHICH site was hit. A plain
|
|
32
32
|
// combined line leads with the client IP instead, so only a non-IP first
|
|
33
33
|
// token counts. When $host is itself an IP — a request made straight to the
|
|
34
34
|
// origin address — there is no site name to report and "" is correct.
|
|
@@ -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
|
|
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
|
-
|
|
178
|
-
|
|
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;
|
|
@@ -193,7 +196,7 @@ export function analyze(lines, cfg, windowSec, st) {
|
|
|
193
196
|
// batch/v1 finding so it leads.
|
|
194
197
|
// Only call it a takeover when the login status is trustworthy for this site
|
|
195
198
|
// AND the IP went on to load an admin screen successfully. Either half alone
|
|
196
|
-
//
|
|
199
|
+
// would page a false "account takeover".
|
|
197
200
|
const authed = (ip) => !st?.hideLogin && loginOkIps.has(ip) && adminOkIps.has(ip);
|
|
198
201
|
for (const [ip, count] of batchIps) {
|
|
199
202
|
if (authed(ip)) {
|
|
@@ -230,13 +233,16 @@ export function analyze(lines, cfg, windowSec, st) {
|
|
|
230
233
|
});
|
|
231
234
|
}
|
|
232
235
|
}
|
|
233
|
-
for (const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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 /
|
|
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;
|
|
@@ -4,7 +4,7 @@ import { readdir } from "node:fs/promises";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
6
6
|
// WordPress security-posture plugin. Runs on the WP host (where the daemonitor
|
|
7
|
-
// client already runs) and polls the state that
|
|
7
|
+
// client already runs) and polls the state that WordPress intrusions tend to move
|
|
8
8
|
// through: admin accounts, page statuses, must-use plugins, and the web user's
|
|
9
9
|
// crontab. It emits raw observations only — severity and the new-admin /
|
|
10
10
|
// admin-demotion DIFFS are decided server-side in the ingest handler
|
|
@@ -16,11 +16,11 @@ import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
|
16
16
|
// is best-effort and isolated: one failing (e.g. crontab needs root) degrades
|
|
17
17
|
// that one field to null, it never kills the poll.
|
|
18
18
|
const pexec = promisify(execFile);
|
|
19
|
-
// Service-account naming patterns
|
|
19
|
+
// Service-account naming patterns common in WordPress intrusions. Any admin
|
|
20
20
|
// whose login/email contains one of these (case-insensitive substring) is
|
|
21
21
|
// flagged for escalation even before the new-admin diff fires.
|
|
22
22
|
const DEFAULT_SERVICE_PATTERNS = [
|
|
23
|
-
"wpsvc", "wpengine", "bot", "svc", "
|
|
23
|
+
"wpsvc", "wpengine", "bot", "svc", "wordpress-svc",
|
|
24
24
|
];
|
|
25
25
|
const DEFAULT_CRITICAL_PAGES = ["login", "home", "index", "front-page"];
|
|
26
26
|
function slug(s) {
|