ADR-0017: Security & performance toolkit¶
Status: Proposed Date: 2026-05-31 Deciders: Ramón Kamibayashi + Claude Opus 4.7 Related: ADR-0010, ADR-0013, ADR-0014, ADR-0015, ADR-0016
Story: WP-Pulse should give the operator proactive signals on security posture + page performance. Today these are reactive ("customer says it's slow", "WPScan emails me"). Bring them into WP-Pulse + add the easy buttons to act.
Decision¶
8 features, 2 sub-agents in parallel (security / performance), no shared file ownership.
H — Security¶
| # | Feature | UX |
|---|---|---|
| H1 | File integrity check | Daily cron diffs WP core files in worktree vs canonical SHA256 from api.wordpress.org/core/checksums/1.0/?version=X.Y.Z&locale=en_US. Any mismatch → flag site as tampered + email/Telegram alert. UI: per-site "Integrity" status chip + drilldown listing modified core files. |
| H2 | Login attempts dashboard | Daily cron tails the prod error_log (or a dedicated auth-fail plugin) for failed wp-login.php POSTs. Surfaces in a table: IP, attempts last 24h, last_seen. Inline "Block this IP" → adds rule to .htaccess (preview-first). |
| H3 | 2FA enforcement audit | Cross-checks usermeta for the 2FA plugins commonly installed (WP 2FA, Two Factor, Wordfence). Lists admin users without 2FA enabled. Bulk "Enable 2FA prompt on next login" via a small mu-plugin queue. |
| H4 | Backup verification drill | Monthly cron: pick the latest site_versions row for each site → spin ephemeral stack (F4 infra) → smoke test "homepage returns 200 + admin/index.php 200 + no fatals in error_log" → record outcome in backup_drills table → alert if fail. |
Data model (alembic 023)¶
CREATE TABLE site_integrity_checks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
checked_at timestamptz DEFAULT now(),
status text NOT NULL, -- "clean|tampered|error"
tampered_files jsonb, -- ["wp-includes/load.php", ...]
wp_version_checked text
);
CREATE INDEX ON site_integrity_checks (site_id, checked_at DESC);
CREATE TABLE site_login_failures (
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
ip text NOT NULL,
attempts_24h int NOT NULL,
first_seen timestamptz,
last_seen timestamptz,
PRIMARY KEY (site_id, ip)
);
CREATE TABLE backup_drills (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
version_id uuid REFERENCES site_versions(id),
drilled_at timestamptz DEFAULT now(),
status text NOT NULL, -- "pass|fail|error"
details jsonb
);
API¶
GET /api/v1/sites/{slug}/integrity→ latest check.POST /api/v1/sites/{slug}/integrity/recheck→ triggers immediate check.GET /api/v1/sites/{slug}/login-failures→ IP table.POST /api/v1/sites/{slug}/login-failures/blockbody{ip}→ adds.htaccessDeny from rule (preview-first).GET /api/v1/sites/{slug}/2fa-audit→ admin users + 2FA status.POST /api/v1/sites/{slug}/2fa-enforcebody{user_ids}→ enqueues nudge.GET /api/v1/sites/{slug}/backup-drills→ list.
UI¶
/sites/[slug]/security(new tab) with 4 sections (cards per feature).- New components:
FileIntegrityCard.tsx,LoginFailuresTable.tsx,TwoFAAuditCard.tsx,BackupDrillsCard.tsx. - Fleet-level
/fleet?tab=securitysummary tab with one row per site, worst-status-first.
Cron jobs¶
file_integrity_check.py— daily 02:00, walks worktree (wp-includes+wp-adminpaths only), SHA256-diffs against canonical.login_failures_sync.py— every hour, parses prod error_log tail (agent exposes log via a newtail_logcommand) forwp-login.php200/401 POSTs.backup_drills_run.py— weekly Sunday 03:00. Picks one random site, runs the drill.
I — Performance¶
| # | Feature | UX |
|---|---|---|
| I1 | Object cache toggle | One-button install of Redis Object Cache plugin + auto-config WP_REDIS_HOST. Detects whether the customer host has Redis available; if not, surfaces "Contact host". |
| I2 | Page cache config helper | Detects installed cache plugins (WP Rocket, W3TC, LiteSpeed, etc). Exposes Purge buttons from WP-Pulse UI. For configurable plugins, surfaces the most-used 5 settings. |
| I3 | CDN config helper | Cloudflare Page Rules helper: detects whether site is behind CF, then auto-suggests rules for /wp-content/uploads/, /wp-admin/, /wp-content/cache/. Operator clicks "Apply" → uses CF API (token already in env) to push. |
| I4 | Lighthouse on demand | "Score this site" button → spawns lighthouse-ci container against prod URL (or preview if checkbox selected) → returns 4 scores + waterfall summary. Compares to baseline stored in site_lighthouse_runs table. |
Data model (alembic 024)¶
CREATE TABLE site_lighthouse_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid REFERENCES sites(id) ON DELETE CASCADE,
ran_at timestamptz DEFAULT now(),
target text NOT NULL, -- "prod|preview"
url text NOT NULL,
scores jsonb NOT NULL, -- {performance, accessibility, best_practices, seo}
audits jsonb, -- selected high-impact audit results
duration_ms int
);
CREATE INDEX ON site_lighthouse_runs (site_id, ran_at DESC);
API¶
POST /api/v1/sites/{slug}/perf/object-cache/install— install + activate Redis Object Cache plugin (preview-first via plugin manager from ADR-0014).GET /api/v1/sites/{slug}/perf/cache-status→ which cache plugin is installed + status.POST /api/v1/sites/{slug}/perf/cache-purge→ purges via the detected plugin.GET /api/v1/sites/{slug}/perf/cf-status→ whether CF, current rules.POST /api/v1/sites/{slug}/perf/cf-page-rulebody{pattern, settings}→ push via CF API.POST /api/v1/sites/{slug}/perf/lighthouse-runbody{target: "prod|preview"}→ 202 + job_id.GET /api/v1/sites/{slug}/perf/lighthouse/{job_id}→ status + result.
UI¶
/sites/[slug]/performance(new tab) with 4 cards.- Components:
ObjectCacheCard.tsx,PageCacheCard.tsx,CdnCard.tsx,LighthouseCard.tsx,LighthouseScoreBars.tsx. - Fleet-level
/fleet?tab=performanceshowing per-site latest Lighthouse score (color chips).
Lighthouse container¶
New docker-compose service (host-side, not per-site) running patrickhulce/lhci (or rolling our own). Triggered on demand. Cap 1 concurrent run server-wide; queue if busy.
Migration¶
Alembic 023 (security tables) and 024 (lighthouse runs). Independent; either can apply first.
Rollout¶
Two sub-agents in parallel, each owns its sub-section. No shared backend touchpoints beyond the alembic chain.
Open questions¶
- CVE in security tab: F1 already cross-refs Patchstack (ADR-0014). Security tab links to the plugin list filter
?has_vuln=true. - Backup drill cost: ephemeral stacks consume RAM. Cap to 1 drill per night.
- CF API token storage: per-site or global? Recommended global — one CF account fronts all the operator's sites in this homelab model. Use existing
server_settings. - Lighthouse from inside vs external: running locally hits the same network as the agent → unrealistic latencies. Recommended: use Google PageSpeed Insights API as fallback for "real internet" scores. Run local LHCI for diff-against-baseline.