@daemonitor/plugins 1.0.3 → 1.1.1
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/index.js +9 -6
- package/dist/lib/BasePlugin.js +26 -16
- package/dist/lib/MonitoringPlugin.js +38 -22
- package/dist/lib/PluginConfigProvider.js +14 -7
- package/dist/lib/PluginLoader.js +26 -11
- package/dist/lib/PluginManager.js +78 -44
- package/dist/lib/processes.js +36 -0
- package/dist/plugins/index.js +31 -10
- package/dist/plugins/monitoring/DockerPlugin.js +170 -0
- package/dist/plugins/monitoring/LogWatchPlugin.js +212 -0
- package/dist/plugins/monitoring/LxcPlugin.js +126 -0
- package/dist/plugins/monitoring/NetScanPlugin.js +77 -0
- package/dist/plugins/monitoring/OSPlugin.js +184 -25
- package/dist/plugins/monitoring/PM2Plugin.js +71 -43
- package/dist/plugins/monitoring/WebsitePlugin.js +205 -42
- package/dist/plugins/monitoring/WordpressPlugin.js +248 -0
- package/index.ts +11 -0
- package/package.json +32 -11
- package/dist/plugins/monitoring/EwelinkPlugin.js +0 -63
- package/dist/plugins/monitoring/MongoDBPlugin.js +0 -30
|
@@ -1,48 +1,68 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
|
|
2
|
+
// Factory function to create PM2 plugin
|
|
3
|
+
export function createPM2Plugin() {
|
|
4
|
+
// Create plugin state
|
|
5
|
+
let pm2Instance = null;
|
|
6
|
+
let refreshTimer = null;
|
|
7
|
+
// Define setup function
|
|
8
|
+
const setupFn = async (plugin) => {
|
|
9
|
+
try {
|
|
10
|
+
// First, check if PM2 is available globally (injected by the client-test)
|
|
11
|
+
if (global.pm2Library) {
|
|
12
|
+
console.log("Using globally provided PM2 instance");
|
|
13
|
+
pm2Instance = global.pm2Library;
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
// Otherwise use dynamic import approach which is ESM friendly
|
|
17
|
+
try {
|
|
18
|
+
const pm2Module = await import('pm2');
|
|
19
|
+
pm2Instance = pm2Module.default || pm2Module;
|
|
20
|
+
// If import fails, try CommonJS require as fallback
|
|
21
|
+
if (!pm2Instance) {
|
|
22
|
+
try {
|
|
23
|
+
// @ts-ignore - Dynamically require pm2
|
|
24
|
+
pm2Instance = require('pm2');
|
|
25
|
+
}
|
|
26
|
+
catch (requireErr) {
|
|
27
|
+
console.error("Failed to require PM2:", requireErr);
|
|
28
|
+
throw new Error(`Neither import nor require could load pm2: ${requireErr.message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.error("Failed to import PM2:", err);
|
|
34
|
+
throw new Error(`PM2 import failed: ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Return a promise for the PM2 connection
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
pm2Instance.connect((err) => {
|
|
11
40
|
if (err) {
|
|
12
|
-
console.error(err);
|
|
41
|
+
console.error("PM2 connection failed:", err);
|
|
13
42
|
reject(err);
|
|
14
43
|
}
|
|
15
44
|
else {
|
|
16
45
|
resolve();
|
|
17
46
|
}
|
|
18
47
|
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
console.error("PM2Plugin setup error:", e);
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
// Define refresh function
|
|
56
|
+
const refreshFn = async (plugin) => {
|
|
57
|
+
if (!pm2Instance) {
|
|
58
|
+
console.error("PM2 instance not initialized");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
27
61
|
const instanceCounts = {};
|
|
28
|
-
|
|
62
|
+
pm2Instance.list(async (err, list) => {
|
|
29
63
|
if (!err) {
|
|
30
64
|
for (const p of list) {
|
|
31
|
-
let { pid, name, pm_id, monit,
|
|
32
|
-
// exit_code,
|
|
33
|
-
// prev_restart_delay,
|
|
34
|
-
// versioning,
|
|
35
|
-
// axm_dynamic,
|
|
36
|
-
// axm_actions,
|
|
37
|
-
// merge_logs,
|
|
38
|
-
// vizion,
|
|
39
|
-
// instance_var,
|
|
40
|
-
// pmx,
|
|
41
|
-
// automation,
|
|
42
|
-
// treekill,
|
|
43
|
-
// windowsHide,
|
|
44
|
-
// kill_retry_time
|
|
45
|
-
} = p;
|
|
65
|
+
let { pid, name, pm_id, monit, } = p;
|
|
46
66
|
if (instanceCounts[name] === undefined) {
|
|
47
67
|
instanceCounts[name] = 0;
|
|
48
68
|
}
|
|
@@ -69,18 +89,26 @@ export class PM2Plugin extends MonitoringPlugin {
|
|
|
69
89
|
status,
|
|
70
90
|
pm_uptime,
|
|
71
91
|
};
|
|
72
|
-
await
|
|
92
|
+
await plugin.send(payload, instance_id);
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
95
|
else {
|
|
76
96
|
console.error(err);
|
|
77
97
|
}
|
|
78
98
|
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
99
|
+
};
|
|
100
|
+
// Define monitor function
|
|
101
|
+
const monitorFn = async (plugin) => {
|
|
102
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config.refreshInterval || 5000);
|
|
103
|
+
};
|
|
104
|
+
// Define teardown function
|
|
105
|
+
const teardownFn = async (plugin) => {
|
|
106
|
+
if (refreshTimer) {
|
|
107
|
+
clearInterval(refreshTimer);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
// Create and return the plugin
|
|
111
|
+
return createMonitoringPlugin("pm2", "PM2", "PM2 Monitoring Plugin", setupFn, monitorFn, refreshFn, teardownFn);
|
|
86
112
|
}
|
|
113
|
+
// Export a default factory function for plugin loader
|
|
114
|
+
export default createPM2Plugin;
|
|
@@ -1,46 +1,209 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
import * as http from "node:http";
|
|
2
|
+
import * as https from "node:https";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
|
|
5
|
+
// Low-level GET that can hit one host (`target`) while presenting a different
|
|
6
|
+
// Host header. fetch() can't do this — Host is a forbidden header per the
|
|
7
|
+
// Fetch spec and is silently dropped — so this uses Node's http/https
|
|
8
|
+
// directly, which honors it.
|
|
9
|
+
function rawGet(target, opts) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
let u;
|
|
13
|
+
try {
|
|
14
|
+
u = new URL(target);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
resolve({ status: 0, body: "", duration: 0, error: `Invalid origin URL: ${target}` });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
for (const [k, v] of Object.entries(opts.query || {}))
|
|
21
|
+
u.searchParams.set(k, v);
|
|
22
|
+
const headers = { ...(opts.headers || {}) };
|
|
23
|
+
if (opts.host)
|
|
24
|
+
headers.Host = opts.host;
|
|
25
|
+
const lib = u.protocol === "http:" ? http : https;
|
|
26
|
+
const req = lib.request({
|
|
27
|
+
hostname: u.hostname,
|
|
28
|
+
port: u.port || (u.protocol === "http:" ? 80 : 443),
|
|
29
|
+
path: `${u.pathname}${u.search}`,
|
|
30
|
+
method: "GET",
|
|
30
31
|
headers,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
// Explicit servername pins TLS/SNI to the real connection
|
|
33
|
+
// target. Without it, Node derives SNI from headers.Host,
|
|
34
|
+
// which would validate the cert against the WRONG hostname
|
|
35
|
+
// (the public one, not the origin actually being dialed).
|
|
36
|
+
...(u.protocol === "https:" ? { servername: u.hostname, rejectUnauthorized: !opts.insecure } : {}),
|
|
37
|
+
}, (res) => {
|
|
38
|
+
let body = "";
|
|
39
|
+
res.on("data", (c) => { body += c; });
|
|
40
|
+
res.on("end", () => resolve({ status: res.statusCode || 0, body, duration: Date.now() - start }));
|
|
38
41
|
});
|
|
42
|
+
req.on("error", (err) => resolve({ status: 0, body: "", duration: Date.now() - start, error: err.message }));
|
|
43
|
+
const timeoutMs = opts.timeout || 10000;
|
|
44
|
+
req.setTimeout(timeoutMs, () => {
|
|
45
|
+
req.destroy();
|
|
46
|
+
resolve({ status: 0, body: "", duration: Date.now() - start, error: "timeout" });
|
|
47
|
+
});
|
|
48
|
+
req.end();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function evaluate(body, status, expected, unexpected, expectedStatusCode) {
|
|
52
|
+
// Report WHICH strings matched/went missing, not just booleans. For security
|
|
53
|
+
// checks (injected-script IOCs, webshell markers, defacement canaries) the
|
|
54
|
+
// alert is only actionable if it names the offending string — "IOC _ea_s
|
|
55
|
+
// matched" vs. an opaque "check failed". `unexpectedStrings` doubles as an
|
|
56
|
+
// IOC list; `expectedStrings` doubles as an integrity canary (its absence =
|
|
57
|
+
// possible defacement). Kept as substring matches (no regex) to preserve the
|
|
58
|
+
// existing config contract.
|
|
59
|
+
const missingExpected = expected.filter((s) => body.indexOf(s) === -1);
|
|
60
|
+
const matchedUnexpected = unexpected.filter((s) => body.indexOf(s) !== -1);
|
|
61
|
+
const hasAllExpectedStrings = missingExpected.length === 0;
|
|
62
|
+
const hasAnyUnexpectedStrings = matchedUnexpected.length > 0;
|
|
63
|
+
const statusOk = expectedStatusCode ? status === expectedStatusCode : status >= 200 && status < 400;
|
|
64
|
+
return { ok: statusOk && hasAllExpectedStrings && !hasAnyUnexpectedStrings, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected };
|
|
65
|
+
}
|
|
66
|
+
async function checkOrigin(ep) {
|
|
67
|
+
const oc = ep.origin;
|
|
68
|
+
const r = await rawGet(oc.url, {
|
|
69
|
+
host: oc.host,
|
|
70
|
+
headers: oc.headers,
|
|
71
|
+
query: oc.query,
|
|
72
|
+
insecure: oc.insecure,
|
|
73
|
+
timeout: oc.timeout ?? ep.timeout,
|
|
74
|
+
});
|
|
75
|
+
if (r.error) {
|
|
76
|
+
return { url: oc.url, host: oc.host, status: r.status, duration: r.duration, ok: false, error: r.error };
|
|
39
77
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
78
|
+
const { ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected } = evaluate(r.body, r.status, oc.expectedStrings ?? ep.expectedStrings ?? [], oc.unexpectedStrings ?? ep.unexpectedStrings ?? [], oc.expectedStatusCode ?? ep.expectedStatusCode);
|
|
79
|
+
return { url: oc.url, host: oc.host, status: r.status, duration: r.duration, ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected };
|
|
80
|
+
}
|
|
81
|
+
// HTTP uptime/content checks. Emits one client_state per endpoint, matching the
|
|
82
|
+
// frontend `web`/`website` fleetAdapter case ({ url, name, ok, status, duration }).
|
|
83
|
+
// Optionally two-tiered: the public URL (through Cloudflare/CDN) plus a direct
|
|
84
|
+
// origin check, nested under `origin`, so a CDN masking an origin outage still
|
|
85
|
+
// surfaces as a warning even while the public site keeps serving fine.
|
|
86
|
+
export function createWebsitePlugin() {
|
|
87
|
+
let refreshTimer = null;
|
|
88
|
+
const check = async (plugin, ep) => {
|
|
89
|
+
const start = Date.now();
|
|
90
|
+
const label = ep.name || ep.url;
|
|
91
|
+
const uid = `web-${(ep.name || ep.url).replace(/[^a-zA-Z0-9_.-]+/g, "-")}`;
|
|
92
|
+
let publicResult;
|
|
93
|
+
try {
|
|
94
|
+
const ctrl = new AbortController();
|
|
95
|
+
const t = ep.timeout ? setTimeout(() => ctrl.abort(), ep.timeout) : null;
|
|
96
|
+
const res = await fetch(ep.url, { signal: ctrl.signal, redirect: "follow" });
|
|
97
|
+
if (t)
|
|
98
|
+
clearTimeout(t);
|
|
99
|
+
const body = await res.text();
|
|
100
|
+
const duration = Date.now() - start;
|
|
101
|
+
const { ok, hasAllExpectedStrings, hasAnyUnexpectedStrings, missingExpected, matchedUnexpected } = evaluate(body, res.status, ep.expectedStrings || [], ep.unexpectedStrings || [], ep.expectedStatusCode);
|
|
102
|
+
publicResult = {
|
|
103
|
+
name: label,
|
|
104
|
+
url: res.url || ep.url,
|
|
105
|
+
status: res.status,
|
|
106
|
+
duration,
|
|
107
|
+
ok,
|
|
108
|
+
redirected: res.redirected,
|
|
109
|
+
hasAllExpectedStrings,
|
|
110
|
+
hasAnyUnexpectedStrings,
|
|
111
|
+
missingExpected,
|
|
112
|
+
matchedUnexpected,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
publicResult = {
|
|
117
|
+
name: label,
|
|
118
|
+
url: ep.url,
|
|
119
|
+
status: 0,
|
|
120
|
+
duration: Date.now() - start,
|
|
121
|
+
ok: false,
|
|
122
|
+
error: err.message,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const origin = ep.origin?.url ? await checkOrigin(ep) : undefined;
|
|
126
|
+
// `ok` stays the PUBLIC tier's own result — don't collapse it with
|
|
127
|
+
// origin.ok. The two tiers are different failure modes with different
|
|
128
|
+
// severity (public down = real outage; origin-only down = CDN masking
|
|
129
|
+
// a backend problem, still worth a warning). Collapsing them into one
|
|
130
|
+
// boolean loses that distinction downstream (ingest branches on
|
|
131
|
+
// `ok`/`origin.ok` separately to assign error vs warning).
|
|
132
|
+
await plugin.send({ ...publicResult, origin }, uid);
|
|
133
|
+
};
|
|
134
|
+
const localEndpointsOf = (plugin) => {
|
|
135
|
+
const cfg = plugin.config || {};
|
|
136
|
+
if (Array.isArray(cfg.endpoints))
|
|
137
|
+
return cfg.endpoints;
|
|
138
|
+
if (cfg.url) {
|
|
139
|
+
return [{
|
|
140
|
+
name: cfg.name,
|
|
141
|
+
url: cfg.url,
|
|
142
|
+
expectedStrings: cfg.expectedStrings,
|
|
143
|
+
unexpectedStrings: cfg.unexpectedStrings,
|
|
144
|
+
expectedStatusCode: cfg.expectedStatusCode,
|
|
145
|
+
timeout: cfg.timeout,
|
|
146
|
+
origin: cfg.origin,
|
|
147
|
+
}];
|
|
148
|
+
}
|
|
149
|
+
return [];
|
|
150
|
+
};
|
|
151
|
+
// Server-managed endpoints: when the config carries a `configUrl` (the
|
|
152
|
+
// daemonitor /api/monitors/for-client endpoint) and this client's
|
|
153
|
+
// `systemKey`, monitors edited in the UI are pulled from there. The last
|
|
154
|
+
// successful fetch is cached so a transient network/API failure keeps the
|
|
155
|
+
// previous set running instead of silently dropping every check.
|
|
156
|
+
let lastRemote = null;
|
|
157
|
+
const remoteEndpointsOf = async (plugin) => {
|
|
158
|
+
const cfg = plugin.config || {};
|
|
159
|
+
const configUrl = cfg.configUrl;
|
|
160
|
+
const systemKey = cfg.systemKey;
|
|
161
|
+
if (!configUrl || !systemKey)
|
|
162
|
+
return null;
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(configUrl, { headers: { "x-system-key": systemKey } });
|
|
165
|
+
if (!res.ok)
|
|
166
|
+
return lastRemote;
|
|
167
|
+
const body = await res.json();
|
|
168
|
+
const eps = Array.isArray(body?.endpoints) ? body.endpoints : [];
|
|
169
|
+
lastRemote = eps;
|
|
170
|
+
return eps;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return lastRemote;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const endpointsOf = async (plugin) => {
|
|
177
|
+
const local = localEndpointsOf(plugin);
|
|
178
|
+
const remote = await remoteEndpointsOf(plugin);
|
|
179
|
+
if (remote == null)
|
|
180
|
+
return local;
|
|
181
|
+
// Merge by name/url: server-managed monitors win, local-only ones
|
|
182
|
+
// (defined directly in the config file) are kept alongside.
|
|
183
|
+
const key = (e) => `${e.name || ""}|${e.url}`;
|
|
184
|
+
const merged = new Map();
|
|
185
|
+
for (const e of local)
|
|
186
|
+
merged.set(key(e), e);
|
|
187
|
+
for (const e of remote)
|
|
188
|
+
merged.set(key(e), e);
|
|
189
|
+
return [...merged.values()];
|
|
190
|
+
};
|
|
191
|
+
const refreshFn = async (plugin) => {
|
|
192
|
+
const eps = await endpointsOf(plugin);
|
|
193
|
+
for (const ep of eps) {
|
|
194
|
+
if (ep?.url)
|
|
195
|
+
await check(plugin, ep);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const monitorFn = async (plugin) => {
|
|
199
|
+
await refreshFn(plugin);
|
|
200
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 60000);
|
|
201
|
+
};
|
|
202
|
+
const teardownFn = async () => {
|
|
203
|
+
if (refreshTimer)
|
|
204
|
+
clearInterval(refreshTimer);
|
|
205
|
+
refreshTimer = null;
|
|
206
|
+
};
|
|
207
|
+
return createMonitoringPlugin("web", "web", "Website / HTTP uptime monitoring plugin", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
46
208
|
}
|
|
209
|
+
export default createWebsitePlugin;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { createMonitoringPlugin } from "../../lib/MonitoringPlugin";
|
|
6
|
+
// WordPress security-posture plugin. Runs on the WP host (where the daemonitor
|
|
7
|
+
// client already runs) and polls the state that the Archpaper intrusions moved
|
|
8
|
+
// through: admin accounts, page statuses, must-use plugins, and the web user's
|
|
9
|
+
// crontab. It emits raw observations only — severity and the new-admin /
|
|
10
|
+
// admin-demotion DIFFS are decided server-side in the ingest handler
|
|
11
|
+
// (clientstate/update.put.ts, case "wordpress"), so an agent restart can't reset
|
|
12
|
+
// the baseline and let a change slip through.
|
|
13
|
+
//
|
|
14
|
+
// Collection is via WP-CLI + filesystem + crontab (no DB credentials, no extra
|
|
15
|
+
// npm dependency) — mirroring MongoDBPlugin's shell-out pattern. Every collector
|
|
16
|
+
// is best-effort and isolated: one failing (e.g. crontab needs root) degrades
|
|
17
|
+
// that one field to null, it never kills the poll.
|
|
18
|
+
const pexec = promisify(execFile);
|
|
19
|
+
// Service-account naming patterns seen in the Archpaper intrusions. Any admin
|
|
20
|
+
// whose login/email contains one of these (case-insensitive substring) is
|
|
21
|
+
// flagged for escalation even before the new-admin diff fires.
|
|
22
|
+
const DEFAULT_SERVICE_PATTERNS = [
|
|
23
|
+
"wpsvc", "wpengine", "bot", "svc", "researchlabs", "wordpress-svc",
|
|
24
|
+
];
|
|
25
|
+
const DEFAULT_CRITICAL_PAGES = ["login", "home", "index", "front-page"];
|
|
26
|
+
function slug(s) {
|
|
27
|
+
return s.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
28
|
+
}
|
|
29
|
+
const MAXBUF = 8 * 1024 * 1024;
|
|
30
|
+
// Run a command in the site's context — inside its LXC container when `lxc` is
|
|
31
|
+
// set (via `sudo -n lxc exec <c> -- …`), else on the host (optionally as
|
|
32
|
+
// `runAs`). Returns stdout, or throws — callers isolate their own failures.
|
|
33
|
+
async function execInSite(site, argv, timeout = 15000) {
|
|
34
|
+
if (site.lxc) {
|
|
35
|
+
const lxcBin = site.lxcBin || "lxc";
|
|
36
|
+
const { stdout } = await pexec("sudo", ["-n", lxcBin, "exec", site.lxc, "--", ...argv], { timeout, maxBuffer: MAXBUF });
|
|
37
|
+
return stdout;
|
|
38
|
+
}
|
|
39
|
+
if (site.runAs) {
|
|
40
|
+
const { stdout } = await pexec("sudo", ["-n", "-u", site.runAs, ...argv], { timeout, maxBuffer: MAXBUF });
|
|
41
|
+
return stdout;
|
|
42
|
+
}
|
|
43
|
+
const { stdout } = await pexec(argv[0], argv.slice(1), { timeout, maxBuffer: MAXBUF });
|
|
44
|
+
return stdout;
|
|
45
|
+
}
|
|
46
|
+
// Run `wp <args>` in the site's context. Adds:
|
|
47
|
+
// --skip-plugins/--skip-themes so a plugin/theme's PHP notices don't get
|
|
48
|
+
// printed onto stdout ahead of the JSON and break the parse;
|
|
49
|
+
// --url when known (fixes wp-config reads of $_SERVER['SERVER_NAME']; also
|
|
50
|
+
// correct for multisite);
|
|
51
|
+
// --allow-root when running via `lxc exec` (that lands as root in the
|
|
52
|
+
// container, and WP-CLI refuses root without it).
|
|
53
|
+
async function wp(site, args, timeout = 15000) {
|
|
54
|
+
const bin = site.wpCli || "wp";
|
|
55
|
+
const full = [...args, `--path=${site.path}`, "--skip-plugins", "--skip-themes"];
|
|
56
|
+
if (site.url)
|
|
57
|
+
full.push(`--url=${site.url}`);
|
|
58
|
+
if (site.lxc)
|
|
59
|
+
full.push("--allow-root");
|
|
60
|
+
return execInSite(site, [bin, ...full], timeout);
|
|
61
|
+
}
|
|
62
|
+
function parseJsonSafe(s, fallback) {
|
|
63
|
+
try {
|
|
64
|
+
const v = JSON.parse(s);
|
|
65
|
+
return v == null ? fallback : v;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function collectSite(site) {
|
|
72
|
+
const label = site.name || path.basename(site.path || "") || "wordpress";
|
|
73
|
+
const servicePatterns = (site.servicePatterns || DEFAULT_SERVICE_PATTERNS).map((s) => s.toLowerCase());
|
|
74
|
+
const criticalPages = (site.criticalPages || DEFAULT_CRITICAL_PAGES).map((s) => s.toLowerCase());
|
|
75
|
+
const out = { name: label, site: label, url: site.url };
|
|
76
|
+
// 1. Current administrators (the rogue-admin / demotion signal set).
|
|
77
|
+
try {
|
|
78
|
+
const raw = await wp(site, ["user", "list", "--role=administrator", "--fields=ID,user_login,user_email,user_registered", "--format=json"]);
|
|
79
|
+
const admins = parseJsonSafe(raw, []).map((u) => ({
|
|
80
|
+
id: String(u.ID ?? ""),
|
|
81
|
+
login: String(u.user_login ?? ""),
|
|
82
|
+
email: String(u.user_email ?? ""),
|
|
83
|
+
registered: u.user_registered ?? null,
|
|
84
|
+
}));
|
|
85
|
+
out.admins = admins;
|
|
86
|
+
// Service-account-pattern admins — flagged locally so they surface even on
|
|
87
|
+
// the very first poll, before the server-side new-admin diff has a baseline.
|
|
88
|
+
out.suspiciousAdmins = admins
|
|
89
|
+
.filter((a) => servicePatterns.some((p) => a.login.toLowerCase().includes(p) || a.email.toLowerCase().includes(p)))
|
|
90
|
+
.map((a) => ({ login: a.login, email: a.email, reason: "service-account naming pattern" }));
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
out.adminsError = String(e?.message || e).slice(0, 160);
|
|
94
|
+
}
|
|
95
|
+
// 2. WP core version (posture; latest-version comparison is done elsewhere).
|
|
96
|
+
try {
|
|
97
|
+
out.wpVersion = (await wp(site, ["core", "version"])).trim();
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
out.wpVersionError = String(e?.message || e).slice(0, 160);
|
|
101
|
+
}
|
|
102
|
+
// 3. Trashed pages/posts — attackers hid the login/home page by trashing it.
|
|
103
|
+
try {
|
|
104
|
+
const raw = await wp(site, ["post", "list", "--post_status=trash", "--post_type=page,post", "--fields=ID,post_title,post_name,post_type", "--format=json"]);
|
|
105
|
+
const trashed = parseJsonSafe(raw, []).map((p) => ({
|
|
106
|
+
id: String(p.ID ?? ""),
|
|
107
|
+
title: String(p.post_title ?? ""),
|
|
108
|
+
slug: String(p.post_name ?? ""),
|
|
109
|
+
type: String(p.post_type ?? ""),
|
|
110
|
+
}));
|
|
111
|
+
out.trashedPages = trashed;
|
|
112
|
+
out.trashedCriticalPages = trashed.filter((p) => criticalPages.includes(p.slug.toLowerCase()) || criticalPages.includes(p.title.toLowerCase()));
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
out.trashedError = String(e?.message || e).slice(0, 160);
|
|
116
|
+
}
|
|
117
|
+
// 4. mu-plugins auto-load with no UI to disable them — a favorite persistence
|
|
118
|
+
// spot. Report every .php and which ones aren't in the allowlist. Read via the
|
|
119
|
+
// container when the site is LXC (the path is inside the container's fs).
|
|
120
|
+
try {
|
|
121
|
+
const dir = path.join(site.path, "wp-content", "mu-plugins");
|
|
122
|
+
let entries;
|
|
123
|
+
if (site.lxc) {
|
|
124
|
+
// `ls -1` inside the container; empty/no-dir → treat as no mu-plugins.
|
|
125
|
+
const out2 = await execInSite(site, ["ls", "-1", dir], 8000).catch(() => "");
|
|
126
|
+
entries = out2.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
entries = await readdir(dir);
|
|
130
|
+
}
|
|
131
|
+
const php = entries.filter((f) => f.toLowerCase().endsWith(".php"));
|
|
132
|
+
out.muPlugins = php;
|
|
133
|
+
if (site.muPluginsAllowlist) {
|
|
134
|
+
const allow = new Set(site.muPluginsAllowlist);
|
|
135
|
+
out.unknownMuPlugins = php.filter((f) => !allow.has(f));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// no mu-plugins dir = fine; leave muPlugins undefined
|
|
140
|
+
}
|
|
141
|
+
// 5. Web user's crontab — www-data should almost never have one; the attacker
|
|
142
|
+
// used it to re-drop webshells every 5 min. Non-empty = alert. `crontab -u`
|
|
143
|
+
// needs privilege: try direct first, then `sudo -n` (the agent user typically
|
|
144
|
+
// has passwordless sudo). "no crontab for <user>" is the HEALTHY empty case,
|
|
145
|
+
// not an error — anything else (still denied) is a real collection failure.
|
|
146
|
+
const cronUser = site.cronUser || "www-data";
|
|
147
|
+
const readCron = async () => {
|
|
148
|
+
// LXC: the web user's crontab lives INSIDE the container (that's where the
|
|
149
|
+
// persistence would be dropped), so read it there.
|
|
150
|
+
if (site.lxc) {
|
|
151
|
+
try {
|
|
152
|
+
return await execInSite(site, ["crontab", "-l", "-u", cronUser], 8000);
|
|
153
|
+
}
|
|
154
|
+
catch (e2) {
|
|
155
|
+
const msg = String(e2?.stderr || e2?.message || "");
|
|
156
|
+
// "no crontab for" = empty (healthy). "cannot use this program" /
|
|
157
|
+
// "not allowed to use" = the user is denied crontab entirely
|
|
158
|
+
// (cron.deny / not in cron.allow) — that's GOOD posture (can't be used
|
|
159
|
+
// for persistence), not a collection failure. Treat both as clean.
|
|
160
|
+
if (/no crontab for|cannot use this program|not allowed to use/i.test(msg))
|
|
161
|
+
return "";
|
|
162
|
+
throw e2;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
return (await pexec("crontab", ["-l", "-u", cronUser], { timeout: 8000 })).stdout;
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
const msg = String(e?.stderr || e?.message || "");
|
|
170
|
+
if (/no crontab for/i.test(msg))
|
|
171
|
+
return ""; // empty is fine
|
|
172
|
+
if (/privileged|not permitted|not allowed|denied/i.test(msg)) {
|
|
173
|
+
// Retry under sudo; may itself throw "no crontab for" (empty) → catch.
|
|
174
|
+
try {
|
|
175
|
+
return (await pexec("sudo", ["-n", "crontab", "-l", "-u", cronUser], { timeout: 8000 })).stdout;
|
|
176
|
+
}
|
|
177
|
+
catch (e2) {
|
|
178
|
+
if (/no crontab for/i.test(String(e2?.stderr || e2?.message || "")))
|
|
179
|
+
return "";
|
|
180
|
+
throw e2;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
throw e;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
try {
|
|
187
|
+
const stdout = await readCron();
|
|
188
|
+
const lines = stdout.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
189
|
+
out.webUserCrontab = lines.join("\n") || null;
|
|
190
|
+
out.hasWebUserCrontab = lines.length > 0;
|
|
191
|
+
out.cronUser = cronUser;
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
out.cronError = String(e?.stderr || e?.message || e).slice(0, 160);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
export function createWordpressPlugin() {
|
|
199
|
+
let refreshTimer = null;
|
|
200
|
+
const sitesOf = (plugin) => {
|
|
201
|
+
const cfg = (plugin.config || {});
|
|
202
|
+
if (Array.isArray(cfg.sites) && cfg.sites.length)
|
|
203
|
+
return cfg.sites;
|
|
204
|
+
if (cfg.path) {
|
|
205
|
+
return [{
|
|
206
|
+
name: cfg.name,
|
|
207
|
+
path: cfg.path,
|
|
208
|
+
url: cfg.url,
|
|
209
|
+
wpCli: cfg.wpCli,
|
|
210
|
+
runAs: cfg.runAs,
|
|
211
|
+
lxc: cfg.lxc,
|
|
212
|
+
lxcBin: cfg.lxcBin,
|
|
213
|
+
cronUser: cfg.cronUser,
|
|
214
|
+
muPluginsAllowlist: cfg.muPluginsAllowlist,
|
|
215
|
+
criticalPages: cfg.criticalPages,
|
|
216
|
+
servicePatterns: cfg.servicePatterns,
|
|
217
|
+
}];
|
|
218
|
+
}
|
|
219
|
+
return [];
|
|
220
|
+
};
|
|
221
|
+
const refreshFn = async (plugin) => {
|
|
222
|
+
const sites = sitesOf(plugin);
|
|
223
|
+
for (const site of sites) {
|
|
224
|
+
if (!site?.path)
|
|
225
|
+
continue;
|
|
226
|
+
try {
|
|
227
|
+
const data = await collectSite(site);
|
|
228
|
+
const uid = `wp-${slug(site.name || site.url || site.path)}`;
|
|
229
|
+
await plugin.send(data, uid);
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
const uid = `wp-${slug(site.name || site.url || site.path)}`;
|
|
233
|
+
await plugin.send({ name: site.name || site.path, error: String(e?.message || e).slice(0, 160) }, uid);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const monitorFn = async (plugin) => {
|
|
238
|
+
await refreshFn(plugin);
|
|
239
|
+
refreshTimer = setInterval(() => refreshFn(plugin), plugin.config?.refreshInterval || 300000);
|
|
240
|
+
};
|
|
241
|
+
const teardownFn = async () => {
|
|
242
|
+
if (refreshTimer)
|
|
243
|
+
clearInterval(refreshTimer);
|
|
244
|
+
refreshTimer = null;
|
|
245
|
+
};
|
|
246
|
+
return createMonitoringPlugin("wordpress", "wordpress", "WordPress security-posture monitoring plugin", async () => { }, monitorFn, refreshFn, teardownFn);
|
|
247
|
+
}
|
|
248
|
+
export default createWordpressPlugin;
|
package/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Export core plugin utilities
|
|
2
|
+
export { PluginConfigProvider } from "./lib/PluginConfigProvider"
|
|
3
|
+
export { PluginManager } from "./lib/PluginManager"
|
|
4
|
+
export { PluginLoader } from "./lib/PluginLoader"
|
|
5
|
+
|
|
6
|
+
// Only export creation functions, not types
|
|
7
|
+
export { createBasePlugin } from "./lib/BasePlugin"
|
|
8
|
+
export { createMonitoringPlugin } from "./lib/MonitoringPlugin"
|
|
9
|
+
|
|
10
|
+
// Export the PM2 plugin factory function
|
|
11
|
+
export { createPM2Plugin } from "./plugins/monitoring/PM2Plugin"
|