porro / php-core
Shared core for Porro connectors: site keys, signing, lock-file reading, sending reports and local site health checks.
Requires
- php: ^8.2
- composer-runtime-api: ^2.2
- composer/semver: ^3.4
- maxmind-db/reader: ^1.11
- paragonie/sodium_compat: ^1.21|^2.0
Requires (Dev)
- laravel/pint: ^1.18
- pestphp/pest: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.3.6
- v0.3.5
- v0.3.4
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.1
- v0.2.0
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- dev-probe-sources/update
- dev-claude/coding-standard
- dev-claude/supply-chain-hardening
- dev-claude/readme-batched-writes
- dev-claude/batched-activity
- dev-claude/recorder-hardening
- dev-claude/probe-sources-sync
- dev-claude/ip-summary
- dev-claude/docker-mlflow-probes
- dev-claude/account-tokens
- dev-claude/stable-nginx-path
- dev-claude/nginx-and-protected
- dev-claude/protect-crawlers
- dev-claude/nginx-easier
- dev-claude/ip-details
- dev-claude/nginx-status
- dev-claude/cloudflare
- dev-claude/activity-core
- dev-claude/site-health-fixes
- dev-claude/report-database
- dev-claude/site-health
- dev-server-packages
- dev-server-os
This package is auto-updated.
Last update: 2026-11-09 16:41:05 UTC
README
Shared core for the Porro connectors: parsing site keys, signing reports, reading composer.lock, sending reports to Porro and the site health checks the connectors show in the control panel.
You don't install this directly. It comes with a connector:
| Connector | For |
|---|---|
porro/laravel-connector |
Laravel 11–13 |
porro/filament-connector |
Filament 3–5 |
porro/statamic-connector |
Statamic 5–6 |
porro/craft-connector |
Craft 4–5 |
How requests are signed
A site key looks like porro_{site id}_{seed}. The seed is the private half of an Ed25519 key pair; Porro stores only the public half. Each request to Porro (a report, or reading findings) is signed over:
connector-v1\n{METHOD}\n{path}\n{timestamp}\n{nonce}\n{sha256 of body}
for example connector-v1\nPOST\n/api/v1/reports\n…, so a signature only works for the endpoint it was made for. It's sent with X-Porro-Site, X-Porro-Timestamp, X-Porro-Nonce and X-Porro-Signature headers. Porro rejects a request outside a 5-minute window or if it has seen the nonce before. Before 0.2.1 the signature was over {timestamp}\n{nonce}\n{sha256 of body} alone; Porro still accepts that for reports for now, but not for findings. If ext-sodium isn't available, paragonie/sodium_compat provides it in pure PHP.
What a report says about the server
Each report includes a system block, so Porro can flag an end-of-life OS and match Ubuntu and Debian security notices against the server's installed packages:
os:ID,VERSION_ID,VERSION_CODENAMEandPRETTY_NAMEfrom/etc/os-releasekernel: the running kernel (php_uname('r'))reboot_required: whether/var/run/reboot-requiredexists (Debian and Ubuntu)fingerprint: an HMAC-SHA256 of/etc/machine-idunder a Porro-specific key, so sites on one server share one record. The machine id itself is never sent.packages: the installed packages from/var/lib/dpkg/status(statusinstall ok installedorhold ok installed), each withname,version,source(the source package Ubuntu and Debian notices name; the package's own name when it has noSource:field) andarch, plussource_versionwhen the source's version differs from the package's, andoriginwhen the package declares onepackages_hash: a SHA-256 of that list. The list itself is only sent when Porro asks for it, because the hash is new to it (the same idea as thecomposer.lockhash):Client::send()seesneed_system_packagesin Porro's reply and sends the report once more with the list, so connectors don't need to do anythingunattended_upgrades.enabled: whetherAPT::Periodic::Unattended-Upgradeis set to anything but"0"in/etc/apt/apt.conf.d(20auto-upgradeson a stock server) and theunattended-upgradespackage is installedunattended_upgrades.last_run_at: when it last started, from the end of/var/log/unattended-upgrades/unattended-upgrades.log, in ISO 8601 with the server's offset. That log is often readable by root only, in which case this isnull
These are plain file reads, with no exec. Anything the PHP process can't read, such as files outside open_basedir on shared hosting, is left out: null, or false for reboot_required.
Site health checks
Porro\Core\Health runs the checks behind each connector's Site health panel, on the site itself and without a Porro account:
EndOfLife: PHP, Laravel, Craft and Statamic versions past, or within 90 days of, the end of security support. Dates come from endoflife.date, fetched once a day, with a copy indata/endoflife.jsonfor when it can't be reached.Advisories: known security advisories for the packages incomposer.lock, from Packagist's security advisories API (the onecomposer audituses). Only package names are sent; versions are matched on the site withcomposer/semver. High and critical advisories rank like Porro's "Act now"; low and medium ones, and anything in a dev dependency, as notices.Updates: the newest release of a few key packages on the major version the site runs, from Packagist.ReportChecks: debug mode in production, failed jobs, pending migrations, a stuck queue, stale backups, failing health checks and whether cron runs, from what the connector already collects. The same rules Porro runs on each report.PorroFindings: once connected, the site's open findings in Porro and its server's, read fromGET /api/v1/findingswith a request signed by the site key (over an empty body), cached for five minutes per Porro URL and key. Local issues are only left out when Porro has an open finding of the same kind.SiteHealthputs them together for the panel, leaving out local issues Porro already raises once its findings are in.
Network results go through the connector's own cache via Sources (failures for ten minutes), so a control panel page rarely waits on the network and an unreachable API never slows every page.
Suspicious activity and blocking
Porro\Core\Activity is what each connector's Activity and Blocking panels are built on. It works on the site alone, and IP addresses never leave it.
ClientIp: the visitor's address. Uses the framework's own value, which respects the site's trusted proxies, and believesCF-Connecting-IPonly when the request came from a Cloudflare range.Probes: paths scanners ask for that no visitor of a Laravel, Craft or Statamic site does. These are dotfiles and secrets, VCS folders, WordPress, database admin tools and known exploits. Paths the app really serves can be excluded.Recorder: counts probes, failed logins, 404 bursts and form floods in the connector's store.- Counts go into hourly buckets kept for 48 hours.
- Only those events are recorded, so ordinary page views cost nothing, and every write fails open.
- Usernames are kept only as keyed hashes.
- Given a
Buffer, a request never writes to the store: it appends one line to a file on its server, andflush()records the lines in batches, with one read and one write per IP and hour and one increment per total. Counts stay exact during a flood. Connectors flush from their scheduler, now and then after a suspicious request, and before a page reads activity.
Buffer: the per-server file behind batched writes.- Each line is appended under an exclusive lock, already cut short and with the username hashed.
- The file is capped at 2 MB. Past that, a request only adds a byte to its hour's count, so a flood can't fill the disk and the totals still add up.
- A flush renames the file before reading it, so flushes can run at the same time, on one server or several, without counting a line twice. An optional lock keeps two servers from updating the same records at once.
- Where the folder can't be written, the
Recorderwrites straight to its store, as before. summary()returns the flagged IPs, worst first, and the issues for the panel. Those cover a file such as/.envanswering 200, IPs probing or trying passwords, and a site that can't see visitor IPs because every request seems to come from a proxy.
ProtectedRanges: addresses that are never blocked or flagged.- These are Cloudflare, Laravel Forge and its health checks, private and reserved ranges, and the site's own config.
- Published lists are fetched once a day.
- A response that isn't a clean list of addresses is thrown away whole, and the copy bundled with the release is used instead.
Blocklist: the blocklist and safelist and their safety rules.- It refuses blocks that would break the site: protected ranges, the admin's own IP, anything safelisted, ranges wider than /16 (IPv4) or /48 (IPv6), and more than 5,000 entries.
prune()drops expired entries and blocks that have since become protected or safelisted.- Connectors store entries through
EntryStore, a database table, so every server of a site sees the same list.
Countries: a rough country for each flagged IP.- Behind Cloudflare it uses the
CF-IPCountryheader the request already carries (ClientIp::country()), so there's no lookup at all. - Elsewhere it reads DB-IP's free country file on the site: about 4MB, downloaded monthly by
update(), CC BY 4.0. Panels showing these countries must credit DB-IP (Countries::ATTRIBUTION).
- Behind Cloudflare it uses the
Cloudflare: blocking at Cloudflare's edge.connect($host)finds the zone serving the site (walking up from a subdomain) and finds or creates the account'sporro_blocklistIP List. It then adds one custom rule,ip.src in $porro_blocklist, next to the zone's existing rules, never by replacing the phase's ruleset.sync()adds and removes only items whose comment startsporro:{site id}:, and waits for Cloudflare's background operations to finish.- The list is account-wide, so other sites' items are left alone.
- An IP another site already added counts as blocked.
- Nothing ever replaces the whole list.
disconnect()removes this site's items and rule. It deletes the list only once it's empty.- Token permissions: Account Filter Lists Edit, Zone WAF Edit, Zone Read.
NginxStatus: how far one server's nginx setup has got, found by looking and never by running anything.- Written: the deny file exists.
- Included: a file under
sites-enabled,conf.dorforge-confincludes it. Commented-out lines don't count, and it's unknown when the config can't be read. - Reloaded: the reload job's stamp,
/var/tmp/porro-nginx.stamp, is newer than the file. reloadJob()is the root scheduler command that does the reloading.
IpDetails: who's behind a flagged IP, cached a day per IP. None of these sources sends the IP anywhere new.- Network and country: the network, its type and any risk categories come from Cloudflare (Intel, else Radar) when the connector has it. Cloudflare already handles the site's traffic.
- Country fallback: DB-IP's file on the site.
- Badges: Tor exit and Spamhaus DROP, from lists downloaded whole and checked locally.
- Reverse DNS: kept only when it resolves back to the same IP. It spots a fake Googlebot (the user agent claims Google, the address isn't Google's) and research scanners such as Censys and Shodan.
AbuseIpdb: AbuseIPDB's confidence score and report count for one IP. It sends the IP to AbuseIPDB, so panels only run it when someone clicks.NginxBlocklist: renders thedenyfile for nginx, plus the Cloudflare real-IP lines that must come before it. It writes the file atomically and only when the list changed.
What a report says about the database
Connectors describe the site's default database connection with Database::describe($driver, $serverVersion, $host) and pass it to Report::database(), so Porro can flag an end-of-life MySQL, MariaDB or PostgreSQL:
engine:mysql,mariadb,pgsql,sqliteorsqlsrv. PDO calls MariaDBmysql; it's told apart by its version stringversion: just the number, e.g.10.11.6from5.5.5-10.11.6-MariaDB-0ubuntu0.24.04.1or16.4fromPostgreSQL 16.4 (Ubuntu…) on x86_64…local: whether the database is on the same machine (the default host,localhost, a loopback address or a unix socket).nullfor SQLitemanaged: the service's name when the host is a known managed database (Amazon RDS, DigitalOcean, Laravel Cloud, Azure, PlanetScale, Neon, Supabase, Google Cloud SQL)
The host, database name, user and credentials are never sent. If the connection can't be read, database is null.
Reviewing probe candidates
Every Monday .github/workflows/probe-sources.yml runs tools/probe-sources.php. It refreshes resources/probes/candidates.json with paths that public sources list and Probes doesn't match yet, then opens or updates one pull request, "Probe sources: ". Each path has a suggested category and a link to where it came from. The sources are:
- OWASP Core Rule Set (Apache-2.0), latest release:
restricted-files.dataandlfi-os-files.datafor paths. It also bringsscanners-user-agents.data, kept for a future user-agent check. - CISA Known Exploited Vulnerabilities: CVEs added in the last 90 days, with request paths from the matching ProjectDiscovery nuclei template (MIT).
Nothing reads the candidates at runtime, and the workflow never edits Probes::PATTERNS or merges anything. A false positive flags real visitors and can suggest blocking them, so a person moves each path in by hand:
- Check it against the must-not-match list (
it('leaves real pages, routes and assets alone')intests/ActivityTest.php). Drop anything a Laravel, Craft or Statamic site could really serve: login and account pages (/login,/user/sign_up,/customer/account/create), short API routes (/api/v1/...), single generic words, and CRS entries that are substrings rather than paths (.bash_,composer.json). Expect to drop most candidates. CRS and nuclei describe attacks on every kind of server, not just these sites. - Add a test. Put the probe in the must-match list with its category. Add the nearest real-site lookalike to the must-not-match list, e.g.
/shop/containersnext to/containers/json. - Move it into
Probes::PATTERNSas a fixed shape anchored where the target lives (^/...\z), in the category that fits. The suggestion is only a guess. Runcomposer test.
Do the moves in their own PR. Merge the candidates PR unchanged: the committed file is the baseline, so the next one lists only what changed. Paths now matched by Probes drop out of the file on the next run.
Run it by hand with php tools/probe-sources.php --summary=body.md. A GITHUB_TOKEN in the environment lifts GitHub's API rate limit.
The workflow has two jobs. The job that downloads the sources and runs PHP holds only a read-only token. The job that opens the PR runs no PHP or Composer. It commits only candidates.json. For it to open PRs, do one of these:
- Turn on "Allow GitHub Actions to create and approve pull requests" under Settings → Actions → General.
- Add a
PROBE_SOURCES_TOKENsecret. Make it a fine-grained personal access token scoped toPorrodev/php-coreonly, with Contents and Pull requests set to Read and write and nothing else, and a short expiry. With this token,tests.ymlalso runs on the PR. PRs opened with the default token don't trigger other workflows.
Developing
composer install
composer test
Security
Please email security@porro.dev rather than opening an issue. We reply within one working day.
License
MIT