# Excubia documentation (full) Documented excubia-core version: 1.0.0-beta Site: https://docs.qiick.io/excubia --- # Introduction Source: https://docs.qiick.io/excubia **Excubia** is an open-source (MIT) health check for Laravel apps, part of the [Qiick](https://qiick.io) suite. The Composer package [`qiick/excubia-core`](https://packagist.org/packages/qiick/excubia-core) reads your lockfiles and manifests and reports, in one place: PHP / Laravel / Node end of life, known vulnerabilities, abandoned and stale packages, blockers for the next Laravel major, and a suggested upgrade order. Run through Artisan, it also checks the posture of the booted app (debug mode, caches, migrations, failed jobs, scheduler, maintenance mode). Optional, and off by default: push the report to your own self-hosted [`excubia-app`](https://github.com/qiick-io/excubia-app), send runtime aggregates and masked exceptions to it, or serve an Oh Dear-compatible health endpoint. Nothing is sent to Qiick or any third party. > **Status:** 1.0.0-beta. The public contracts (CLI options, report JSON, check ids, exit codes, `excubia.json`, push payloads, health endpoint, SARIF ids, Action inputs) are frozen for 1.0.0-beta: [`docs/CONTRACTS.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/CONTRACTS.md). See [`docs/ROADMAP.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/ROADMAP.md) and [`docs/VERSIONING.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/VERSIONING.md). ## Requirements | Requirement | Notes | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | PHP `^8.2` | To run Excubia. The scanned project may use any PHP version. | | Composer 2 | `composer.lock` is required in the scanned project. | | Laravel 12 or 13 (optional) | Only for the Artisan commands and the opt-in features. Laravel 11 is not supported for the Artisan integration (D19); the standalone CLI still scans Laravel 11 projects and reports them as end of life. | Any Composer project can be scanned. Laravel-specific checks are `skipped` when `laravel/framework` is not locked. ## Quick start ```bash composer require --dev qiick/excubia-core vendor/bin/excubia check ``` `vendor/bin/excubia` does not boot your app and never runs project code. In a Laravel app the package is auto-discovered, so the same checks plus the six posture checks run with: ```bash php artisan excubia:check ``` Install without `--dev` (`composer require qiick/excubia-core`) when the deployed app runs Excubia itself: the scheduled push, runtime monitoring, exception capture, the health endpoint or posture checks in production. While those opt-ins are off, the package registers four Artisan commands and one every-minute scheduled heartbeat, and nothing else. More examples: ```bash vendor/bin/excubia check --format=json --output=report.json vendor/bin/excubia check --format=markdown --output=report.md vendor/bin/excubia check --fail-on=critical vendor/bin/excubia check --only=php-eol,composer-advisories vendor/bin/excubia check --skip=node-eol --no-dev vendor/bin/excubia check ../other-project --offline vendor/bin/excubia cache:clear vendor/bin/excubia --version ``` ## Checks | ID | Name | Data source | Fails or warns when (severity) | | ----------------------------- | -------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `php-eol` | PHP end of life | endoflife.date | Security support ended (critical); active support ended or security ends within 180 days (medium) | | `laravel-eol` | Laravel end of life | endoflife.date | Security fixes ended (critical); bug fixes ended or security ends within 180 days (medium) | | `node-eol` | Node end of life | endoflife.date | EOL (high); maintenance ends within 180 days (low). Skipped when no Node version is declared | | `composer-advisories` | Known vulnerabilities (Composer) | Packagist security advisories | An advisory affects the locked version (severity from the advisory) | | `npm-advisories` | Known vulnerabilities (npm) | OSV.dev | A vulnerability affects a version in `package-lock.json` (severity from OSV, default medium) | | `composer-abandoned` | Abandoned packages | Packagist | Abandoned direct dependency (high); abandoned transitive dependency (medium) | | `composer-stale` | Stale packages | Packagist, GitHub | Repository archived (high); no release for more than 24 months (low) | | `laravel-next-blocker` | Next-major blockers | Packagist | The latest release of a direct dependency does not allow the next Laravel major (medium), or only in a pre-release (low) | | `laravel-debug-mode` | Debug mode | Booted app | `app.debug` on outside `local` / `testing` (high) | | `laravel-production-caches` | Production caches | Booted app | Config, route or event cache missing in `production` (low, one row per cache) | | `laravel-pending-migrations` | Pending migrations | Migrator | Migrations have not run (medium) | | `laravel-failed-jobs` | Failed jobs | Failed job provider | Failed jobs exist (medium; high once the oldest is 24 hours old) | | `laravel-scheduler-heartbeat` | Scheduler heartbeat | Default cache store | The scheduler has not run for more than 10 minutes, or never (medium) | | `laravel-maintenance-mode` | Maintenance mode | Booted app | The app is in maintenance mode (medium) | - The six `laravel-*` posture checks after `laravel-next-blocker` need the booted app, so only `php artisan excubia:check` and `excubia:push` run them. `vendor/bin/excubia` reports them as `skipped` ("requires Artisan"). - For `laravel-scheduler-heartbeat`, the service provider schedules `php artisan excubia:heartbeat` every minute. It stores the Unix time under the cache key `excubia:scheduler-heartbeat` in the default cache store. Keep the usual `* * * * * php artisan schedule:run` cron (or `schedule:work`), and use a shared cache store (database, Redis) when the scheduler and `excubia:check` run on different machines. - Every report ends with an ordered upgrade plan (`upgrade-plan`): security fixes, runtime EOL, blockers, Laravel major, replacements. It is `info` only, never changes the exit code and cannot be selected with `--only` / `--skip`. - Thresholds (180 days, 24 months, 10 minutes, 24 hours) are configurable in [`excubia.json`](https://docs.qiick.io/excubia/configuration#checks). Full severity rules: [`docs/SPEC.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/SPEC.md) section 3. ## Formats | `--format` | Output | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `table` (default) | Human-readable, grouped by severity, coloured on a TTY (not with `NO_COLOR` or when writing to a file), summary line and upgrade plan | | `json` | Versioned report, `schemaVersion` `1`, schema in [`docs/report-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/report-schema.json). Parse this, not the text formats | | `markdown` | For pull requests, job summaries and AI agents | | `sarif` | SARIF 2.1.0 for GitHub code scanning, see [CI guide](https://docs.qiick.io/excubia/ci#sarif) | In the JSON report every `skipped` finding has a `skipReason`: `data-source-failure`, `offline` (`--offline` found no cached response) or `not-applicable` (the check does not apply or is disabled). The JSON report also carries, as optional fields of schema version 1: - top level: `generatedAt` (UTC, `Y-m-d\TH:i:s\Z`), `excubiaVersion` (the value of `--version`) and `project` with `php`, `laravel` and `node`, each `{version, cycle, securitySupportEndsOn, activeSupportEndsOn}` or `null` when not declared (no name, path or hostname). The cycle and the `Y-m-d` dates come from the endoflife.date data the EOL checks already read, also when they pass; they are `null` when that check was skipped or did not run; - per finding: `fingerprint` (sha256 of `checkId`, `subject` and `advisoryId` joined by a NUL byte, the same value as the SARIF `partialFingerprints`, stable across runs), `advisoryId` on advisory findings, and `details` with check-specific values so you do not parse `message`: | `checkId` | `details` keys | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `composer-advisories` | `package`, `lockedVersion`, `affectedVersions` (Composer constraint), `fixedVersions` (list: the exclusive upper bound of each affected range) | | `npm-advisories` | `package`, `lockedVersion`, `fixedVersions` (always empty for now: the OSV batch answer has ids only) | | `php-eol`, `laravel-eol`, `node-eol` | `cycle`, `securitySupportEndsOn`, `activeSupportEndsOn` (`Y-m-d` or `null`) | | `composer-abandoned` | `package`, `lockedVersion`, `replacement` (or `null`) | | `composer-stale` | `package`, `lockedVersion` | | `laravel-next-blocker` | `package`, `lockedVersion`, `targetMajor` (integer) | | `laravel-pending-migrations` | `count` | | `laravel-failed-jobs` | `count`, `oldestAgeHours` (or `null`) | | `laravel-scheduler-heartbeat` | `minutesSinceLastRun` (`null`: never ran) | | private packages row of the five checks that send names | `count` | `laravel-debug-mode`, `laravel-production-caches`, `laravel-maintenance-mode` and other skipped rows have no `details`. The exact rules are in [`docs/report-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/report-schema.json). ## CLI ``` vendor/bin/excubia check [path] [options] vendor/bin/excubia push [path] [--config=FILE] [--strict] vendor/bin/excubia cache:clear [path] [--config=FILE] ``` `path` is the project directory to scan (default: the current directory). | Option | Commands | Meaning | | -------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `--format=FORMAT` | `check` | `table` (default), `json`, `markdown` or `sarif` | | `--output=FILE` | `check` | Write the report to `FILE` instead of stdout. The only file Excubia writes in your project | | `--fail-on=SEVERITY` | `check` | `critical`, `high` (default), `medium`, `low` or `none`. A finding at or above it makes the exit code `1`. Wins over `failOn` in `excubia.json` | | `--only=IDS` | `check` | Comma-separated check ids to run. Unknown ids are an error | | `--skip=IDS` | `check` | Comma-separated check ids to skip. With `--only`, applied to that selection | | `--no-dev` | `check` | Ignore `require-dev` / `packages-dev`. Same as `"includeDev": false` | | `--offline` | `check` | Use cached data-source responses only; checks without a cached response are `skipped` | | `--no-cache` | `check` | Do not read or write the response cache | | `--config=FILE` | all | Config file (default: `excubia.json` in the project, if present). A missing explicit file is an error | | `--no-banner` | `check` | Hide the ASCII banner | | `--strict` | `check`, `push` | Exit `3` when a data-source failure skipped a check; `push` then sends nothing. Same as `"strict": true` | | `-q`, `-v`, `-vv` | all | Quiet, verbose, debug. From `-v` every HTTP request is listed on stderr | | `--version` | | Print the Excubia version | The Artisan commands take the same options, without `path` (they always scan the app's base path): | Artisan command | Options | What it does | | ---------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `php artisan excubia:check` | as `check` | Runs every check, posture checks included | | `php artisan excubia:push` | `--config`, `--strict` | Runs the checks and sends the report to your `excubia-app` ([guide](https://docs.qiick.io/excubia/excubia-app)) | | `php artisan excubia:push-runtime` | `--config` | Sends the buffered runtime aggregates and exceptions ([guide](https://docs.qiick.io/excubia/runtime-monitoring)). Scheduled every minute when an opt-in is on | | `php artisan excubia:heartbeat` | | Stores the scheduler heartbeat. Scheduled every minute by the provider | ### Exit codes | Code | `check` | `push`, `push-runtime` | | ---- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `0` | No finding at or above `--fail-on` | Sent, or nothing to send. Findings do not change it | | `1` | At least one finding at or above `--fail-on` | — | | `2` | Usage or configuration error: missing or broken `composer.lock`, invalid `excubia.json`, unknown check id, bad option value | Configuration error: missing URL or token, plain HTTP to a host other than localhost, invalid config | | `3` | Only in strict mode: a check was skipped because a data source failed | The app rejected the token, answered an error or could not be reached; in strict mode also a data-source failure (nothing is sent) | When several apply, `2` wins over `1`, and `1` wins over `3`, so CI sees real findings first. Strict mode is `--strict` or `"strict": true`. A failed data source means the network was down with no cached response, or an API answered an error; the message on stderr names each check and data source. Without strict mode such checks are only marked `skipped` and the exit code ignores them. Deliberate skips never exit `3`: `--offline` without a cached response, `--only` / `--skip`, Laravel checks on a non-Laravel project, a disabled check, or a row removed by an ignore rule. `--strict` can only turn strict mode on: it wins over `"strict": false`, and nothing on the command line turns `"strict": true` off. ## Privacy By default only package names and versions (and runtime versions) leave the machine, and only to the public data sources in the check table. No file contents, `.env` values, source code, paths or hostnames. Private packages (path, VCS, Satis, Private Packagist, `file:` or other-registry origins in the lockfile, plus `privacy.skipPackages`) are never sent and show as `skipped`. No telemetry. Each opt-in that sends data to your own `excubia-app` is listed field by field in the [privacy guide](https://docs.qiick.io/excubia/privacy). ## Versioning and support Excubia follows SemVer. Public contracts change only in a major; deprecated options keep working with a warning for at least one minor. In `0.x`, minors may still break and only the latest minor gets fixes. Policy and supported versions: [`docs/VERSIONING.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/VERSIONING.md). The list of public contracts, with their files, version fields and contract tests: [`docs/CONTRACTS.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/CONTRACTS.md); everything else (PHP classes, message wording, table and Markdown layout) is internal. ## Guides - [Configuration reference](https://github.com/qiick-io/excubia-core/blob/main/excubia/configuration) - [CI](https://github.com/qiick-io/excubia-core/blob/main/excubia/ci) - [Push to excubia-app](https://github.com/qiick-io/excubia-core/blob/main/excubia/excubia-app) - [Runtime monitoring](https://github.com/qiick-io/excubia-core/blob/main/excubia/runtime-monitoring) - [Exception capture](https://github.com/qiick-io/excubia-core/blob/main/excubia/exception-capture) - [Health endpoint](https://github.com/qiick-io/excubia-core/blob/main/excubia/health-endpoint) - [Privacy](https://github.com/qiick-io/excubia-core/blob/main/excubia/privacy) - [Upgrading from 0.x to 1.0](https://github.com/qiick-io/excubia-core/blob/main/excubia/upgrading) --- # Configuration reference Source: https://docs.qiick.io/excubia/configuration Excubia reads one optional file, `excubia.json`, at the root of the scanned project (or the file passed with `--config`), and a few environment variables. Without the file every default below applies. - The file is JSON so it works without booting Laravel and is easy for agents to edit. JSON Schema: [`excubia.schema.json`](https://github.com/qiick-io/excubia-core/blob/main/excubia.schema.json). Add `"$schema"` to get completion in your editor. - Unknown keys, wrong types and out-of-range values are a configuration error: the command prints which key and exits `2`. - In a Laravel app the service provider reads the default `excubia.json` of the app base path once per boot. An invalid file there disables the opt-in features (the app still boots); `php artisan excubia:check` shows the error. - Command-line options win over the file: `--fail-on` over `failOn`, `--no-dev` over `includeDev`. `--strict` can only turn `strict` on. ## Full example Every key with its default value, except `$schema`, `ignore` and `app`, which have no default: ```json { "$schema": "https://raw.githubusercontent.com/qiick-io/excubia-core/main/excubia.schema.json", "failOn": "high", "includeDev": true, "strict": false, "checks": { "composer-stale": { "staleAfterMonths": 24 }, "php-eol": { "warnDaysBeforeEol": 180 }, "laravel-eol": { "warnDaysBeforeEol": 180 }, "node-eol": { "enabled": true }, "laravel-scheduler-heartbeat": { "staleAfterMinutes": 10 }, "laravel-failed-jobs": { "highSeverityAfterHours": 24 } }, "ignore": [ { "checkId": "composer-advisories", "subject": "vendor/package", "advisoryId": "GHSA-xxxx-xxxx-xxxx", "reason": "Not reachable from our code", "until": "2027-01-31" } ], "cache": { "directory": null, "ttlHours": 24 }, "privacy": { "skipPackages": [] }, "app": { "url": "https://excubia.example.com" }, "healthEndpoint": { "enabled": false, "path": "excubia/health" }, "runtime": { "enabled": false, "bufferMaxBytes": 1048576, "requests": { "enabled": true, "sampleRate": 1 }, "slowQueries": { "enabled": true, "sampleRate": 1, "thresholdMs": 1000 }, "jobs": { "enabled": true, "sampleRate": 1 }, "scheduler": { "enabled": true, "sampleRate": 1 }, "outgoingHttp": { "enabled": true, "sampleRate": 1 } }, "exceptions": { "enabled": false, "maskPatterns": [], "ignore": [], "bufferMaxBytes": 1048576 } } ``` ## General | Key | Type | Default | Meaning | | ------------ | --------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `$schema` | string | — | Schema URL for editors. Ignored by Excubia. | | `failOn` | `critical`, `high`, `medium`, `low` or `none` | `high` | A finding at or above this severity makes `check` exit `1`. `none` never fails on findings. `--fail-on` wins. | | `includeDev` | boolean | `true` | Scan `packages-dev` too. `--no-dev` sets it to `false`. | | `strict` | boolean | `false` | Exit `3` when a check was skipped because a data source failed; `push` then sends nothing. Same as `--strict`, which wins over `false`. | ## Checks `checks` holds per-check settings. Only these checks have settings; any other check id here is an error. To turn a check off, use `--skip` (or `node-eol.enabled`). | Key | Type | Default | Meaning | | ------------------------------------------------------ | ----------- | ------- | ---------------------------------------------------------------------------------------- | | `checks` | object | `{}` | Per-check settings. | | `checks.composer-stale` | object | | Settings of `composer-stale`. | | `checks.composer-stale.staleAfterMonths` | integer ≥ 1 | `24` | Warn (low) when a package had no release for more than this many months. | | `checks.php-eol` | object | | Settings of `php-eol`. | | `checks.php-eol.warnDaysBeforeEol` | integer ≥ 0 | `180` | Warn (medium) when PHP security support ends within this many days. | | `checks.laravel-eol` | object | | Settings of `laravel-eol`. | | `checks.laravel-eol.warnDaysBeforeEol` | integer ≥ 0 | `180` | Warn (medium) when Laravel security fixes end within this many days. | | `checks.node-eol` | object | | Settings of `node-eol`. | | `checks.node-eol.enabled` | boolean | `true` | `false` reports `node-eol` as `skipped` (`not-applicable`). | | `checks.laravel-scheduler-heartbeat` | object | | Settings of `laravel-scheduler-heartbeat`. | | `checks.laravel-scheduler-heartbeat.staleAfterMinutes` | integer ≥ 1 | `10` | Warn when the heartbeat written by `excubia:heartbeat` is older than this many minutes. | | `checks.laravel-failed-jobs` | object | | Settings of `laravel-failed-jobs`. | | `checks.laravel-failed-jobs.highSeverityAfterHours` | integer ≥ 1 | `24` | Failed jobs warn at medium, and at high once the oldest is at least this many hours old. | ## Ignore rules `ignore` is a list of findings you accept. A rule matches a finding with the same `checkId` and `subject`, and, when `advisoryId` is set, the same advisory. Ignored findings are removed from the report, the exit code and the health endpoint. | Key | Type | Default | Meaning | | --------------------- | -------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ignore` | list of objects | `[]` | Accepted findings. | | `ignore[].checkId` | string, required | | Check id, for example `composer-advisories`. | | `ignore[].subject` | string, required | | The finding subject: a package (`vendor/package`), a runtime (`php`, `laravel/framework`, `node`) or, for the posture checks, `app.debug`, `config-cache`, `route-cache`, `event-cache`, `migrations`, `failed-jobs`, `scheduler` or `maintenance-mode`. | | `ignore[].advisoryId` | string or null | `null` | Match only this advisory (GHSA or CVE id as shown in the report). `null` matches every finding of the subject. | | `ignore[].reason` | string, required | | Why it is accepted. Printed when the rule expires. | | `ignore[].until` | `YYYY-MM-DD` or null | `null` | Last day the rule applies (inclusive, UTC). After it the finding is active again and a warning is printed on stderr. `null` never expires. | ## Response cache Data-source responses are cached on disk so repeated runs and `--offline` work without network. | Key | Type | Default | Meaning | | ----------------- | -------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `cache` | object | | Cache settings. | | `cache.directory` | string or null | `null` | Cache directory. `null` uses `qiick-excubia-` in the system temporary directory, so nothing is written to the project; it is skipped when it is a symlink, owned by another user or writable by others. | | `cache.ttlHours` | integer ≥ 0 | `24` | Age after which a cached response is fetched again. `--offline` uses only responses younger than this; older ones count as missing. | `--no-cache` bypasses the cache for one run. `vendor/bin/excubia cache:clear` deletes the cache entries (only files named like cache entries). ## Privacy Packages the lockfile shows as not served by Packagist or the public npm registry are never sent to a public data source; see [private packages](https://docs.qiick.io/excubia/privacy#private-packages). `privacy.skipPackages` adds names the lockfile cannot tell apart, such as a private package published on the public npm registry. | Key | Type | Default | Meaning | | ---------------------- | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `privacy` | object | | What the checks may send to public data sources. | | `privacy.skipPackages` | list of strings | `[]` | Package name globs never sent to Packagist, OSV.dev or GitHub, for example `"acme/*"` or `"@acme/*"`. `*` matches any characters (`/` included), `?` exactly one; the whole name must match, case-insensitively. Only letters, digits, `@ / . _ ~ -` and the two wildcards are allowed. Matching packages appear in one `skipped` row per check. | ## excubia-app The target of `excubia push`, `excubia:push` and `excubia:push-runtime`. See the [push guide](https://docs.qiick.io/excubia/excubia-app). | Key | Type | Default | Meaning | | ----------- | ------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `app` | object | | Your own `excubia-app`. | | `app.url` | string | — | Base URL of the app, for example `https://excubia.example.com`. HTTPS, or plain HTTP to `localhost`, `127.0.0.1` or `[::1]` only. `EXCUBIA_APP_URL` wins. | | `app.token` | string | — | Project push token from the app. `EXCUBIA_APP_TOKEN` wins. Prefer the environment variable so the token is not committed; `check`, `push`, `excubia:check`, `excubia:push` and `excubia:push-runtime` print a warning on stderr when it is set. | ## Health endpoint See the [health endpoint guide](https://docs.qiick.io/excubia/health-endpoint). The secret is not a key: it comes from `EXCUBIA_HEALTH_SECRET` only. | Key | Type | Default | Meaning | | ------------------------ | ------- | ---------------- | -------------------------------------------------------------------------------------------------------------- | | `healthEndpoint` | object | | Oh Dear-compatible endpoint, Laravel only. | | `healthEndpoint.enabled` | boolean | `false` | Register the `GET` route (only when `EXCUBIA_HEALTH_SECRET` is set too, at least 32 characters). | | `healthEndpoint.path` | string | `excubia/health` | URL path of the route. Leading and trailing `/` are ignored. Letters, digits, `.`, `_`, `~`, `-` and `/` only. | ## Runtime monitoring See the [runtime monitoring guide](https://docs.qiick.io/excubia/runtime-monitoring). Off by default; only `runtime.enabled` is needed to turn it on. | Key | Type | Default | Meaning | | --------------------------------- | -------------- | --------- | ----------------------------------------------------------------------------- | | `runtime` | object | | Opt-in runtime aggregates, Laravel only. | | `runtime.enabled` | boolean | `false` | Register the listeners and schedule `excubia:push-runtime` every minute. | | `runtime.bufferMaxBytes` | integer ≥ 4096 | `1048576` | Size cap of `storage/excubia/runtime/`. The oldest entries are dropped first. | | `runtime.requests` | object | | Incoming HTTP requests. | | `runtime.requests.enabled` | boolean | `true` | Collect requests while `runtime.enabled` is true. | | `runtime.requests.sampleRate` | number 0–1 | `1` | Share of requests recorded. | | `runtime.slowQueries` | object | | Database queries at or above the threshold. | | `runtime.slowQueries.enabled` | boolean | `true` | Collect slow queries. | | `runtime.slowQueries.sampleRate` | number 0–1 | `1` | Share of slow queries recorded. | | `runtime.slowQueries.thresholdMs` | integer ≥ 0 | `1000` | A query this slow or slower is recorded. | | `runtime.jobs` | object | | Queued jobs. | | `runtime.jobs.enabled` | boolean | `true` | Collect jobs. | | `runtime.jobs.sampleRate` | number 0–1 | `1` | Share of jobs recorded. | | `runtime.scheduler` | object | | Scheduled tasks. | | `runtime.scheduler.enabled` | boolean | `true` | Collect scheduled tasks. | | `runtime.scheduler.sampleRate` | number 0–1 | `1` | Share of task runs recorded. | | `runtime.outgoingHttp` | object | | Calls made with Laravel's HTTP client. | | `runtime.outgoingHttp.enabled` | boolean | `true` | Collect outgoing calls. | | `runtime.outgoingHttp.sampleRate` | number 0–1 | `1` | Share of outgoing calls recorded. | ## Exception capture See the [exception capture guide](https://docs.qiick.io/excubia/exception-capture). Off by default and independent of `runtime`. | Key | Type | Default | Meaning | | --------------------------- | --------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `exceptions` | object | | Opt-in exception capture, Laravel only. | | `exceptions.enabled` | boolean | `false` | Add the `reportable()` callback and schedule `excubia:push-runtime` every minute. | | `exceptions.maskPatterns` | list of strings | `[]` | Extra PCRE patterns with delimiters, for example `"/ORD-[0-9]+/"`. Matches in messages become `[masked]`, before the built-in masking. An invalid pattern is a configuration error. | | `exceptions.ignore` | list of strings | `[]` | Exception classes never captured, subclasses included, for example `"App\\Exceptions\\PaymentDeclinedException"`. A leading `\` is ignored. | | `exceptions.bufferMaxBytes` | integer ≥ 4096 | `1048576` | Size cap of `storage/excubia/exceptions/`. The oldest entries are dropped first. | ## Environment variables Excubia reads these from the process environment (`getenv()`), never from Laravel config. With `php artisan config:cache`, Laravel no longer loads `.env` into the process, so set them in the real environment: the PHP-FPM pool, the container, the systemd unit or the CI job. | Variable | Used by | Meaning | | ----------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `EXCUBIA_APP_URL` | `push`, `excubia:push`, `excubia:push-runtime` | Base URL of your `excubia-app`. Wins over `app.url`. | | `EXCUBIA_APP_TOKEN` | `push`, `excubia:push`, `excubia:push-runtime` | Project push token. Wins over `app.token`. Never printed, cached or written to a report. | | `EXCUBIA_HEALTH_SECRET` | health endpoint | Secret expected in the `oh-dear-health-check-secret` header, at least 32 characters. Without it, or when it is shorter, the route is not registered. | | `GITHUB_TOKEN` | `composer-stale` | Optional. Raises the GitHub API rate limit for the archived-repository lookup. Sent only to `api.github.com`; never printed, cached or written to a report. | | `HTTPS_PROXY` | all HTTP requests | Proxy for the data sources and the push, read by Symfony HttpClient. | | `NO_COLOR` | `table` format | Turns colours off. | Empty values count as unset. --- Source: [`docs/guides/configuration.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/guides/configuration.md) in excubia-core. --- # Upgrading from 0.x to 1.0 Source: https://docs.qiick.io/excubia/upgrading 1.0 adds features but keeps what 0.1.x did. Most projects only need to update the constraint: ```bash composer require --dev qiick/excubia-core:^1.0 ``` Use the same command without `--dev` if you turn on one of the opt-ins that run in the deployed app. Then read the list below; the items marked **check** may change what your CI sees. ## Symfony 8 is supported `symfony/console` and `symfony/http-client` accept `^7.2 || ^8.0`. On a Laravel 13 app that already locks Symfony 8 components, `composer require` works without `-W`, and Composer no longer downgrades the app's own Symfony packages. If you added `-W` (`--with-all-dependencies`) to install 0.x or an earlier 1.0 pre-release, drop it. Symfony 8 needs PHP 8.4 or newer; on PHP 8.2 and 8.3 Composer keeps Symfony 7, which still works. ## Laravel 11 is no longer supported for the Artisan integration (D19) The Artisan commands and the opt-in features need Laravel 12 or 13. Laravel 11 is past security support, and Composer blocks its releases by default, so it cannot be tested. - On a Laravel 11 app, upgrade Laravel first, or keep using only the standalone CLI: install Excubia in a separate directory (as the [GitHub Action](https://docs.qiick.io/excubia/ci#github-action) does) and run `vendor/bin/excubia check /path/to/app`. The standalone CLI still scans Laravel 11 projects and reports them as end of life. - The package floor stays PHP `^8.2`. ## New check ids (check) Six posture checks run through `php artisan excubia:check` and `excubia:push` (not through `vendor/bin/excubia`, where they are `skipped`): `laravel-debug-mode`, `laravel-production-caches`, `laravel-pending-migrations`, `laravel-failed-jobs`, `laravel-scheduler-heartbeat`, `laravel-maintenance-mode`. If you run `php artisan excubia:check` in CI with the default `--fail-on=high`, `laravel-debug-mode` fails when `APP_DEBUG=true` outside `local` / `testing`, and `laravel-failed-jobs` can reach high. Either run `vendor/bin/excubia check` in CI, `--skip` the posture checks there, or accept a row with an `ignore` rule. See the [check table](https://docs.qiick.io/excubia#checks). ## A scheduled heartbeat (check) The service provider now schedules `php artisan excubia:heartbeat` every minute. It writes one cache key, `excubia:scheduler-heartbeat`, in the default cache store and nothing else. It appears in `php artisan schedule:list`. It needs no setup beyond the usual `schedule:run` cron. ## New report fields The JSON report keeps `schemaVersion` `1`. New optional fields: - `skipReason` on every `skipped` finding (`data-source-failure`, `offline`, `not-applicable`); - top level `generatedAt`, `excubiaVersion` and `project` (#92): `php`, `laravel` and `node` are each `{version, cycle, securitySupportEndsOn, activeSupportEndsOn}` or `null`, with the support dates present also when the EOL checks pass (#123); - per finding `fingerprint` (the SARIF fingerprint, stable across runs), `advisoryId` on advisory findings and `details` with check-specific values (#92). The keys per check id are in the [README](https://docs.qiick.io/excubia#formats) and [`docs/report-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/report-schema.json). Table, Markdown and SARIF output are unchanged. Consumers must ignore fields they do not know, as [`docs/VERSIONING.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/VERSIONING.md) says. ## Strict mode and exit code 3 `--strict` (or `"strict": true`) makes a check that was skipped because a data source failed exit `3`. Without it nothing changes: such checks stay `skipped` and the exit code ignores them. Precedence is `2` > `1` > `3`. ## Private packages are not checked (check) Excubia no longer sends the names of private packages to Packagist, OSV.dev or GitHub (#108). A package is private when the lockfile shows it was not served by Packagist or the public npm registry (path, artifact, VCS, Satis, Private Packagist, `file:`, links, workspace members, other registries), or when it matches `privacy.skipPackages`. `composer-advisories`, `npm-advisories`, `composer-abandoned`, `composer-stale` and `laravel-next-blocker` add one `skipped` row with subject `private packages` naming them, so a check that used to `pass` can now show `skipped`. It never fails and `--strict` ignores it (`skipReason` `not-applicable`). To accept the row, add an `ignore` rule with that subject. See [privacy](https://docs.qiick.io/excubia/privacy#private-packages). ## New `excubia.json` keys All optional, all off or unchanged by default. An existing 0.1.x file stays valid. | Key | For | | ------------------------------------------------------ | --------------------------------------------------------------------------------------- | | `strict` | Strict mode | | `checks.laravel-scheduler-heartbeat.staleAfterMinutes` | Posture check threshold, default `10` | | `checks.laravel-failed-jobs.highSeverityAfterHours` | Posture check threshold, default `24` | | `app` | [Push to excubia-app](https://docs.qiick.io/excubia/excubia-app) | | `healthEndpoint` | [Health endpoint](https://docs.qiick.io/excubia/health-endpoint) | | `runtime` | [Runtime monitoring](https://docs.qiick.io/excubia/runtime-monitoring) | | `exceptions` | [Exception capture](https://docs.qiick.io/excubia/exception-capture) | | `privacy.skipPackages` | [Private packages](https://docs.qiick.io/excubia/privacy#private-packages) never sent to public data sources | New environment variables: `EXCUBIA_APP_URL`, `EXCUBIA_APP_TOKEN`, `EXCUBIA_HEALTH_SECRET`. Full reference: [configuration](https://docs.qiick.io/excubia/configuration). ## New commands and formats - `--format=sarif` for GitHub code scanning. - `vendor/bin/excubia push` and `php artisan excubia:push`. - `php artisan excubia:push-runtime`, scheduled by the provider only when `runtime.enabled` or `exceptions.enabled` is on. - `php artisan excubia:heartbeat`, see above. ## GitHub Action If your workflow installs and runs Excubia by hand, you can replace it with the action, which also uploads SARIF to code scanning: ```yaml - uses: actions/checkout@v4 - uses: qiick-io/excubia-core@v1 with: fail-on: critical ``` The action's `fail-on` default is `critical`, not the CLI's `high`. See the [CI guide](https://docs.qiick.io/excubia/ci). ## After 1.0 The report JSON, check ids, exit codes, CLI and Artisan options, `excubia.json`, environment variables, push payloads, the health endpoint response and the Action inputs change incompatibly only in a major. New checks, options and optional fields can arrive in minors. See [`docs/VERSIONING.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/VERSIONING.md). --- Source: [`docs/guides/upgrading.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/guides/upgrading.md) in excubia-core. --- # CI Source: https://docs.qiick.io/excubia/ci Excubia is built to gate pipelines: it exits `1` when a finding is at or above `--fail-on`, and writes JSON, Markdown or SARIF for the job to keep. The standalone CLI does not boot the app, so a CI job needs only PHP and the project's `composer.lock`; no database, no `.env`, no `composer install` of the project itself. ## GitHub Action The composite action in [`action.yml`](https://github.com/qiick-io/excubia-core/blob/main/action.yml) runs `excubia check`, writes the Markdown report to the job summary and uploads the SARIF log to GitHub code scanning, so findings show up in the **Security** tab and on pull requests. ```yaml # .github/workflows/excubia.yml name: Excubia on: push: branches: [main] pull_request: schedule: - cron: '0 6 * * 1' # weekly: new advisories and EOL dates appear without code changes permissions: contents: read security-events: write # for the SARIF upload jobs: excubia: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: qiick-io/excubia-core@v1 with: fail-on: critical github-token: ${{ github.token }} ``` | Input | Default | Meaning | | -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------- | | `path` | `.` | Project directory to scan, relative to the workspace. | | `fail-on` | `critical` | `critical`, `high`, `medium`, `low` or `none`, as `--fail-on`. Note the default is `critical` here and `high` in the CLI. | | `only` | — | Comma-separated check ids to run. | | `skip` | — | Comma-separated check ids to skip. | | `github-token` | — | Optional. Raises the GitHub API rate limit for `composer-stale`. Passed to Excubia as `GITHUB_TOKEN` only. | | `upload-sarif` | `true` | `false` skips the code scanning upload (and the need for `security-events: write`). | | `php-version` | `8.4` | PHP used to run Excubia (8.2 or newer), not the scanned project's PHP. | | Output | Meaning | | ------------ | ----------------------------- | | `sarif-file` | Path of the SARIF log. | | `exit-code` | Exit code of `excubia check`. | How it works: - Excubia is installed with Composer in `$RUNNER_TEMP/excubia` from the action's own checkout, so the version matches the ref you use (`@v1`, a tag or a commit SHA). Its dependencies (`symfony/console`, `symfony/http-client`, `composer/semver`…) come from the lock committed in `.github/action-install/` and resolved for PHP 8.2, so the action runs the same reviewed versions on every run and every `php-version` from 8.2 up; Composer plugins and scripts are disabled. The scanned project's code and dependencies are never installed or executed. - The SARIF log is uploaded even when the step then fails on `fail-on`. Pull requests from forks get a read-only token, so the upload is skipped there. - `excubia.json` in `path` is read as usual, so ignore rules and thresholds apply. - Pin the action to a commit SHA if your policy requires it. ## SARIF `--format=sarif` writes a [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) log: - Each check id is a rule; each `fail` or `warn` finding is a result. `pass` and `skipped` rows and the upgrade plan are left out. - Levels: `critical` and `high` → `error`, `medium` → `warning`, `low` and `info` → `note`. The Excubia severity stays in `properties.severity`. - The location is the manifest the finding comes from (`composer.json`, `composer.lock`, `package-lock.json`, `.nvmrc`, `.node-version` or `package.json`; for the posture checks `config/app.php`, `config/queue.php`, `database/migrations` or `routes/console.php`), relative to the project. Never an absolute path. - `partialFingerprints` come from the check id, subject and advisory id, so an alert keeps its identity across runs. ## Any other CI Install Excubia as a dev dependency and run it. The exit code fails the job. ```bash composer install --no-interaction --no-scripts # or only: composer require --dev qiick/excubia-core vendor/bin/excubia check --no-banner --fail-on=high ``` Keep the reports as artifacts: ```bash vendor/bin/excubia check --no-banner --format=json --output=excubia.json --fail-on=none vendor/bin/excubia check --no-banner --format=markdown --output=excubia.md ``` The second run reuses the response cache of the first one. Typical choices: | Need | Command | | ------------------------------------------------------------------------ | ----------------------------------------------------------------------- | | Fail only on critical findings | `--fail-on=critical` | | Report, never fail | `--fail-on=none` | | Fail when a data source was down, rather than pass with `skipped` checks | `--strict` (exit `3`) | | Production dependencies only | `--no-dev` | | No network in the job | Restore a cached directory set with `cache.directory`, then `--offline` | | Scan a sub-directory | `vendor/bin/excubia check path/to/app` | GitLab CI example: ```yaml excubia: image: php:8.4-cli before_script: - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - composer install --no-interaction --no-scripts --no-progress script: - vendor/bin/excubia check --no-banner --format=markdown --output=excubia.md artifacts: when: always paths: [excubia.md] ``` Exit codes: `0` clean, `1` finding at or above `--fail-on`, `2` usage or configuration error, `3` data-source failure in strict mode. Precedence `2` > `1` > `3`. See the [README](https://docs.qiick.io/excubia#exit-codes). ## Push from CI To send the report to your own `excubia-app` after a deploy, see [Push to excubia-app](https://docs.qiick.io/excubia/excubia-app#after-a-deploy). ## Posture checks in CI The six `laravel-*` posture checks read the booted app, so they only run through `php artisan excubia:check`. In CI they would describe the CI environment, not production, so use `vendor/bin/excubia` there (the posture checks are `skipped`), and run the posture checks on the server or through the scheduled push. --- Source: [`docs/guides/ci.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/guides/ci.md) in excubia-core. --- # Push to excubia-app Source: https://docs.qiick.io/excubia/excubia-app `excubia push` runs the same checks as `excubia check` and sends the JSON report to your own self-hosted [`excubia-app`](https://github.com/qiick-io/excubia-app), the multi-project dashboard. The app never reads your project or polls it: projects push. Nothing is sent until you configure an app URL and a project token. ## 1. Get a URL and a token In `excubia-app`, register the project and copy its push token. The URL is the app's base URL, for example `https://excubia.example.com`. ## 2. Configure Set both in the environment: ```bash EXCUBIA_APP_URL=https://excubia.example.com EXCUBIA_APP_TOKEN=your-project-token ``` Or put the URL in `excubia.json` and keep only the token in the environment: ```json { "app": { "url": "https://excubia.example.com" } } ``` The environment wins over `app.url` / `app.token`. Keep the token out of committed files: `app.token` still works, but `check`, `push`, `excubia:check`, `excubia:push` and `excubia:push-runtime` print a warning on stderr when it is set (`app.token is set in excubia.json; prefer EXCUBIA_APP_TOKEN so the token is not committed.`). Excubia reads the variables with `getenv()`, not through Laravel config: with `php artisan config:cache`, `.env` is not loaded into the process, so set them in the real process environment (PHP-FPM pool, container, systemd unit, CI secrets). The scheduler runs each command in its own process, so the variables must also be set where `schedule:run` runs (the cron or `schedule:work` environment), not only for the web server. A scheduled push without a token exits `2` and shows as failed in the scheduler. The URL must be HTTPS. Plain HTTP is accepted only for `localhost`, `127.0.0.1` and `[::1]`, which is useful to try it against a local app. ## 3. Send a report ```bash vendor/bin/excubia push # any project php artisan excubia:push # Laravel; also sends the posture checks ``` On success it prints `Report sent to excubia-app (batch ).` and exits `0`, whatever the findings. | Exit code | Meaning | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | Sent. Findings do not change it. | | `2` | Configuration error: missing URL or token, plain HTTP to a host other than localhost, missing `composer.lock`, invalid `excubia.json`. | | `3` | The app rejected the token (401/403), answered an error, or could not be reached. With `--strict`, also a check skipped because a data source failed: then nothing is sent. | `push --strict` (or `"strict": true`) makes sure the app never stores an incomplete report as if it were the full picture. ## From the scheduler Send a report every day, so the app sees new advisories and EOL dates even when nobody deploys: ```php // routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('excubia:push')->daily(); ``` The server needs the usual scheduler cron: ``` * * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1 ``` Install the package without `--dev` (`composer require qiick/excubia-core`) so the command exists in production. ## After a deploy Push from the deploy job so the app reflects what is live: ```yaml # GitHub Actions, after the deploy step - run: vendor/bin/excubia push env: EXCUBIA_APP_URL: ${{ vars.EXCUBIA_APP_URL }} EXCUBIA_APP_TOKEN: ${{ secrets.EXCUBIA_APP_TOKEN }} ``` Or run `php artisan excubia:push` on the server as the last step of the deploy script (Envoyer, Forge, Deployer hooks), which also includes the posture checks of the deployed app. ## Stale projects The app marks a project stale when no report arrives within a window you configure in the app. A stale project usually means the scheduler stopped or the token was revoked, so keep the daily scheduled push even if you also push on deploy. ## What is sent `POST {url}/api/reports` with `Authorization: Bearer `, over TLS with the same timeouts (connect 5 s, total 15 s) and limited retries as the data sources. The body is an envelope: ```json { "protocolVersion": 1, "batchId": "5b0d2c9e-7a51-4f0e-9a43-2f3f0f2a6c11", "sentAt": "2026-11-03T06:00:00Z", "report": { "schemaVersion": 1, "...": "the JSON report" } } ``` - `batchId` is a new UUID per run, kept across HTTP retries, so a retried send is stored once. - `report` is the same document as `--format=json` ([`docs/report-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/report-schema.json)): package names and versions, runtime versions and findings, with `generatedAt`, `excubiaVersion`, `project` (each runtime's version, endoflife.date cycle and support end dates, present on every report so the EOL calendar covers healthy projects) and the per-finding `fingerprint` and `details` the app uses for its fleet views. Envelope schema: [`docs/push-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/push-schema.json). - The token is never printed, cached or written to a report. Full list per feature: [privacy](https://docs.qiick.io/excubia/privacy). --- Source: [`docs/guides/excubia-app.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/guides/excubia-app.md) in excubia-core. --- # Runtime monitoring Source: https://docs.qiick.io/excubia/runtime-monitoring In a Laravel 12 or 13 app, Excubia can send runtime aggregates to your own `excubia-app`: request, slow query, job, scheduled task and outgoing HTTP timings. It is off by default. While it is off, the service provider registers no listener and no schedule, and nothing runs. ## Prerequisites - The package installed without `--dev`: `composer require qiick/excubia-core`. - An `excubia-app` URL and project token, set up as in [Push to excubia-app](https://docs.qiick.io/excubia/excubia-app#2-configure) (`EXCUBIA_APP_URL` / `EXCUBIA_APP_TOKEN` or `app` in `excubia.json`). - The Laravel scheduler running: `* * * * * php artisan schedule:run` from cron, or `php artisan schedule:work`. ## 1. Turn it on ```json { "app": { "url": "https://excubia.example.com" }, "runtime": { "enabled": true } } ``` That is all that is needed. Each type can be switched off or sampled: ```json { "runtime": { "enabled": true, "bufferMaxBytes": 1048576, "requests": { "enabled": true, "sampleRate": 1 }, "slowQueries": { "enabled": true, "sampleRate": 1, "thresholdMs": 1000 }, "jobs": { "enabled": true, "sampleRate": 1 }, "scheduler": { "enabled": true, "sampleRate": 1 }, "outgoingHttp": { "enabled": true, "sampleRate": 1 } } } ``` The values shown are the defaults. `sampleRate` is the share of events recorded, from 0 to 1 (`0.1` records one event in ten). Every key is in the [configuration reference](https://docs.qiick.io/excubia/configuration#runtime-monitoring). The provider reads `excubia.json` at boot. With `php artisan config:cache` that still works (the file is not Laravel config), but restart long-running workers (`queue:restart`, Octane) after changing it. ## 2. Check the schedule ```bash php artisan schedule:list ``` With `runtime.enabled` true you see `php artisan excubia:push-runtime` every minute, next to `php artisan excubia:heartbeat`. ## 3. Send The scheduler sends every minute. To send by hand: ```bash php artisan excubia:push-runtime ``` It prints `Sent N runtime batches to excubia-app.`, `No runtime data to send.`, or the error. | Exit code | Meaning | | --------- | --------------------------------------------------------------------------------------------------------- | | `0` | Sent, nothing to send, or both runtime monitoring and exception capture are off. | | `2` | Configuration error: invalid `excubia.json`, missing URL or token, insecure URL. | | `3` | The app rejected the token, answered an error or could not be reached. The data is kept for the next run. | ## How it works - Laravel events are aggregated in memory per UTC minute: `count`, `errorCount`, `totalMs` and `maxMs` per route, query, job, task or host. - The aggregates are written to a local buffer under `storage/excubia/runtime/` when the request or command ends, and after each queued job or scheduled task. Nothing is sent during a request. - `excubia:push-runtime` claims the buffer into batch files and sends them oldest first to `POST {url}/api/runtime`. A batch is deleted only after a 2xx answer. When the app is down, batches are kept and resent with the same `batchId`, so the app stores each once. - The buffer is capped by `runtime.bufferMaxBytes` (default 1 MiB). Past the cap the oldest entries are dropped, so an unreachable app never fills the disk. - Every listener catches its own errors: an error inside Excubia never reaches your app. Measured overhead is about 0.1 ms per request with every type on. | Type | Laravel event | Error when | | -------------- | ------------------------------------------------------------------------ | --------------- | | `requests` | `RequestHandled` | Status 5xx | | `slowQueries` | `QueryExecuted`, at or above `thresholdMs` | — | | `jobs` | `JobProcessing` / `JobProcessed` / `JobFailed` | The job failed | | `scheduler` | `ScheduledTaskFinished` / `ScheduledTaskFailed` / `ScheduledTaskSkipped` | The task failed | | `outgoingHttp` | HTTP client `ResponseReceived` | Status 5xx | Envelope: `protocolVersion` `1`, `type` `runtime`, `batchId`, `sentAt` and `runtime`. Schemas: [`docs/runtime-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/runtime-schema.json) and `$defs.runtimeEnvelope` in [`docs/push-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/push-schema.json). ## What is sent Route names (`unnamed` for routes without a name), HTTP methods and status classes; slow SQL without bindings, with inline literals (quoted strings, numbers, hex and dollar-quoted literals) replaced by `?`, and the connection name; job class, queue and connection names; scheduled task descriptions (paths made relative, secrets masked), else `artisan ` or the executable's file name, without arguments; the host of outgoing calls. Never URLs or query strings, bodies, headers, cookies, users, IP addresses, query bindings, exception messages, `.env` values or absolute paths. Details: [privacy](https://docs.qiick.io/excubia/privacy#runtime-monitoring). Name your routes (`->name('orders.show')`) to get useful request rows. ## Turn it off Set `"enabled": false` (or remove `runtime`). The listeners and the schedule disappear at the next boot. Whatever is still in `storage/excubia/runtime/` stays there and is not sent; delete the directory if you want. --- Source: [`docs/guides/runtime-monitoring.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/guides/runtime-monitoring.md) in excubia-core. --- # Exception capture Source: https://docs.qiick.io/excubia/exception-capture In a Laravel 12 or 13 app, Excubia can send the exceptions your app reports to your own `excubia-app`, masked and grouped by fingerprint. It is a separate opt-in, off by default, and works with [runtime monitoring](https://docs.qiick.io/excubia/runtime-monitoring) on or off. While it is off, nothing is registered on the exception handler. It does not replace your error tracker: your own reporting (log channels, Sentry, Flare, other `reportable()` callbacks) runs exactly as before. ## Prerequisites - The package installed without `--dev`: `composer require qiick/excubia-core`. - An `excubia-app` URL and project token, set up as in [Push to excubia-app](https://docs.qiick.io/excubia/excubia-app#2-configure). - The Laravel scheduler running (`schedule:run` from cron, or `schedule:work`). ## 1. Turn it on ```json { "app": { "url": "https://excubia.example.com" }, "exceptions": { "enabled": true } } ``` Optional keys: ```json { "exceptions": { "enabled": true, "maskPatterns": ["/ORD-[0-9]+/"], "ignore": ["App\\Exceptions\\PaymentDeclinedException"], "bufferMaxBytes": 1048576 } } ``` - `maskPatterns`: extra PCRE patterns, with delimiters, for data specific to your app. Matches become `[masked]`, before the built-in masking. An invalid pattern is a configuration error. - `ignore`: exception classes, subclasses included, that are never captured. Exceptions your app does not report (`dontReport`, `ShouldntReport`) are never captured either. - `bufferMaxBytes`: cap of the local buffer, default 1 MiB. Reference: [configuration](https://docs.qiick.io/excubia/configuration#exception-capture). ## 2. Check the schedule `php artisan schedule:list` shows `php artisan excubia:push-runtime` every minute. It is the same command as for runtime monitoring and sends both buffers. ## 3. Try it ```php // routes/web.php, temporarily Route::get('/excubia-test', fn () => throw new RuntimeException('Order for jane@example.com failed'))->name('excubia.test'); ``` Open `/excubia-test`, then: ```bash php artisan excubia:push-runtime ``` It prints `Sent 1 exception batch to excubia-app.`. In the app the message shows as `Order for [email] failed`, with the context `request` and the route `excubia.test`. Remove the route afterwards. Exit codes are those of `excubia:push-runtime`: `0` sent or nothing to send, `2` configuration error, `3` the app rejected the token, answered an error or could not be reached (the data is kept). A failure on the exception buffer does not stop the runtime buffer, and the other way round. ## How it works - Excubia adds one `reportable()` callback to Laravel's exception handler. It returns nothing, so the rest of the reporting chain runs, and every step catches its own errors, so capture never reaches your app. - Each occurrence is masked first, then grouped in memory by fingerprint, context and environment, and written to `storage/excubia/exceptions/` when the request or command ends, when a queue worker loops, or once 100 groups are pending. - The fingerprint is a sha256 of the class and the in-app stack frames (file relative to the project and `Class::method`, without line numbers). Moving code within a file does not split a group, and vendor frames are ignored. - The context is the job class for exceptions thrown in a job, else the route name for a request, else the Artisan command name, else `unknown`. - `excubia:push-runtime` sends the groups to `POST {url}/api/exceptions`, with the same batch files, `batchId` reuse until a 2xx answer, and size cap as the runtime buffer. The app adds up `count` and widens `firstSeen` / `lastSeen` for the same fingerprint. Envelope: `protocolVersion` `1`, `type` `exceptions`, `batchId`, `sentAt` and `exceptions` (`{"groups": [...]}`). Schemas: [`docs/exception-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/exception-schema.json) and `$defs.exceptionsEnvelope` in [`docs/push-schema.json`](https://github.com/qiick-io/excubia-core/blob/main/docs/push-schema.json). ## What is sent Per group: class, fingerprint, masked message (at most 1000 characters), stack frames (relative file, line, function, in-app flag; never arguments or local variables), the classes of chained exceptions, the context, the environment (`app.env`), the count and the first and last seen times. Never request payloads, headers, cookies, users, IP addresses, query bindings, `.env` values or absolute paths. Built-in masking, applied before anything is written to disk: absolute paths (also Windows and UNC paths), URL credentials, hosts and query strings, host names in `host:` / `resolve host` phrases, quoted SQL literals, database hosts, JWTs, bearer and basic credentials, any other `Authorization` value, `password=` / `token=` / `api_key=` style values, emails, IP addresses, UUIDs, long hex strings and API-key-like strings, phone-like, social-security, card-like and long numbers. The exact rules are in the [privacy guide](https://docs.qiick.io/excubia/privacy#exception-capture). ## Turn it off Set `"enabled": false` (or remove `exceptions`). The callback and, when runtime monitoring is off too, the schedule disappear at the next boot. What is left in `storage/excubia/exceptions/` is not sent. --- Source: [`docs/guides/exception-capture.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/guides/exception-capture.md) in excubia-core. --- # Health endpoint Source: https://docs.qiick.io/excubia/health-endpoint In a Laravel 12 or 13 app, Excubia can serve its latest result in the [Oh Dear application health](https://ohdear.app/docs/features/application-health-monitoring) JSON format, the format [spatie/laravel-health](https://github.com/spatie/laravel-health) also uses, so external monitors can poll it. It is off by default. It complements the [push to excubia-app](https://docs.qiick.io/excubia/excubia-app); it is not the channel to the app. This page is the setup. The request and response contract (fields, status mapping, the `crashed` and "not run yet" answers) is [`docs/health-endpoint.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/health-endpoint.md). ## 1. Turn it on The package must be installed without `--dev`: `composer require qiick/excubia-core`. ```json { "healthEndpoint": { "enabled": true, "path": "excubia/health" } } ``` `path` is optional (default `excubia/health`). Reference: [configuration](https://docs.qiick.io/excubia/configuration#health-endpoint). ## 2. Set the secret ```bash EXCUBIA_HEALTH_SECRET=$(openssl rand -hex 32) ``` The secret must be at least 32 characters long; use a random value. The secret is read from the process environment only, never from `excubia.json`, so it is not committed. With `php artisan config:cache`, `.env` is not loaded into the process, so set it in the real environment (PHP-FPM pool, container, systemd unit). Enabled without a secret, or with a secret shorter than 32 characters, the provider does not register the route, so it cannot be left open by mistake; with a short secret `excubia:check` and `excubia:push` print a warning on stderr. Check that the route exists: ```bash php artisan route:list --name=excubia.health ``` With `php artisan route:cache`, the route is part of the cache: rebuild it after changing `healthEndpoint` or the secret. ## 3. Store a result on a schedule The endpoint never runs checks in the request. It serves what the last `php artisan excubia:check` or `php artisan excubia:push` stored in `storage/excubia/health.json`: ```php // routes/console.php use Illuminate\Support\Facades\Schedule; Schedule::command('excubia:push')->daily(); Schedule::command('excubia:check --offline --fail-on=none --no-banner')->everyFiveMinutes(); ``` - Oh Dear reports stale results when `finishedAt` is older than 10 minutes, so a frequent run is needed. - `--offline` keeps the frequent run cheap: it reads the response cache and sends no request. The cache entries expire after `cache.ttlHours` (24 by default), so keep one online run per day, such as the daily `excubia:push` (or a daily `excubia:check` if you do not use `excubia-app`). Without it the offline checks turn `skipped`. - `vendor/bin/excubia` never stores a result; only the Artisan commands do, and only while the endpoint is enabled. Without the push, use `Schedule::command('excubia:check --fail-on=none --no-banner')->daily();` as the online run. ## 4. Point the monitor at it In Oh Dear, enable Application health monitoring with the URL `https://your-app.example/excubia/health` and the same secret. Try it by hand: ```bash curl -s -H "oh-dear-health-check-secret: $EXCUBIA_HEALTH_SECRET" https://your-app.example/excubia/health ``` A missing or wrong secret gets your app's normal `404`, so the route looks like a missing page. The route is throttled with Laravel's `ThrottleRequests` middleware at 60 requests a minute per client IP (your app's default cache store); more answer `429`. Before the first stored run, the answer is `200` with one `skipped` entry ("Not run yet"). ## Notes - An invalid `excubia.json` at boot: the route is not registered and the app boots normally; `excubia:check` reports the configuration error. - The provider reads the default `excubia.json` of the app base path. A `--config` file passed to the commands decides whether that run stores a result, not whether the route exists. - Findings removed by `ignore` rules are not counted. `--fail-on` does not change the status: a `warn` finding is `warning` whatever the threshold. - The response holds check ids, names, statuses, severities, counts and package or runtime names. No messages, paths, hostnames, tokens or `.env` values. --- Source: [`docs/guides/health-endpoint.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/guides/health-endpoint.md) in excubia-core. --- # Privacy Source: https://docs.qiick.io/excubia/privacy What leaves the machine, for each feature. This page follows [`docs/SPEC.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/SPEC.md) section 7; the threat model and how each rule is enforced are in [`docs/SECURITY-MODEL.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/SECURITY-MODEL.md). There is no telemetry, no analytics and no phone-home to Qiick or any third party. Besides the public data sources of the checks, the only destination is your own `excubia-app`, and only for the features you turn on. | Feature | Default | Sends to | What | | ----------------------------------------- | ------------------------------- | ---------------------- | -------------------------------------------- | | [Checks](#checks) | on | Public data sources | Package names and versions, runtime versions | | [Push](#push) | off until URL and token are set | Your `excubia-app` | The JSON report | | [Runtime monitoring](#runtime-monitoring) | off (`runtime.enabled`) | Your `excubia-app` | Aggregates per minute | | [Exception capture](#exception-capture) | off (`exceptions.enabled`) | Your `excubia-app` | Masked exception groups | | [Health endpoint](#health-endpoint) | off (`healthEndpoint.enabled`) | Whoever has the secret | Check statuses | ## Checks `excubia check` and the Artisan commands send only package **names and versions**, and runtime versions, to the public APIs of the checks: endoflife.date, Packagist (metadata and security advisories), OSV.dev and the GitHub API. No file contents, `.env` values, source code, paths or hostnames. - Private packages are never sent: see [below](#private-packages). - `GITHUB_TOKEN` is optional, read from the environment only, sent only to the GitHub API as a bearer header, and never printed or written to the cache or the report. - All HTTP is over TLS with timeouts (connect 5 s, total 15 s) and limited retries; `HTTPS_PROXY` is respected. - Excubia never writes to the scanned project except the `--output` file, and `vendor/bin/excubia` never executes project code. - The Artisan commands run inside the booted app. The posture checks read it without writing, except the scheduler heartbeat cache key `excubia:scheduler-heartbeat`, and their findings carry only booleans, counts, ages and names: never `.env` values, DSNs, hostnames, paths or exception messages. - The response cache stores public API responses only, in the system temporary directory unless `cache.directory` says otherwise. ### Private packages Only packages the lockfile shows as served by the public registries are sent (#108): | Lockfile | Sent | Not sent | | ------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `composer.lock` | `notification-url` is `https://packagist.org/downloads/` | `path` or `artifact` dist, VCS repositories, Satis, Private Packagist (another `notification-url`), no `notification-url` | | `package-lock.json` | `resolved` starts with `https://registry.npmjs.org/` | `link: true`, `file:` and VCS dependencies, workspace members (no `resolved`), any other registry | The lockfile cannot tell a private package on the public npm registry, or one fetched through Packagist, from a public one. Name those in `privacy.skipPackages` with globs such as `"acme/*"` or `"@acme/*"` ([configuration](https://docs.qiick.io/excubia/configuration#privacy)): ```json { "privacy": { "skipPackages": ["acme/*", "@acme/*"] } } ``` Each check that would send names (`composer-advisories`, `npm-advisories`, `composer-abandoned`, `composer-stale`, `laravel-next-blocker`) reports the skipped packages in one `skipped` row with subject `private packages`, `skipReason` `not-applicable` and a message such as `2 private packages not sent to Packagist: acme/billing, acme/ui.` The names come from your own lockfile and stay in the local report; with the opt-in push they reach only your own `excubia-app`, and the health endpoint shows the subject without the message. A check with no other finding then reports `skipped`; it never fails and `--strict` ignores it. Private packages are not checked for advisories, abandonment or staleness: watch them yourself. ## Push `excubia push` / `excubia:push` send the JSON report (`docs/report-schema.json`) to `POST {url}/api/reports`: package names and versions, runtime versions and findings (check id, severity, status, subject, message, fix hint, evidence URL, advisory id), inside an envelope with `protocolVersion`, `batchId` and `sentAt`. - Nothing is sent until an app URL and a project token are configured. - HTTPS only; plain HTTP only to `localhost`, `127.0.0.1` and `[::1]`. - The token is sent as `Authorization: Bearer` and is never printed, cached or written to a report. ## Runtime monitoring Off until `runtime.enabled` is true. Sends only these aggregates per UTC minute, each with `count`, `errorCount`, `totalMs`, `maxMs` and its `sampleRate` (`docs/runtime-schema.json`): - requests: route name (or `unnamed`), HTTP method, status class (`2xx`, …); - slow queries: SQL text without bindings, with inline literals (single-quoted strings, numbers, hex and dollar-quoted literals) replaced by `?`; double-quoted and backtick-quoted identifiers stay, and the connection name; - jobs: job class, queue and connection names, `processed` or `failed`; - scheduled tasks: the description you set (paths relative to the project, secrets such as `token=…` masked as in exception messages), else `artisan ` for Artisan commands or the executable's file name for `exec()` tasks, never their arguments, else `Closure`; `finished`, `failed` or `skipped`; - outgoing HTTP (Laravel's HTTP client): host and status class. Never sent, and never written to the local buffer: URLs and query strings, request or response bodies, headers, cookies, users, IP addresses, query bindings, exception messages, `.env` values or absolute paths. ## Exception capture Off until `exceptions.enabled` is true, independent of runtime monitoring. One `reportable()` callback sees only what the app reports (`dontReport` applies), returns nothing so the app's own reporting is unchanged, and skips the classes in `exceptions.ignore`. Per group (same fingerprint, context and environment) it sends exactly (`docs/exception-schema.json`): - `class` (anonymous classes as `Parent@anonymous`), `fingerprint`, and the masked `message` (at most 1000 characters); - `frames`, innermost first, at most 50: `file` relative to the project root (`vendor//…` for dependencies, the bare file name outside the project), `line`, `function` (`Class::method`, closures without their line), `inApp`. Never arguments or local variables; - `previous`: the classes of chained exceptions (at most 5), without their messages; - `context`: `request` and the route name (`unnamed` without one), `job` and the job class, `command` and the Artisan command name without arguments, or `unknown`; - `environment` (`app.env`), `count`, `firstSeen` and `lastSeen`. Masking, applied before anything is written to the local buffer, in this order: | What | Becomes | | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | Matches of `exceptions.maskPatterns` | `[masked]` | | Absolute paths (`/…`, `C:\…`, `\\server\share\…`) | project root removed; other paths reduced to the file name | | URL credentials | removed | | URL hosts and ports | `[host]` (the scheme and path stay) | | URL query strings and fragments | `?[query]` | | Quoted SQL literals, when the message carries SQL | `?` | | Database `Host`, `Socket`, `Port`, `Database` details | `[masked]` | | JWTs | `[jwt]` | | `Bearer` / `Basic` credentials | `[token]` | | Any other `Authorization` / `Proxy-Authorization` value (`Token`, `Digest`, `AWS4-HMAC-SHA256`…) | `[masked]` | | Host names after `host:`, `hostname=`, `resolve host` or `getaddrinfo for` | `[host]` | | `password=`, `secret:`, `token=`, `api_key=` and similar values | `[masked]` | | Emails | `[email]` | | IPv4 and IPv6 addresses | `[ip]` | | UUIDs | `[uuid]` | | Hex strings of 32+ characters, 20+ character strings mixing letters and digits (API keys) | `[token]` | | Phone-like numbers (`+` and 8 or more digits with separators), US social security numbers, card-like runs of 13–19 digits, any number longer than four digits | `[number]` | Query exceptions are rebuilt from the driver message, the connection name and the SQL without bindings. Never sent: request payloads, headers, cookies, users, IP addresses, query bindings, `.env` values or absolute paths. ## Health endpoint Off until `healthEndpoint.enabled` is true and `EXCUBIA_HEALTH_SECRET` is set to at least 32 characters. It sends nothing by itself; it answers a `GET` that carries the secret, with check ids, check names, statuses, severities, counts and package or runtime names. Finding messages, fix hints and evidence URLs are not included, so no path, hostname, token or `.env` value reaches the response. A wrong or missing secret gets `404`, and the route is throttled (60 requests a minute per client IP). ## Local files | Path | Written by | Content | | ------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------- | | `--output` file | `check` | The report you asked for | | System temp `qiick-excubia-/` or `cache.directory` | all checks | Public data-source responses | | `storage/excubia/runtime/` | runtime monitoring | Aggregates waiting to be sent, capped by `runtime.bufferMaxBytes` | | `storage/excubia/exceptions/` | exception capture | Masked groups waiting to be sent, capped by `exceptions.bufferMaxBytes` | | `storage/excubia/health.json` | `excubia:check`, `excubia:push` with the endpoint on | The last health result | | Cache key `excubia:scheduler-heartbeat` | `excubia:heartbeat` | A Unix timestamp | Report a privacy or security problem as described in [`SECURITY.md`](https://github.com/qiick-io/excubia-core/blob/main/SECURITY.md). --- Source: [`docs/guides/privacy.md`](https://github.com/qiick-io/excubia-core/blob/main/docs/guides/privacy.md) in excubia-core. ---