Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
261b2ea
fix(client-reports): keep Control's URL out of branding, fix period l…
claude Nov 3, 2026
b331d20
fix(contact-forms): add grace window to daily/weekly form-test due check
claude Nov 3, 2026
26b9ba3
fix(pressable): record Pressable traffic before client reports, witho…
claude Nov 3, 2026
e6d9ad0
fix(uptime): treat HTTP 429 as a blocked probe, not downtime
claude Nov 3, 2026
31d737d
fix(updates): retry auto-paused plugins, prioritise CVEs, run nightly…
claude Nov 3, 2026
2c17e46
fix(updates): surface real upgrader errors, redact download URLs, pau…
claude Nov 3, 2026
6d5dfe0
fix(backup-relay): require backup-create, keep per-site failures, dro…
claude Nov 3, 2026
0d60cc9
fix(security): normalize malware finding paths and alert on new uploa…
claude Nov 3, 2026
1503f11
fix(bill-com): normalize domains and accept fleet TLDs when matching …
claude Nov 3, 2026
968247e
fix(client-reports): one show/hide rule and shared wording for email,…
claude Nov 3, 2026
5716124
fix(client-reports): measure partial months from when monitoring bega…
claude Nov 3, 2026
fa15c92
fix(client-reports): no all-clear from partial malware scans, honest …
claude Nov 3, 2026
dff5f7c
fix(client-reports): hold suspect backups, partial or proxy-skewed tr…
claude Nov 3, 2026
0a4026b
fix(scans): flag standalone sites as WordPress, skip unreachable chec…
claude Nov 3, 2026
2b57ee2
Merge latest main and the fleet-wide fixes from the October report audit
claude Nov 3, 2026
e95c7d2
chore(migrations): give the four new 2026-11-03 migrations distinct t…
claude Nov 3, 2026
f7e41c2
fix(client-reports): hold reports whose off-site relay backups are fa…
claude Nov 3, 2026
f5a0357
fix(client-reports): count a clean SSH malware scan as complete unles…
claude Nov 3, 2026
fa34adc
fix(client-reports): read the uptime monitor's probe_blocked events a…
claude Nov 3, 2026
fde97a7
feat(client-reports): add a Recompile action for unsent scheduled rep…
claude Nov 3, 2026
a554b28
fix(contact-forms): stop promising email delivery verification the te…
claude Nov 3, 2026
a2599b1
docs(client-reports): describe current report behaviour and the safe …
claude Nov 3, 2026
9bcc90c
chore(gitleaks): allowlist the invented JWT fixture in UpgraderOutput…
claude Nov 3, 2026
18e3fa9
fix(client-reports): say live form tests were reported sent, not deli…
claude Nov 3, 2026
9b96bdc
fix(client-reports): measure uptime over the report timezone's days a…
claude Nov 3, 2026
2e34a1f
fix(action-log): redact secrets in every row mirrored to Companion, b…
claude Nov 3, 2026
60f3212
fix(updates): bound auto-pause retries and base the site pause on a r…
claude Nov 3, 2026
137aa90
fix(uptime): wait for a run of 429s before probe_blocked, and keep a …
claude Nov 3, 2026
e459b46
fix(security): make every SSH malware check prove it finished before …
claude Nov 3, 2026
6bfbdf9
fix(client-reports): don't show "blocked: 0" when lockouts in the per…
claude Nov 3, 2026
1adf9ea
fix(client-reports): hold relay sites whose off-site archives began p…
claude Nov 4, 2026
b46818c
fix(client-reports): ignore the scanner's own signature list in store…
claude Nov 4, 2026
ebed6f0
fix(client-reports): hold a report that applied no updates while upda…
claude Nov 4, 2026
71c0e89
fix(client-reports): refuse to send unsent reports compiled under old…
claude Nov 4, 2026
923e479
docs: document the new report and GTmetrix settings, the 429 chat not…
claude Nov 4, 2026
5ff8824
docs: bump updated dates on pages whose tracked code changed in this …
claude Nov 4, 2026
11c83f4
test(uptime): expect a manual recheck to need two 429s in a row befor…
claude Nov 4, 2026
d0393c5
docs(changelog): record the October client-report audit fixes
claude Nov 4, 2026
b276ada
fix(client-reports): don't hold reports for lockouts that never becam…
claude Nov 4, 2026
a184a7b
Merge main (v1.12.0, dependency bumps, UI QA fixes) into the report a…
claude Nov 5, 2026
47ab3aa
fix(client-reports): greet the actual recipient, or "Hi there,"
claude Nov 5, 2026
255ba96
fix(security): store exact-version advisories as exact ranges
claude Nov 5, 2026
4b19c44
fix(client-reports): report updates as they really happened
claude Nov 5, 2026
13f706d
fix(client-reports): count only real bans, and only when measured all…
claude Nov 5, 2026
db0f3db
fix(client-reports): start coverage when a care plan is switched on m…
claude Nov 5, 2026
ba7e1ee
fix(updates): hold back theme updates that would install a different …
claude Nov 5, 2026
23ac65d
fix(client-reports): hold reports with an outage of an hour or more
claude Nov 5, 2026
8544abd
fix(client-reports): colour uptime by value and match the text part t…
claude Nov 5, 2026
32becae
fix(client-reports): word operator review reasons from the real figures
claude Nov 5, 2026
4f116b9
feat(client-reports): add Clients tab, client reports page and test s…
claude Nov 5, 2026
7d3ea64
feat(updates): log plugin, theme and core updates made outside Control
claude Nov 5, 2026
2c24c00
fix(client-reports): only tell WordPress reports are active when a sc…
claude Nov 5, 2026
73fde12
docs: describe how vulnerability feed operators become stored ranges
claude Nov 5, 2026
d05fd44
feat(client-reports): let an operator mark review-tier malware findin…
claude Nov 5, 2026
d98a877
feat(client-reports): filterable reports list with bulk recompile
claude Nov 5, 2026
8e66ab8
fix(client-reports): read observed updates and advisories as recorded…
claude Nov 5, 2026
d4a4536
feat(client-reports): preview toolbar and app-styled email version
claude Nov 5, 2026
1e023e6
docs(client-reports): describe the Clients tab, filters, test sends a…
claude Nov 5, 2026
b3c6674
feat(client-reports): link the site dashboard to its client reports
claude Nov 5, 2026
068d424
chore(client-reports): bump report schema to 7
claude Nov 5, 2026
6ffb53f
fix(client-reports): fold the long missing-schedule list after nine s…
claude Nov 5, 2026
228b5be
test(client-reports): use invented versions and times in report fixtures
claude Nov 5, 2026
10d4913
Merge report-content fixes from the October report verification
claude Nov 5, 2026
e6eece6
Merge report-content and fleet fixes into the reports UI branch
claude Nov 5, 2026
6ba5437
fix(client-reports): report page uses the email's shared phrases
claude Nov 5, 2026
03e75b2
docs(client-reports): document schema 7 report-content rules
claude Nov 5, 2026
42c2dc5
feat(client-reports): organise clients and schedules by care plan
claude Nov 5, 2026
788febc
docs(changelog): record the Clients tab, report verification fixes an…
claude Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ AWS_USE_PATH_STYLE_ENDPOINT=false

