@daemonitor/plugins 1.1.42 → 1.1.44
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.
- package/dist/plugins/index.js +2 -0
- package/dist/plugins/monitoring/AdsensePlugin.js +3 -3
- package/dist/plugins/monitoring/AppActivityPlugin.js +152 -0
- package/dist/plugins/monitoring/Ga4Plugin.js +1 -1
- package/dist/plugins/monitoring/LogWatchPlugin.js +2 -2
- package/dist/plugins/monitoring/WordpressPlugin.js +18 -3
- package/package.json +8 -6
package/dist/plugins/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createSnmpPlugin } from "./monitoring/SnmpPlugin.js";
|
|
|
11
11
|
import { createThreeCXPlugin } from "./monitoring/ThreeCXPlugin.js";
|
|
12
12
|
import { createTplinkPlugin } from "./monitoring/TplinkPlugin.js";
|
|
13
13
|
import { createPatroniPlugin } from "./monitoring/PatroniPlugin.js";
|
|
14
|
+
import { createAppActivityPlugin } from "./monitoring/AppActivityPlugin.js";
|
|
14
15
|
import { createAdsensePlugin } from "./monitoring/AdsensePlugin.js";
|
|
15
16
|
import { createGa4Plugin } from "./monitoring/Ga4Plugin.js";
|
|
16
17
|
import { createNagiosPlugin } from "./monitoring/NagiosPlugin.js";
|
|
@@ -38,6 +39,7 @@ export default {
|
|
|
38
39
|
ThreeCX: createThreeCXPlugin,
|
|
39
40
|
Tplink: createTplinkPlugin,
|
|
40
41
|
Patroni: createPatroniPlugin,
|
|
42
|
+
Appactivity: createAppActivityPlugin,
|
|
41
43
|
Adsense: createAdsensePlugin,
|
|
42
44
|
Ga4: createGa4Plugin,
|
|
43
45
|
Nagios: createNagiosPlugin,
|
|
@@ -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,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin.js";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
const DAY_MS = 86_400_000;
|
|
6
|
+
const RECENT_LIMIT = 10;
|
|
7
|
+
const MAX_USER_PAGES = 20;
|
|
8
|
+
const USER_PAGE_SIZE = 1000;
|
|
9
|
+
export function maskEmail(email) {
|
|
10
|
+
const s = String(email ?? "");
|
|
11
|
+
const at = s.indexOf("@");
|
|
12
|
+
if (at < 1)
|
|
13
|
+
return "unknown";
|
|
14
|
+
return `${s[0]}***${s.slice(at)}`;
|
|
15
|
+
}
|
|
16
|
+
// One row per person, action and minute: a Google sign-in writes two audit lines
|
|
17
|
+
// a moment apart. An account made through the admin API names service_role as the
|
|
18
|
+
// actor and the new user in traits.user_id, as in admin_auth_events().
|
|
19
|
+
export const AUDIT_SQL = `
|
|
20
|
+
with e as (
|
|
21
|
+
select distinct on (person, action, date_trunc('minute', created_at))
|
|
22
|
+
created_at as at, action, who, provider
|
|
23
|
+
from (
|
|
24
|
+
select created_at,
|
|
25
|
+
payload->>'action' as action,
|
|
26
|
+
coalesce(payload->'traits'->>'user_id', payload->>'actor_id') as person,
|
|
27
|
+
coalesce(payload->'traits'->>'user_email', payload->>'actor_username') as who,
|
|
28
|
+
coalesce(payload->'traits'->>'provider', payload->'traits'->>'provider_type') as provider
|
|
29
|
+
from auth.audit_log_entries
|
|
30
|
+
where created_at >= now() - interval '30 days'
|
|
31
|
+
and payload->>'action' in ('user_signedup', 'login')
|
|
32
|
+
) raw
|
|
33
|
+
order by person, action, date_trunc('minute', created_at), created_at
|
|
34
|
+
)
|
|
35
|
+
select json_build_object(
|
|
36
|
+
'users', (select count(*) from auth.users where deleted_at is null),
|
|
37
|
+
'events', coalesce((select json_agg(e) from e), '[]'::json)
|
|
38
|
+
)`;
|
|
39
|
+
const windows = (times, now) => ({
|
|
40
|
+
d1: times.filter((t) => t >= now - DAY_MS).length,
|
|
41
|
+
d7: times.filter((t) => t >= now - 7 * DAY_MS).length,
|
|
42
|
+
d30: times.filter((t) => t >= now - 30 * DAY_MS).length,
|
|
43
|
+
});
|
|
44
|
+
const newestFirst = (events) => [...events].sort((a, b) => Date.parse(b.at) - Date.parse(a.at)).slice(0, RECENT_LIMIT);
|
|
45
|
+
/** Turn audit rows into the report. Exported for the check. */
|
|
46
|
+
export function summariseAudit(users, rows, now = Date.now()) {
|
|
47
|
+
const events = rows.map((r) => ({
|
|
48
|
+
at: new Date(r.at).toISOString(),
|
|
49
|
+
action: r.action === "user_signedup" ? "signup" : "login",
|
|
50
|
+
who: maskEmail(r.who),
|
|
51
|
+
...(r.provider ? { provider: String(r.provider) } : {}),
|
|
52
|
+
}));
|
|
53
|
+
const times = (action) => events.filter((e) => e.action === action).map((e) => Date.parse(e.at));
|
|
54
|
+
return {
|
|
55
|
+
users,
|
|
56
|
+
signups: windows(times("signup"), now),
|
|
57
|
+
logins: windows(times("login"), now),
|
|
58
|
+
loginsArePeople: false,
|
|
59
|
+
recent: newestFirst(events),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Turn GoTrue admin users into the report. Exported for the check. */
|
|
63
|
+
export function summariseUsers(list, now = Date.now()) {
|
|
64
|
+
const stamp = (v) => {
|
|
65
|
+
const t = Date.parse(String(v ?? ""));
|
|
66
|
+
return Number.isFinite(t) ? t : null;
|
|
67
|
+
};
|
|
68
|
+
const events = [];
|
|
69
|
+
const signups = [];
|
|
70
|
+
const logins = [];
|
|
71
|
+
for (const u of list) {
|
|
72
|
+
const who = maskEmail(u?.email);
|
|
73
|
+
const provider = u?.app_metadata?.provider ? String(u.app_metadata.provider) : undefined;
|
|
74
|
+
const created = stamp(u?.created_at);
|
|
75
|
+
const seen = stamp(u?.last_sign_in_at);
|
|
76
|
+
if (created != null && created >= now - 30 * DAY_MS) {
|
|
77
|
+
signups.push(created);
|
|
78
|
+
events.push({ at: new Date(created).toISOString(), action: "signup", who, ...(provider ? { provider } : {}) });
|
|
79
|
+
}
|
|
80
|
+
if (seen != null && seen >= now - 30 * DAY_MS) {
|
|
81
|
+
logins.push(seen);
|
|
82
|
+
events.push({ at: new Date(seen).toISOString(), action: "login", who, ...(provider ? { provider } : {}) });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
users: list.length,
|
|
87
|
+
signups: windows(signups, now),
|
|
88
|
+
logins: windows(logins, now),
|
|
89
|
+
loginsArePeople: true,
|
|
90
|
+
recent: newestFirst(events),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function collectPostgres(src, timeout) {
|
|
94
|
+
if (!src.database)
|
|
95
|
+
throw new Error("source has no database");
|
|
96
|
+
// ponytail: reads through one local container, so this agent's host going down
|
|
97
|
+
// silences every source. Move to HAProxy with a read-only role if that bites.
|
|
98
|
+
const { stdout } = await execFileAsync("docker", ["exec", src.container || "supacluster-patroni-1", "psql", "-U", "postgres", "-d", src.database, "-tAc", AUDIT_SQL], { timeout, killSignal: "SIGKILL", maxBuffer: 8 * 1024 * 1024 });
|
|
99
|
+
const parsed = JSON.parse(stdout);
|
|
100
|
+
return summariseAudit(Number(parsed.users) || 0, parsed.events || []);
|
|
101
|
+
}
|
|
102
|
+
async function collectSupabase(src, timeout) {
|
|
103
|
+
if (!src.url || !src.serviceKey)
|
|
104
|
+
throw new Error("source needs url and serviceKey");
|
|
105
|
+
const base = src.url.replace(/\/+$/, "");
|
|
106
|
+
const list = [];
|
|
107
|
+
for (let page = 1; page <= MAX_USER_PAGES; page++) {
|
|
108
|
+
const res = await fetch(`${base}/auth/v1/admin/users?page=${page}&per_page=${USER_PAGE_SIZE}`, {
|
|
109
|
+
headers: { apikey: src.serviceKey, Authorization: `Bearer ${src.serviceKey}` },
|
|
110
|
+
signal: AbortSignal.timeout(timeout),
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
throw new Error(`admin users HTTP ${res.status}`);
|
|
114
|
+
const users = (await res.json())?.users || [];
|
|
115
|
+
list.push(...users);
|
|
116
|
+
if (users.length < USER_PAGE_SIZE)
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
return summariseUsers(list);
|
|
120
|
+
}
|
|
121
|
+
export function sourceUid(src) {
|
|
122
|
+
return src.uniqueId || `activity-${src.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
123
|
+
}
|
|
124
|
+
export function createAppActivityPlugin() {
|
|
125
|
+
let refreshTimer = null;
|
|
126
|
+
const refreshFn = async (plugin) => {
|
|
127
|
+
const cfg = (plugin.config || {});
|
|
128
|
+
const timeout = cfg.requestTimeout || 20000;
|
|
129
|
+
for (const src of cfg.sources || []) {
|
|
130
|
+
const uid = sourceUid(src);
|
|
131
|
+
const head = { kind: "appactivity", name: src.name, source: src.kind || "postgres" };
|
|
132
|
+
try {
|
|
133
|
+
const body = src.kind === "supabase" ? await collectSupabase(src, timeout) : await collectPostgres(src, timeout);
|
|
134
|
+
await plugin.send({ ...head, reachable: true, ...body, timestamp: Date.now() }, uid);
|
|
135
|
+
}
|
|
136
|
+
catch (e) {
|
|
137
|
+
await plugin.send({ ...head, reachable: false, error: String(e?.message || e).slice(0, 200), timestamp: Date.now() }, uid);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const monitorFn = async (plugin) => {
|
|
142
|
+
await refreshFn(plugin);
|
|
143
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 300000);
|
|
144
|
+
};
|
|
145
|
+
const teardownFn = async () => {
|
|
146
|
+
if (refreshTimer)
|
|
147
|
+
clearInterval(refreshTimer);
|
|
148
|
+
refreshTimer = null;
|
|
149
|
+
};
|
|
150
|
+
return createMonitoringPlugin("appactivity", "appactivity", "Sign-ups and logins for apps on Supabase, self-hosted or cloud", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
151
|
+
}
|
|
152
|
+
export default createAppActivityPlugin;
|
|
@@ -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.
|
|
@@ -196,7 +196,7 @@ export function analyze(lines, cfg, windowSec, st, rules = []) {
|
|
|
196
196
|
// batch/v1 finding so it leads.
|
|
197
197
|
// Only call it a takeover when the login status is trustworthy for this site
|
|
198
198
|
// AND the IP went on to load an admin screen successfully. Either half alone
|
|
199
|
-
//
|
|
199
|
+
// would page a false "account takeover".
|
|
200
200
|
const authed = (ip) => !st?.hideLogin && loginOkIps.has(ip) && adminOkIps.has(ip);
|
|
201
201
|
for (const [ip, count] of batchIps) {
|
|
202
202
|
if (authed(ip)) {
|
|
@@ -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) {
|
|
@@ -68,6 +68,13 @@ function parseJsonSafe(s, fallback) {
|
|
|
68
68
|
return fallback;
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
+
// WP-CLI prints user_registered as UTC with no zone marker.
|
|
72
|
+
export function registrationCounts(registered, now = Date.now()) {
|
|
73
|
+
const day = 86_400_000;
|
|
74
|
+
const times = registered.map((r) => Date.parse(`${String(r).replace(" ", "T")}Z`)).filter(Number.isFinite);
|
|
75
|
+
const since = (days) => times.filter((t) => t >= now - days * day).length;
|
|
76
|
+
return { users: registered.length, signups: { d1: since(1), d7: since(7), d30: since(30) } };
|
|
77
|
+
}
|
|
71
78
|
async function collectSite(site) {
|
|
72
79
|
const label = site.name || path.basename(site.path || "") || "wordpress";
|
|
73
80
|
const servicePatterns = (site.servicePatterns || DEFAULT_SERVICE_PATTERNS).map((s) => s.toLowerCase());
|
|
@@ -92,6 +99,14 @@ async function collectSite(site) {
|
|
|
92
99
|
catch (e) {
|
|
93
100
|
out.adminsError = String(e?.message || e).slice(0, 160);
|
|
94
101
|
}
|
|
102
|
+
// Registrations, for the activity view. Counts only: nothing here names anyone.
|
|
103
|
+
try {
|
|
104
|
+
const raw = await wp(site, ["user", "list", "--fields=user_registered", "--format=json"], 30000);
|
|
105
|
+
out.activity = registrationCounts(parseJsonSafe(raw, []).map((u) => u.user_registered));
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
out.activityError = String(e?.message || e).slice(0, 160);
|
|
109
|
+
}
|
|
95
110
|
// 2. WP core version (posture; latest-version comparison is done elsewhere).
|
|
96
111
|
try {
|
|
97
112
|
out.wpVersion = (await wp(site, ["core", "version"])).trim();
|
package/package.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daemonitor/plugins",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.44",
|
|
4
4
|
"description": "daemonitor plugins",
|
|
5
5
|
"types": "types/index.d.ts",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
11
|
+
"prepublishOnly": "npm run build"
|
|
12
|
+
},
|
|
8
13
|
"files": [
|
|
9
14
|
"dist",
|
|
10
15
|
"types"
|
|
@@ -22,6 +27,7 @@
|
|
|
22
27
|
},
|
|
23
28
|
"author": "",
|
|
24
29
|
"license": "ISC",
|
|
30
|
+
"packageManager": "pnpm@9.15.9",
|
|
25
31
|
"devDependencies": {
|
|
26
32
|
"@types/node": "^26.1.1",
|
|
27
33
|
"nuxt": "^3.15.4",
|
|
@@ -53,9 +59,5 @@
|
|
|
53
59
|
},
|
|
54
60
|
"engines": {
|
|
55
61
|
"node": ">=18.17"
|
|
56
|
-
},
|
|
57
|
-
"scripts": {
|
|
58
|
-
"build": "tsc",
|
|
59
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
60
62
|
}
|
|
61
|
-
}
|
|
63
|
+
}
|