@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.
@@ -1,48 +1,68 @@
1
- import pm2 from "pm2";
2
- import { MonitoringPlugin } from "../../lib/MonitoringPlugin.js";
3
- export class PM2Plugin extends MonitoringPlugin {
4
- constructor() {
5
- super("pm2", "PM2", "PM2 Monitoring Plugin");
6
- }
7
- async setup() {
8
- return new Promise((resolve, reject) => {
9
- try {
10
- pm2.connect(async (err) => {
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
- catch (e) {
21
- console.error("ERROR", e);
22
- reject(e);
23
- }
24
- });
25
- }
26
- async refresh() {
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
- pm2.list(async (err, list) => {
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 this.send(payload, instance_id);
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
- async monitor() {
81
- this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 5000);
82
- }
83
- async teardown() {
84
- clearInterval(this.refreshTimer);
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 { MonitoringPlugin } from "../../lib/MonitoringPlugin.js";
2
- export class WebsitePlugin extends MonitoringPlugin {
3
- constructor() {
4
- super("website", "Website", "Website Monitoring Plugin");
5
- }
6
- async setup() {
7
- // no setup required
8
- return Promise.resolve();
9
- }
10
- async refresh() {
11
- const startTimestamp = Date.now();
12
- const result = await fetch(this.config.url);
13
- const endTimestamp = Date.now();
14
- const text = await result.text();
15
- const status = result.status;
16
- const headers = result.headers;
17
- const ok = result.ok;
18
- const redirected = result.redirected;
19
- const type = result.type;
20
- const url = result.url;
21
- const body = text;
22
- const duration = endTimestamp - startTimestamp;
23
- const expectedStrings = this.config.expectedStrings || [];
24
- const unexpectedStrings = this.config.unexpectedStrings || [];
25
- const hasAllExpectedStrings = expectedStrings.every(expectedString => body.indexOf(expectedString) !== -1);
26
- const hasAnyUnexpectedStrings = unexpectedStrings.some(unexpectedString => body.indexOf(unexpectedString) !== -1);
27
- await this.send({
28
- status,
29
- duration,
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
- ok,
32
- redirected,
33
- type,
34
- url,
35
- body,
36
- hasAllExpectedStrings,
37
- hasAnyUnexpectedStrings
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
- async monitor() {
41
- this.refreshTimer = setInterval(this.refresh.bind(this), this.config.refreshInterval || 5000);
42
- }
43
- async teardown() {
44
- clearInterval(this.refreshTimer);
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"