VITE_APP_NAME="${APP_NAME}"

# Client reports show outage times (and measure uptime) in this timezone
# (any IANA zone name, e.g. Europe/London). Stored times are UTC; an empty
# or invalid zone falls back to UTC.
# CLOCKWORK_REPORTS_TIMEZONE=America/New_York

CLOCKWORK_ARCJET_BOTS_URL=https://raw.githubusercontent.com/arcjet/well-known-bots/main/well-known-bots.json

CLOCKWORK_MATTERMOST_ENABLED=false
Expand Down Expand Up @@ -369,6 +374,10 @@ CLOCKWORK_GTMETRIX_REGION=4
# CLOCKWORK_GTMETRIX_TIMEOUT=120
# CLOCKWORK_GTMETRIX_POLL_INTERVAL=5
# CLOCKWORK_GTMETRIX_POLL_MAX_ATTEMPTS=36
# API credits the plan refills daily; the rotation's month-end catch-up
# never schedules more scans than this in one night. 10 is the smallest
# (Core) plan's allowance; set it to your plan's daily credits.
# CLOCKWORK_GTMETRIX_DAILY_CREDITS=10

# Google PageSpeed Insights v5 — FALLBACK engine. Kept wired up for ~30
# days post-cutover so a transient GTmetrix outage doesn't lose a site's
Expand Down
8 changes: 8 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,11 @@ tests/Feature/Installer/InstallerWizardTest.php:generic-api-key:73
# private-key rule on the PEM markers alone, same false-positive shape as
# ReencryptSecretsTest.php above.
database/seeders/AnonymizedSnapshotSeeder.php:private-key:105

