Version 1.0.0
Added
- Standard and Pro editions. Standard covers Craft, plugin and server checks, SSL, the dashboard and score, manual scans and diagnostics. Pro adds the security checks, the file-integrity scan, content health, the plain-language guidance, alerting, scan history, scheduled scans, report export, the REST API and multisite. Standard still runs and scores the Pro categories, so the grade is honest and the headline number means something; what it withholds is which header is missing and which file changed. Withholding happens server-side in
helpers/Gate— the values never reach the template, because blurring real data in the DOM is bypassable from developer tools in seconds and shipping that inside a security plugin would be an own goal. One exception, deliberately: a critical integrity finding is shown in full on Standard. That means files no longer match their official release, or executable PHP is sitting in an uploads directory — the person who most needs to know is the one who hasn't paid yet. - JSON report download in the control panel (
Download JSONon the report page). The same payload the console exporter writes, so a support request can include it without shell access — which the customers most likely to need help are least likely to have. Both callers now renderhelpers/Report, rather than assembling the payload separately and drifting apart.
Changed
- Project config drift is reported separately from code, and is no longer treated as tampering.
config/projectis rewritten by any project-config write — editing a section or field layout, changing user group permissions or email settings, installing or toggling a plugin, saving Pulse's own settings. Watching it alongsidetemplates/andmodules/aimed a change detector at a directory whose entire purpose is to change: on any site anyone actually administers it fired within days, docked the health grade, and phrased routine admin work as suspected tampering. The particularly bad case was self-inflicted — changing a setting in Pulse made the next scan report the site as possibly compromised. A warning that fires constantly is one nobody reads, which means it also gets dismissed on the occasion it is real. It is now its own Project Config Changes row: still watched, still naming the changed files, still recorded to history, but informational and unscored. Code that only changes on deploy keeps the scored warning. Migrationm260731_000001re-keys existing baseline rows so upgrading installs don't get one parting false alarm. - The Self-check IP field led with hairpin-NAT, framing it as an edge case. It is effectively required wherever scans run on a schedule — cron has no web request to auto-detect from — so the instructions now lead with that.
- Every check row is now one line at a fixed height. A row's description used to render inline whenever the status wasn't
ok— which includedinfo, so the inventory rows that carry the longest explanations (Database Size, Timezone, OS) were the ones making rows two and three lines tall. Categories full of them stood visibly taller than healthy ones, and the page jumped every time you switched tabs. Descriptions and the "what this means / how to fix it" guidance now share a single (i) control on the row that opens them in place, so a panel is a predictable N × 44px. Values sit in an aligned column, and rows with no Ignore button line up with those that have one. Panels also carry a minimum height so a five-row category like SSL no longer collapses the page. - Craft CMS tab: removed Disk Space, License Status and Search Index; added Post Max Size, Upload Max Filesize and Max Input Vars. The three PHP limits are the ones that actually cause reported faults — and
max_input_varsin particular fails silently, truncating a long entry on save with no error, which is exactly the kind of thing worth surfacing before someone loses content. The Server tab's combined Upload Limits row is gone, since it reported two of the same three values. - Removed the
diskSpaceWarningMbsetting, which existed only to threshold the Disk Space check.
Fixed
- The grade medallion was clipped in the PDF. It centred its letter with
line-height, which depends entirely on the font's ascent and descent, so moving the report to DejaVu dropped the letter far enough for the disc to cut through it — on the first thing a customer sees. It is centred with padding now, which no font metric can move, and declaredcontent-box: dompdf ignoresbox-sizingand always lays out content-box, so a 64px box plus 17px padding became 81px tall — a stadium rather than a circle — while the browser obeyedborder-boxand stayed round. 47 + 17 = 64 square in both. - Arrows in the PDF report rendered as "?". The report's CSS font stack overrode dompdf's
defaultFont, resolving to Helvetica — a PDF core font in Windows-1252, which carries the em dash and curly quotes but has no U+2192. So every "Utilities → Updates" in the remediation text, the part a customer actually follows, came out as "Utilities ? Updates". The stack now puts DejaVu (which ships with dompdf and has the glyph) first when rendering for PDF. - The All Checks tab badge showed only the highest-severity count, so a category mixing a critical with warnings badged "1" there while reading "3" on the dashboard card, tab and panel. Fourth rendering of the same number, now the same rule as the other three.
- A revealed compromise came without any advice on what to do about it. Standard deliberately shows critical integrity findings in full — files no longer matching their official release, or executable PHP in an uploads directory. But the remediation text was gated on the category, and
Guidance::for()only ever sees a category and a check key, never a status, so it stripped the guidance from the very row the exception exists to reveal. The reader was told their site had been modified and then denied the explanation. The decision now sits inGate::redact(), which has the whole row and can tell an always-visible critical from an ordinary locked one. - The Media tab returned a 500 on MySQL 8. The distinct-asset-kinds query inherited an element query's default
ORDER BY elements.dateCreated, and MySQL rejects ordering by a column outside the SELECT list of aDISTINCTquery (error 3065). MariaDB doesn't enforce it, which is exactly why this survived testing on a MariaDB host and would have broken for every customer on MySQL. Fixed by opting out of the default ordering, whichElementQuerysupports by settingorderByexplicitly — including to empty. - The console report exporter bypassed the edition gate entirely.
pulse/report/exporthad no check of its own andReport::build()read the checks table without redaction, so one command dumped every locked security and integrity finding in full. Redaction now happens inside the payload builder, so no caller can leak by forgetting, and the command is gated as well. - Muting an issue was possible on Standard, but the Ignored page wasn't. You could hide a finding with no way to see or undo it. Muting and the integrity baseline reset are now gated, and their buttons no longer render rather than failing when pressed.
- The same unresolved issue opened a new alert every cooldown window. Recording an alert checked "have we alerted recently?" but never "is one already open", so a problem left unfixed accumulated a fresh open row each time the cooldown lapsed — the Alerts page showed "6 open" for four distinct conditions, turning the open count into a measure of how long something had been ignored. There is now one open alert per condition, refreshed rather than duplicated, and each scan collapses any backlog left by earlier versions.
- Alerts for checks that no longer exist could never be closed. Auto-resolve only matched checks present in the scan results, so a renamed or removed check left its alert open permanently — a self-connect false positive from a previous version was still listed weeks later. Resolution now works like result pruning: within a category the scan covered, anything not reported as a problem is closed.
- "Passed" counted informational rows. The stat box read
okCount + infoCount, so inventory readings — OS, timezone, database size, PHP SAPI — were presented as checks that passed, whileScore.phpdeliberately excludes them from the score. The box now countsokonly, and the fourth box reports the informational count instead of the total, so the four sum to the total. - Passing checks were indistinguishable from informational ones on the dashboard category tabs — everything that wasn't a warning or critical rendered the same grey, while All Checks showed the same rows as green.
okvalues are now green there too. - The Latest column was blank for every plugin. Craft only populates
releaseswhen an update exists, so "up to date" and "we never reached the Plugin Store" both rendered as "—", leaving the adjacent "all plugins are up to date" check asserting something the table beside it couldn't corroborate. Up-to-date plugins now show the version they were confirmed against, and an unreachable Plugin Store says "unknown". - SSL Domain Match showed the certificate's common name, so a wildcard or multi-domain certificate displayed the apex domain while its own detail line named the subdomain actually checked. It now reports the host that matched.
- The Server tab and the Security tab appeared to contradict each other about the web server.
SERVER_SOFTWAREdescribes the process running PHP; theServerresponse header describes whatever answers at the edge, and on a proxied stack those are legitimately different names. Both rows now say which layer they describe and point at each other. - A completed scan gave no feedback. The scan confirmation is now written into the progress overlay while it is still on screen, taken from the response payload, and the session flash is set for both the AJAX and plain form-post paths so it also appears after the navigation. Two independent paths, because the single one kept failing in different ways: routing it only through the flash left a full scan silent, and routing it only through sessionStorage +
Craft.cp.displayNotice()cleared the stored message before displaying it, so aCraft.cpthat wasn't ready yet swallowed the message permanently and broke the per-tab confirmation that had been working. - The History table showed raw internal keys (
x_powered_by,cp_url,header_conflicts) where every other page shows a friendly label. - Integrity rows for packages with no upstream to compare against reported their version number in a column of file counts; they now read "not verifiable".
- The Diagnostics page sat blank for about ten seconds. It ran live connectivity probes before emitting any HTML. The probes are now fetched after the page renders, behind a spinner, like the Media tab.
- Media's "Smallest first" sort silently only re-ordered the pre-computed 50 largest files, so it never showed genuinely small assets. The card and the option now say so.
- The Settings schedule panel showed timestamps two hours out, and reported freshly-scanned categories as overdue.
dateCheckedis written with PHP's default timezone, which Craft sets to the site's — so the stored value is site-local wall-clock, not UTC. The new panel parsed it as UTC and added the site's offset. Every view now reads these values in the timezone they were written in, andDashboardController::formatChecked()is the single named place that says so, because the failure mode here is a conversion that leaves all the pages agreeing with each other while all of them are wrong. - The Settings schedule panel rendered good news as an error. Craft styles
.note:not(.tip):not(.warning)as an error — red border, red alert glyph — so "Scheduled scans are running" appeared as a failure. It now uses.note.tip. - The Craft health toggle in Settings still described "disk space" among the things it checks, after that check was removed.
- The GitHub token accepted an environment variable that doesn't exist. It was the one secret with no validator, so
$GITHUB_TOKENsaved happily whether or not the variable was set — and an unset one resolves to an empty bearer, silently dropping the Integrity Scan back to GitHub's 60-request anonymous limit. Caught at save time now, like the other secrets. - Secrets stored as environment variables were sent as the literal
$VARstring. The GitHub Token field advertised env-var support — the control panel's env picker, the$GITHUB_TOKENplaceholder — but the value was read straight off the settings model and handed to GitHub unresolved. Following the field's own placeholder therefore authenticated with the string"$GITHUB_TOKEN", which GitHub rejects, dropping the Integrity Scan back to the 60-request anonymous rate limit without a word about why. The API key, Slack webhook and custom webhook now accept env vars too, and every consumer reads them throughSettings::resolved*()rather than the raw property. Validation resolves first as well, so$PULSE_API_KEYis no longer rejected for being under the 16-character minimum, and a reference that isn't set in this environment is reported as such instead of as a malformed URL. - The settings page was readable by anyone who could see a health score.
DashboardControllermaps each action to a permission and defaults the rest to View health results — and the settings page fell through that default, so any user with view access could read the API key, GitHub token, Slack webhook URL and notification addresses in plain text. Only admins could ever save them (Craft'splugins/save-plugin-settingsrequires it), so this was disclosure with no matching ability to act. It now requires an admin. - The Alerts page decided whether to offer its "send test notification" button from its own copy of the channel test, reading raw settings — so an unset
$SLACK_WEBHOOK_URLcounted as a configured channel and offered a button that could only fail. Both the page and the dispatcher now shareNotificationService::configuredChannels().
Changed
- The Settings page now says whether scheduled scans are actually running. The check intervals are read by
craft-monitor/run/due, which has to be on a cron — they schedule nothing by themselves, and nothing in the control panel said so. The standard failure was a site that looked monitored, had intervals configured, and had not been scanned since the day it was installed. Settings now shows the last scan per category, flags any category more than twice its interval overdue (twice, not once, so the few minutes between an interval elapsing and cron's next tick don't cry wolf), and prints the crontab line for this installation ready to paste. - Settings renders read-only when
allowAdminChangesis off. Craft refuses the save on such sites, but the page still offered a full form and a Save button that could only fail. It now explains that the values belong inconfig/craft-monitor.phpand hides the Save button. Aconfig.example.phpships with the plugin, documenting every key. - The "send test notification" button now also sits in Settings, next to the notification fields it tests, rather than only on the Alerts page. It posts over AJAX because the settings page is one form and HTML has no nested forms; the action answers JSON to an AJAX caller and still redirects for the plain form post on Alerts.
Removed
- The central SaaS dashboard integration is gone.
centralDashboardUrlandenableCentralReporting, along withHealthCheckJob's reporting POST, described a service that doesn't exist — three settings that did nothing except invite questions. TheapiKeysetting stays, because it also guards this site's own health endpoint (/api/craft-monitor/v1/health), which is a real feature; it's now described as what it is rather than as something "generated in the central dashboard".
Added
- Integrity / malware scan (new
integritycategory, on by default, ownintegrityCheckInterval). Four checks:- Craft CMS core and each Composer plugin are verified file-by-file against the official upstream release for the exact commit pinned in
composer.lock. It reads the per-file blob hashes from GitHub's Git Trees API (cached on disk by commit, so repeat scans make no network calls) and reproduces them locally with the git-blob algorithm. Modified or added files (the injected-backdoor signal) are flagged critical for core / warning for plugins; files that exist only in the upstream git tree (tests, CI,.github… stripped from release builds viaexport-ignore) are noted but never alarmed. Packages installed from a local path or a non-GitHub source are skipped cleanly. - Webroot & uploads heuristic: flags executable PHP inside asset/upload volumes (where only media belongs — a hallmark web-shell location) and PHP in the webroot using patterns common to backdoors (
eval,base64_decode,gzinflate,preg_replace /e, …). - Custom-code baseline: snapshots a git-blob hash of your own
templates/,config/, andmodules/(stored in the newcraftmonitor_baselinetable) and flags later changes — the only way to spot tampering of code that has no upstream to diff against. First run establishes the baseline; after a legitimate deploy, reset it withphp craft craft-monitor/integrity/baseline-reset. - Console commands:
craft-monitor/integrity/scanandcraft-monitor/integrity/baseline-reset. OptionalgithubTokensetting raises the GitHub API limit from 60 to 5,000 requests/hour.
- Craft CMS core and each Composer plugin are verified file-by-file against the official upstream release for the exact commit pinned in
- Multisite support. Checks are now split into shared (environment-wide: Craft core, plugins, server — stored once) and per-site (URL/content based: SSL, security, content — run against each site's own URL and stored per site). A site switcher on the Dashboard and All Checks pages scopes the view; scans, the report, and the REST API (
?site=<handle>) all honour the selected site.run/dueschedules each per-site category per site;craft-monitor/run <scope> <siteHandle>targets a site from the console. Adds asiteIdcolumn to the checks/history tables (migrationm260624_000001). - Ignore / undo for issues. Any warning or error can be muted from its row ("Ignore"); ignored issues stop counting toward the health score/grade, drop off the dashboard tabs, and are excluded from alerts. A new Ignored nav tab lists them with an Undo control. Ignores are scoped per site and persist until undone; muting an issue also resolves its open alert. Stored in a new
craftmonitor_ignorestable. - Plain-language guidance on every check (
helpers/Guidance.php): each flagged result now carries a "What this means" explanation and a "How to fix it" step written for non-technical site owners (deep-technical fixes phrased as "Ask your developer to…"). Shown as an expandable note on each problem row in the dashboard detail tabs, and inline under each problem in the printable report. - Plain-English dashboard headline: a one-sentence summary above the overview ("N things need your attention — M urgent", or "Everything looks healthy") plus a short explainer of what the A–F score/grade means.
- A live-site test & verification plan at
docs/TESTING.md— an ordered checklist covering every check, setting, notification, the scheduler, the API, the report, and diagnostics, with expected results and the reasoning behind each. - Connection Diagnostics page (Pulse → Diagnostics): reports the server's network facts and probes every endpoint it can reach itself on — for solving self-connect failures.
- Self-check IP setting +
SelfConnecthelper: works around hairpin-NAT hosting where the server can't reach its own public address. Security, SSL, and GZIP checks now pin the connection to the reachable private IP (auto fromSERVER_ADDR, or set explicitly for cron) while keeping the public hostname for Host header + TLS SNI.
Removed
- Performance (PageSpeed/Lighthouse) and SEO checks are gone, not parked. Both were disabled by default, both depended on a third-party API with its own quota and rate limits, and neither was part of what Pulse is for — health, security and integrity. The services, settings (
enablePerformanceCheck,enableSeoCheck,performanceCheckInterval,performanceStrategy,googleApiKey), guidance entries, settings fields, and the README and test-plan sections describing them have all been removed. Stored results were already dropped by migrationm260624_000002. The git history has them if they are ever wanted back.
Changed
- Scoring no longer counts informational rows, and a critical finding caps the grade at C.
inforesults are inventory rather than health — the OS name, Node version, timezone, which SAPI ran the scan — and counting them as full passes let a long list of neutral facts inflate an unhealthy site's score. They are now excluded from the calculation entirely (a result set of nothing butinfostill scores 100). Separately, anycriticalorerrorfinding now caps the letter grade at C however high the number lands, so a site with devMode enabled in production can never show an A.Score::grade()takes an optional results array to apply the cap; called with just a number it behaves exactly as before. On a representative site this moved the headline from 92 / grade A to 86 / grade C. - Colour pass: the Passed stat box and the A grade now use the same green as the health bar; warnings use
#ffc107(with a darker amber for text/pills so they stay legible). Removed the accent blue entirely — links, the active-tab underline, and the "What this means" toggle are now dark/bold, matching the control panel's neutral chrome. - The overview cards now flow in an even two-column (masonry) layout, so a shorter card no longer leaves a large gap before the next one — spacing between sections is consistent.
- The "What this means / how to fix it" panel is now a subtle inset block instead of a dashed divider.
- Removed the per-plugin license check rows (one "unknown / free" row per plugin); they cluttered the dashboard and weren't actionable. License status still shows in the Plugins inventory table where it has context.
- Design now follows the Craft control panel. The dashboard palette is mapped onto Craft's own CP design tokens (
--link-color,--text-color,--hairline-color,--success/--warning/--error-color, the gray scale), so Pulse inherits the user's theme instead of using its own saturated blue; secondary actions use native Craft.btnelements. The overview now lays out category cards in an even, balanced grid (previously everything stacked in the left column with only SSL on the right, which also rendered SSL twice). - The "What this means / how to fix it" guidance now appears only on actionable rows (warnings/errors), not on purely informational checks like Database or Site URL — there's nothing to fix there.
- Self-connect checks fail fast with the real cURL/socket error surfaced in the result message (and shown inline on dashboard rows).
checks.twignow lists the content and integrity categories (was missing).craft-monitor/run/duenow batches notifications into a single dispatch per cron tick instead of one per due category.- Pulse no longer lists itself in the dashboard Plugins inventory.
Added
- History retention (Settings → History Retention (days), default 90;
0keeps everything).craftmonitor_historyis append-only — one row per check per run — and nothing ever removed from it, which is roughly 490,000 rows a year on a single site at the documented hourly cron, more with several sites. A plugin that ships a database-bloat check should not be the thing bloating the database. Pruning runs automatically after each scan (a single predicate on the indexeddateChecked, so it costs nothing on the usual run where it matches nothing) andphp craft craft-monitor/history/prune [--days=N]clears a backlog deliberately rather than making the next cron run pay for it.craft-monitor/history/statusreports how much history is stored and how far back it reaches. - User permissions (Pulse → View health results, Run health scans, Manage issues, alerts and the integrity baseline). Craft only enforces
accessPlugin-<handle>on non-action requests —craft\web\Applicationskips it when$request->getIsActionRequest()— so the control-panel pages were gated while every POST endpoint was reachable by any logged-in user regardless of their permissions. That included muting security issues, resolving alerts, firing test notifications, burning the GitHub API quota, and resetting the file-integrity baseline that tamper detection compares against.DashboardController::beforeAction()now maps each action to a permission and defaults unlisted actions to View, so a new action is gated unless it deliberately opts down. Admins and Solo installs are unaffected (Craft'sUser::can()returns true for both); on Team/Pro, non-admin users need the new permissions granted. - When the public address reaches the wrong server, Pulse now tries other ways in instead of giving up. Detecting a wrong server was only half the job: the route was chosen by what answered first, so on hosting whose public address resolves internally to another vhost, the first attempt "succeeded" against a stranger and the remaining routes were never tried — leaving the whole Security category unverifiable on a site that was perfectly reachable by other means. Each candidate route is now confirmed with the identity challenge before being adopted, and a wrong answer moves on to the next rather than ending the search. Candidates, in order: the public address, a configured Self-check IP, the server's own address, and loopback — the last of which is what usually works on panel hosting, where the site answers on 127.0.0.1 with the right Host header and SNI even when its own domain does not resolve helpfully from inside. When a route other than the public address is used, the report says which one and why.
- The security checks now prove they reached this site before believing what it says. A 200 from the site URL only means a web server answered, not that yours did. On hosting where the server resolves its own domain to a different vhost — split-horizon DNS, or an internal proxy layer — Pulse was reading a stranger's response and reporting it as the site's own: every security header "missing" on a site that sets all of them correctly,
openrestyreported as the web server, and "no HTTPS redirect" on a site that redirects. A confidently wrong security report is worse than none. The fetch now asks the site to echo a token derived from Craft's security key by HMAC; when the answer doesn't come back, the header checks report "wrong server answered" and are marked unverified instead of reporting another machine's settings. The endpoint replies only to a caller that already knows the token, so a passer-by gets a 404 and cannot even tell Pulse is installed. - Repeated headers are no longer collapsed to the last value, and conflicting ones are reported. The header parser stored one value per name, so a security header sent twice — an
.htaccessand a proxy in front both setting it, which is common — had the last value reported while browsers use the first (RFC 6797 §8.1 for HSTS). Found on a real site sendingStrict-Transport-Securitytwice:max-age=31536000; includeSubDomainsfrom.htaccessandmax-age=15768000from the server in front. Pulse reported the weaker six-month policy that no browser was applying. All values are now kept, accessors take the first, and a new Conflicting Security Headers check (header_conflicts) reports any security header sent more than once with differing values, naming both values and how browsers resolve them. Separate Print and Download buttons on the report. The single "Print / Save as PDF" button only ever opened the browser's print dialog, where saving a PDF is a destination buried in a dropdown rather than something the button does — so it printed and never downloaded. Printing and downloading are now distinct actions, and the download produces a file.
Pulse still takes no PDF dependency. Every published version of dompdf currently carries security advisories and Composer 2 blocks advisory-affected packages by default, so requiring one would fail to install on a default setup and put a package with an open advisory list inside a plugin whose job is reporting on security. Instead the download uses a PDF engine when the site already has one — dompdf ships with both Craft Commerce and Formie, so a large share of Craft sites have it — and falls back to a self-contained HTML file otherwise, which still opens and prints anywhere. The button labels itself accordingly. The report header was rebuilt as a table because dompdf implements CSS 2.1 and has no flexbox, so a single template now renders correctly in both the browser and the PDF.
- Certificate trust check (
ssl_trust). The existing certificate checks read the certificate with peer verification off — necessary, since you have to be able to fetch a bad certificate to explain what is wrong with it — but nothing then asked whether a browser would actually accept it. Dates and domain coverage can be perfect on a self-signed certificate, and Pulse reported one as "valid": verified againstself-signed.badssl.comanduntrusted-root.badssl.com, both of which passed every existing check. A second connection now verifies the chain against the system CA bundle and reports critical when it doesn't lead to a trusted root, with the OpenSSL reason captured from the warning PHP would otherwise swallow. Name matching stays withssl_domain, so a failure here isolates trust alone.
Fixed
- A report-only Content-Security-Policy is no longer reported as no policy at all. The check looked only for
Content-Security-Policy, so a site publishingContent-Security-Policy-Report-Only— the documented way to roll a policy out, where you collect violation reports before enforcing — was told the header "is not set". That told sites which had done most of the work that they had done none of it, and buried the one step they actually still needed. Report-only is now reported as its own state: still a warning, because nothing is being blocked yet, but with the accurate reason and the actual next step. Found by running the checks against a real production site. - Passing checks can carry details again.
HealthCheckResult::ok()was the only factory without a$detailsparameter, and PHP discards surplus arguments to a userland function rather than erroring — so the checks already passing a details array to it had it silently thrown away.php_extensions(whose comment reads "the full list rides indetailsfor the expandable display in the dashboard"),integrity_coreandssl_domainall storedNULL, meaning the dashboard's expandable detail never appeared on any check that passed. - The webroot scan no longer hides how much it skipped. Its 20,000-entry budget counted every filesystem entry rather than PHP files, so on an asset-heavy webroot it was spent on images and arbitrary PHP files went unexamined — silently, with the check still reporting "clean". The budget is now 200,000 entries, hitting it downgrades the result from
oktoinfo("partly checked") instead of a clean bill of health, and the passing message states that it pattern-matches the first 256KB of each file. Symlinked files are scanned too: a symlinked web-shell in an upload directory is exactly as dangerous as a copied one. - The required-extensions check no longer passes a server Craft cannot run on. It checked 11 extensions; Craft 5 requires more.
bcmathandiconvwere both missing from the list and are separate packages on common distributions, so a server withoutphp-bcmathwas told "all required PHP extensions are loaded". Also verifies a usable PDO driver is present —pdoalone says nothing about whetherpdo_mysql/pdo_pgsqlis installed. (ReflectionandSPLare required by Craft but deliberately not checked: they cannot be disabled in any real PHP build.) - The database check now validates the version instead of just printing it. Craft 5 requires MySQL 8.0.17+, MariaDB 10.4.6+ or PostgreSQL 13+; an unsupported server previously produced an
inforow indistinguishable from a healthy one. MariaDB is detected by version string rather than driver name, since it reports through the MySQL driver and would otherwise be judged against MySQL's threshold. - Integrity checks that failed to verify now warn instead of reporting
info.infois excluded from scoring, so a package Pulse tried and failed to compare against its official release cost nothing. A package with nothing to compare against — installed from a local path or a non-GitHub source — is a property of how it was installed rather than a failure, and staysinfo. - The timezone check compared a value against its own source. Craft sets PHP's timezone from its own setting during bootstrap, so
date_default_timezone_get()always equalledCraft::$app->getTimeZone()and the row could never report anything but a match. It now readsphp.inidirectly, which is a value Craft did not just write. (On this install that turns "PHP: Europe/Zurich | Craft: Europe/Zurich" into the real "Craft: Europe/Zurich | server: Europe/Bucharest".) - API key comparison is now timing-safe (
hash_equalsrather than!==, which short-circuits on the first differing byte) and the key must be at least 16 characters. It is the only thing protecting an endpoint that returns server paths, PHP configuration, database size and the plugin inventory. - Notification emails and the self-check IP are actually validated. Both were accepted as bare strings, so a typo only surfaced later as a notification that silently never arrived, or a self-connection check failing for no visible reason. The email field is validated per-address, since it is a comma-separated list.
- Replaced the deprecated
getEditionName()with theCmsEditionenum onCraft::$app->edition; the old call is deprecated as of Craft 5.0 and removed in Craft 6. - Symlinks no longer slip past the integrity scan.
hashTree()skipped symlinks, and a skipped entry has no local hash — sodiff()classified it as removed, which the scan deliberately treats as normal export-ignore noise rather than tampering. Replacing a tracked file (or a whole directory) undervendor/with a symlink to an attacker's script therefore produced "All on-disk files match the official release" while PHP's autoloader followed the link and executed it. Symlinks are now hashed the way git records them — a blob containing the target path, so a legitimate upstream symlink still matches byte-for-byte — which makes a swapped file surface as modified and a swapped directory as added. Verified end-to-end against both attacks and against the realcraftcms/cmsandverbb/formietrees (no false positives); covered bytests/unit/IntegrityHashTest.php. - A site Pulse cannot reach no longer scores better than one it fully inspected. When the security checks could not fetch the site,
SecurityService::runAll()returned a singleinforow in place of its twelve checks. That removed those checks from the total as well as from the problems, so every issue they would have reported silently disappeared — on a real install an unreachable site scored 86 against 85 for the same site inspected properly. The checks now keep their keys and report "could not verify" as warnings, so an unmeasurable site costs more than a measured one (the same site now scores 78 unreachable vs 86 reachable).checkHttpsRedirect()andcheckAdminCpUrl()still run for real in this state, since neither needs the homepage fetch. The row set lives inhelpers/UnverifiedChecks.phpand is covered bytests/unit/UnverifiedChecksTest.php, including an assertion that the unreachable set can never outscore an inspected one. - The HTTPS-redirect check no longer passes a test it could not run.
checkHttpsRedirect()built its own cURL handle and skipped theSelfConnectpinning every other security check uses, so on hairpin-NAT hosting (the exact caseSelfConnectexists for) it could not reach port 80 — and it read that failed connection as proof the site was HTTPS-only, returning ok. A site serving plain HTTP with no redirect therefore passed its own security check. The probe now tries the public address first and then the self-connect IP, and an unreachable port 80 reports "could not verify" as a warning instead of a pass; the exception path returns a warning rather thaninfo(which scores as a full pass). The verdict logic moved tohelpers/HttpsVerdict.phpand is covered bytests/unit/HttpsVerdictTest.php. - Server and PHP checks no longer report the cron process as if it were the website. Run from
php craft craft-monitor/run— the documented production setup —php_sapi,php_memory,php_max_exec,php_opcache, andweb_serverall measured the short-lived CLI process, so a healthy site showed "Web server: Unknown", "OPcache hit rate: 0%", and a memory limit of-1, and the values flipped every time a dashboard scan overwrote a cron scan.web_servernow asks the site over a self-connect-pinned request (reporting the real server from cron), OPcache reports "not measurable from cron" instead of a meaningless 0% hit rate, and the SAPI/memory/execution-time checks state which process they measured and point to a dashboard scan for the web values. - OPcache no longer raises a false "disabled" warning from cron. Most hosts ship
opcache.enable_cli=0, so every scheduled scan warned that OPcache was off even when it was enabled and working for the website. - Alerts are recorded even when no notification channel is configured.
dispatch()returned early when no email/Slack/webhook channel was set up, before writing the alert row — and the default install hasemailNotificationson with no recipients, so it never wrote one. The Alerts page, its acknowledge/resolve workflow, and the alert history were therefore permanently empty out of the box. Recording and delivering are now separate: alerts are always stored (with a nullchannelwhen nothing was delivered), and channels are only used for sending. - PHP memory limit reads "unlimited" instead of
-1. The sibling max-execution-time check already spelled it out; this one showed the raw value. - Notification channels now detect delivery failure. The Slack/custom-webhook sender fired the HTTP request and discarded the result, so a wrong URL, DNS failure, or a non-2xx response (404/500) was silently treated as success — and the "Send test notification" button always reported the channel as sent even when nothing arrived.
_postJson()now throws on a transport error or non-2xx status, each channel reports whether it actually delivered, and the test button tells you exactly which channels succeeded and which failed (with the reason logged). - Alerts are now scoped per site. The cooldown and auto-resolve logic keyed only on
(category, checkKey), so on a multisite install the same per-site check (SSL/security/content) failing on two sites collided: one site's alert suppressed the other's cooldown, and a recovery on one site resolved the other's open alert. Adds asiteIdcolumn tocraftmonitor_alerts(migrationm260625_000001) and scopes the cooldown, auto-resolve, and ignore-clears by site. Existing open alerts are closed by the migration so the new scoping starts clean. PluginHealthService: reading$plugin->namefor an outdated-but-disabled plugin could hit a property-access-on-null warning (the??did not suppress it); now uses$plugin?->name.SslService: a certificate whose validity dates can't be parsed now reports an explicit "could not read" error instead of a bogus "EXPIRED / 1970-01-01";openssl_x509_parse()returningfalseis normalised to a clean connection failure rather than relying on a swallowedTypeError.ServerService: the "upload_max_filesize is low" warning no longer fires when the directive reads as0/empty (added the> 0guard its sibling check already had).SecurityService: an emptycpTrigger(control panel served from the web root) is no longer mis-reported as a hardened custom path.
Version 1.0.0-beta.1
Added
- Performance checks (Phase 4): Google PageSpeed Insights / Lighthouse — category scores (Performance, Accessibility, Best Practices, SEO), Core Web Vitals lab metrics (LCP, CLS, TBT, FCP, Speed Index, TTI), and a real-user CrUX summary when available. Disabled by default; governed by
performanceCheckInterval, the optionalgoogleApiKey, and a mobile/desktopperformanceStrategy. Local/private sites are detected and skipped cleanly (PSI can't reach them). - SEO checks (Phase 4): homepage title, meta description, canonical, robots/noindex, single H1, viewport, html lang, Open Graph, plus robots.txt and XML sitemap presence.
- Content health checks (Phase 5): draft / revision / trashed-element bloat, disabled-entry and empty-section reporting.
- Printable health report (Phase 5) at
craft-monitor/report— score + all checks, styled for browser Print → Save as PDF. - Alerts page with acknowledge/resolve actions; alerts auto-resolve when a check recovers; "Send test notification" button.
- Console command for cron-driven scans:
craft-monitor/run [scope]andcraft-monitor/run/due(runs only categories whose configured interval has elapsed). The*CheckIntervalsettings are now functional. - Working alert dispatch (email / Slack / custom webhook) for problem results, with per-check cooldown; alerts are recorded in
craftmonitor_alerts. - Disk space health check (uses the
diskSpaceWarningMbthreshold). - Unit tests (PHPUnit) for the scoring helper and byte formatting;
composer testscript. - English translation scaffold (
translations/en/craft-monitor.php).
Changed
- Scheduled (queue) and manual scans now share one code path (
CheckRunnerService→ResultStorageService), so both store identically and both prune stale checks. - CP dashboard and REST API now use one scoring implementation (
helpers/Score), so their scores/grades always match. - The Pulse "Settings" subnav now links to Craft's native plugin-settings page (the previous in-plugin page rendered fields with no save form).
- SSL: validity and expiry merged into a single check (no more double-critical/double-penalty for an expired cert); custom HTTPS ports are honoured.
- Plugin inventory counts installed-but-disabled plugins; Pulse no longer reports on itself.
- Relabeled the "Composer" check to "Composer Lockfile" (it reports the plugin-API version, not the Composer binary version).
Fixed
- SSL wildcard matching no longer false-positives (e.g.
*.example.commatchingx.notexample.com).
Version 1.0.0-alpha.1
Added
- Initial plugin scaffold (Phase 1)
- Craft CMS health checks: version, PHP, devMode, security key, queue, deprecations, memory, extensions, disk space, directories, database, sessions, search index
- Plugin health checks: installed list, updates, license status
- Server checks: web server, OS, PHP SAPI, OPcache, upload limits, timezone, Composer, Node.js, image driver
- SSL certificate monitoring: validity, expiry, domain match, chain, TLS version
- Security checks: HSTS, X-Frame-Options, CSP, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, server header, X-Powered-By, .env exposure, backup files, admin CP URL, HTTPS redirect
- Uptime monitoring: reachability, response time, uptime percentage, history
- CP dashboard with health score (A–F), category breakdowns, and check details
- All Checks detail view
- History view with uptime pings and check log
- Full settings page with feature toggles, intervals, thresholds, and notification config
- REST API endpoints for central dashboard integration
- Queue job for scheduled health checks
- Database migration with 4 tables: checks, history, uptime, alerts