# UpgraderOutputTest.php's FAKE_JWT constant is an invented, unsigned
# JWT-shaped fixture (header {"typ":"JWT","alg":"HS256"}, payload
# {"license":"fake"}, signature segment = base64url of "fakesignature").
# It only exercises UpgraderOutput's redaction of JWT-in-path download URLs
# and is never sent anywhere. Matched by gitleaks' default jwt rule on shape
# alone. (Not quoted here, so this comment does not trip the same rule.)
tests/Unit/Support/UpgraderOutputTest.php:jwt:20
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Client Reports — Clients tab**: `/client-reports` opens on every client grouped by care plan ("Care plan clients — reported monthly" first, "No care plan" collapsed), with each site's care-plan badge, schedule, recipients and a six-month report history whose cells open that month's report. Care plan / No care plan / All, Needs attention / A–Z order, search and filters; a page per client with its full report history, previous/next client and a client switcher. A "Client reports" link sits in each site's dashboard header.
- **Client Reports — Send test to me**: emails a report's exact client email only to the signed-in operator, marked `[TEST]`, without changing the report or its schedule. Send now spells out who receives the report and that a schedule's first send switches it to sending clean reports automatically.
- **Client Reports — Schedules for care-plan sites that have none**: one-click (and "Create all") schedules from the Clients and Scheduling tabs; Scheduling lists care-plan schedules first and folds away the ones that won't send.
- **Client Reports — Reports list** (now `/client-reports/reports`): one period at a time with replaced copies hidden, filters for status, hold-reason category and search, summary counts that act as filters, reason chips, labelled actions, and "Recompile all outdated / selected" (never sends).
- **Client Reports — Report preview toolbar**: operator-only previous/next through the filtered list, Email version, Recompile, Print, Test to me and Send (disabled with the reason), keyboard shortcuts, and review reasons grouped by category with links to the site's dashboard tab. The email version page uses the app layout with HTML and plain-text tabs.
- **Client Reports — Mark findings reviewed**: an operator can mark a held report's review-tier malware findings (never `issues_found`) as reviewed with a note; both emails then read "Reviewed — no threat found", and the mark survives Recompile while the scan is unchanged.
- **Updates made outside Control are recorded**: snapshot changes write informational `plugin_update_observed` / `theme_update_observed` / `core_update_observed` activity (local only, never mirrored to the client's wp-admin, no effect on streaks or pauses).
- **Client Reports — Recompile held drafts without sending**: a **Recompile** action (reports list and operator preview) and `clockwork:recompile-held-client-reports` (`--outdated`, `--site`, `--dry-run`) rebuild an unsent scheduled report for its own period from current data, supersede the old copy and re-check it for review. Neither ever sends email. Unsent reports compiled under older rules (schema < 6) can't be sent until recompiled; the history shows **Outdated: recompile**.
- **Uptime — Probe-blocked state**: a site that answers the monitor with HTTP 429 (rate limiting) as many times in a row as the failure threshold moves to `probe_blocked` instead of down. No downtime, down alert or client email; one ops chat notice after a configurable number of hours (default 6, Monitoring → Settings), new `uptimeProbeBlocked` chat event.
- **Malware scans — New PHP file in uploads notice**: a chat notice (`phpInUploadsDetected`) fires once for each PHP file that newly appears under `wp-content/uploads`, diffed against the previous scan, so a dropped file is noticed without re-alerting on long-standing ones. Companion scans now set `has_malware_hit`, so the malware alert fires for them too.
- **Backup relay — Per-site failure state**: each site keeps its last relay attempt, last error and failing-since time (shown on the site's Backups card).

### Changed
- **Pressable traffic** is recorded at 04:45 UTC, before the 06:00 client-report run, for every Pressable site (with or without Companion), so a reported month's last day is complete.
- **Performance scans**: the weekly rotation gives each site a fixed weekday from its domain (enrolments no longer reshuffle everyone), adds a month-end catch-up for care-plan sites with no scan that month within the daily GTmetrix budget (`CLOCKWORK_GTMETRIX_DAILY_CREDITS`), and starts a baseline scan on enrolment or when a care plan is switched on.
- **Nightly plugin updates** run at most once per local (America/New_York) date, so the DST fall-back night no longer queues a second batch; `--force` overrides.
- **Client Reports — Email**: sent From the configured address under the report brand's company name; outage and coverage dates use `CLOCKWORK_REPORTS_TIMEZONE` (default America/New_York); one show/hide rule now drives the HTML email, the plain-text part and the preview/PDF.
- **Client Reports — Recipient import** creates schedules for sites without a care plan disabled unless `--include-no-care-plan` is passed, and each scheduled run logs (`client_report_skipped`) the schedules it skipped for having no care plan.

### Fixed
- **Client Reports — Report content verified against real data**: the greeting uses the recipient's first name only when there's one recipient who is a known contact, otherwise "Hi there,"; update dates use the report timezone; updates applied outside Control are listed and "no updates were needed" is never claimed; the agency's own Companion/Renegade plugins and updates that didn't stick are left out; "Malicious visitors blocked" excludes false-positive bans and shows only when lockouts reached Control all month; a care plan switched on mid-month starts the coverage period; outages of an hour or more hold the report; uptime is coloured by value; the plain-text email matches the HTML (data transferred, backup kinds); review reasons give exact figures. The preview, printout and public page use the same wording as the email. Report schema is now 7.
- **Client Reports — wp-admin** no longer shows reports as active for a site without a Control schedule.
- **Vulnerabilities — Exact-version advisories** (`== X`) no longer match every older release; a migration repairs existing rows.
- **Updates — Theme identity guard**: the nightly updater holds back a theme update that would install a different wordpress.org theme sharing the folder name (e.g. over a custom child theme), lists it in the nightly summary and announces it once; it doesn't count as a failure.
- **Client Reports — Accuracy fixes from an audit of a month of held drafts**:
- The support URL never falls back to Control's own (private) URL; the settings form rejects localhost/private URLs. Period lengths are whole days.
- Sites enrolled mid-month are measured from when they were added (uptime from when monitoring began), say so, and are held as partial-month reports.
- Rate-limited monitor checks (HTTP 429) aren't downtime; an open block at month end holds as "monitor was blocked", not "still down".
- A malware scan that stopped early shows "Partial scan", never "No malware found"; an earlier in-period hit, the finding kinds and scan id are given to the reviewer; matches on the scanner's own signature file are ignored.
- The WordPress core-files row is left out on hosts with no way to run the check; "Threats blocked" appears only when measured, and "at the server" only where a fail2ban jail exists.
- Vulnerable-plugin holds match plugins by slug and fire when a waiting update would fix the plugin or its updates are paused; a month where updates were waiting but none applied is held.
- Backups: database dumps far smaller than the database are held as suspect; a relay that has been failing, began mid-month, or missed many days is held with the reason.
- Traffic gets a status (ok / partial / suspect / no data / unsupported) and is shown only when complete and believable (e.g. not when every request comes from a load balancer's IP).
- Failed contact-form checks hold the report; live tests say the site "reported sending" the email rather than "delivered", and template, module and docs wording no longer promise inbox delivery checks.
- The preview lists core, theme and plugin updates, has a Contact forms section, and keeps staff names and operator notes off the printout and public page. Report schema is now 6.
- **Uptime**: HTTP 429 answers no longer mark a site down; during a real outage they don't close and reopen it (which re-sent down alerts and client emails every cycle).
- **Plugin updates**: vulnerable plugins are queued first (advisories now match on the plugin directory); auto-paused plugins are retried once per new release or security fix (7-day back-off, not for licence/offer failures), and a successful update lifts the pause and clears the wp-admin notice. Real upgrader errors (not writable, unauthorized download, bad/missing package) are recorded with a `reason_code` instead of "no longer offered", and repeated file-permission failures pause the site once with one alert instead of pausing every plugin.
- **Backup relay**: can't be enabled for a site whose Companion lacks `backup-create`, and such sites fail with an actionable message; a stale archive hash is no longer paired with a newer archive.
- **Malware scans**: finding paths are normalized to the WordPress root, so suppressions and the own-scanner exclusion work on every host layout (`clockwork:suppress-malware-path --kind`); SSH scans prove each check finished, so timeouts, cut-off output or a missing docroot no longer read as clean.
- **Contact form tests** no longer skip days: daily/weekly due checks use a grace window instead of a strict 24h/7d cutoff.
- **Pressable traffic** no longer overwrites earlier history with zeros for days the API returned no data.
- **Bill.com syncs** match invoice domains after normalization (including a leading `www.`) and recognise every TLD the fleet uses; archived-site domains and collisions are reported separately.
- **Scan coverage**: standalone (custom-host) sites are marked as WordPress (with a backfill migration and a snapshot self-heal), and the nightly core-checksum run skips sites with no shell transport instead of failing every night.

### Security
- **Upgrader output redaction**: licence keys, tokens and signed download URLs in WordPress upgrader output are redacted before update results are stored, logged, sent to chat or mirrored to Companion (including `clockwork:backfill-companion-action-log --reset-since` re-pushes of older rows).

### Upgrade notes
- Run `php artisan migrate` (new columns on `sites`, `plugin_update_jobs` and `plugin_update_failure_streaks`, and a backfill of `sites.is_wordpress` for standalone Companion sites).
- Run `php artisan clockwork:sync-bill-customers --dry-run` and `clockwork:sync-bill-care-plans --dry-run` and review the output before the next real sync: normalized matching can link `www.` sites and change care-plan flags.
- Refresh held client-report drafts with `php artisan clockwork:recompile-held-client-reports --outdated --dry-run`, then without `--dry-run`. It never sends.
- Set `CLOCKWORK_GTMETRIX_DAILY_CREDITS` to the GTmetrix plan's daily credits (default 10). Optionally set `CLOCKWORK_REPORTS_TIMEZONE`.
- Stored `plugin_update_jobs.messages` and `action_logs.details` rows written before this release still contain unredacted upgrader output; scrub them separately.
- After deploy: `npm run build` (new Tailwind classes in the Client Reports views) and clear the view cache. Report schema 7 marks every unsent report outdated: run `php artisan clockwork:recompile-held-client-reports` (never sends). `/client-reports` now opens the Clients tab; the reports list moved to `/client-reports/reports`. Run `php artisan queue:restart` so workers load the observed-update hook. The app host needs HTTPS access to `api.wordpress.org` for the theme identity check.

## [1.12.0] - 2026-11-04

### Added
Expand Down
11 changes: 9 additions & 2 deletions app/Console/Commands/BackfillCompanionActionLog.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Models\ActionLog;
use App\Models\Site;
use App\Services\ActionLog\ActionLogger;
use App\Services\Companion\ClockworkCompanionClient;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
Expand Down Expand Up @@ -78,9 +79,12 @@ public function handle(): int
->update(['companion_pushed_at' => null]);
}

// Local-only rows (observed or refused updates) never go to the
// client's mirror; ActionLogger doesn't push them either.
$rows = ActionLog::query()
->where('site_id', $site->id)
->whereNull('companion_pushed_at')
->whereNotIn('action_type', ActionLog::LOCAL_ONLY_TYPES)
->orderBy('ran_at')
->limit($limit)
->get();
Expand All @@ -97,7 +101,10 @@ public function handle(): int

foreach ($rows as $row) {
try {
$client->appendActionLog([
// Rows written before upgrader output was redacted still
// hold licence keys and signed download URLs; never
// write those back into the client's wp-admin.
$client->appendActionLog(ActionLogger::forCompanionMirror([
'action_type' => $row->action_type,
'target' => $row->target,
'summary' => $row->summary,
Expand All @@ -108,7 +115,7 @@ public function handle(): int
'actor' => $row->actor,
'care_plan_enabled' => (bool) ($site->care_plan_enabled ?? false),
'ran_at' => $row->ran_at?->toIso8601String(),
]);
]));
$row->forceFill(['companion_pushed_at' => now()])->save();
$okCount++;
} catch (Throwable $e) {
Expand Down
11 changes: 9 additions & 2 deletions app/Console/Commands/CheckSiteUptime.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public function handle(UptimeProber $prober, UptimeStateUpdater $updater, Indexa

$up = 0;
$down = 0;
$blocked = 0;
$seoFailures = 0;
$seoLastError = null;
$start = microtime(true);
Expand All @@ -66,7 +67,13 @@ public function handle(UptimeProber $prober, UptimeStateUpdater $updater, Indexa
$seoFailures++;
$seoLastError = $seoError->getMessage();
}
$probe->succeeded ? $up++ : $down++;
if ($probe->isProbeBlocked) {
$blocked++;
} elseif ($probe->succeeded) {
$up++;
} else {
$down++;
}
} catch (Throwable $e) {
// Synthesize a transport-failed probe and run it through the
// state machine so consecutive_failures still ticks up — a
Expand All @@ -88,7 +95,7 @@ public function handle(UptimeProber $prober, UptimeStateUpdater $updater, Indexa
}

$elapsed = (int) round(microtime(true) - $start);
$this->info("Done. up={$up} down={$down} elapsed={$elapsed}s");
$this->info("Done. up={$up} down={$down} blocked={$blocked} elapsed={$elapsed}s");

if ($seoFailures > 0) {
$this->warn("SEO indexability piggyback failed on {$seoFailures} of {$sites->count()} sites. Last error: {$seoLastError}");
Expand Down
Loading
Loading