Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

## [Unreleased]

### Added

- **API-key prefix and IPv4 scrubbing (KD-0887).** The Scrubber now redacts Stripe-style `sk_live_...` keys, AWS `AKIA...` access key IDs, and IPv4 addresses, in addition to the existing JWT / Bearer / BSN / email coverage.
- **Database DSN password scrubbing (KD-0887).** Any `scheme://user:pass@host` credential — not just a fixed list of DB scheme names — has its password redacted (`scheme://user:[REDACTED:dsn-password]@host`), closing the most severe gap named in the KD-0885 audit debrief.
- **`QueryException` / `PDOException` carrier-strip (KD-0887).** A `QueryException` message embeds the full SQL string with bound parameter values interpolated in — free-text data (a name, an address, a care-data note) that is not a regex-able secret shape and previously reached the payload unredacted on the most common database-error path (surfaced by the emmie annexation, which carries NEN 7510 / AVG care data). `ErrorTracker` now replaces any `PDOException`-family message with `class [SQLSTATE x] [driver code y]` before the Scrubber even runs, dropping the SQL and bindings entirely while preserving the fingerprint.
- **`PathNormalizer` username redaction fallback (KD-0887, M-3).** A stack frame whose path does not start with `base_path()` (vendor installed outside the app root, a globally-installed tool) previously leaked the absolute path verbatim, including the OS username. A secondary pass now redacts the username segment of any `/home/<user>/` or `/Users/<user>/` shape in those frames.

### Fixed

- **BSN eleven-test (Dutch: elfproef) validation (KD-0887, M-1).** The KD-0885 fix widened the BSN pattern to any run of 9-or-more digits, which resolved the missed-detection leak but over-redacted legitimate 9+-digit IDs (order numbers, invoice IDs, timestamps) as `[REDACTED:bsn]`. BSN candidates — both grouped (`123.456.782`) and bare digit runs — are now validated against the eleven-test checksum before redaction, so only a number that is actually shaped like a real BSN redacts. A digit run is scanned for any contiguous 9-digit window that passes, so a real BSN embedded in a longer number is still caught without redacting the surrounding digits.
- **Eager `Dispatcher` injection broke the never-throw invariant.** `ErrorTracker` took `Illuminate\Contracts\Bus\Dispatcher` as a constructor dependency, so resolving the service threw a `BindingResolutionException` *before* `report()`'s try/catch whenever the Bus deferred provider was unresolvable. Because `report()` runs inside the consumer's exception handler, that throw escaped the reportable callback and replaced the original error with `Target [Illuminate\Contracts\Bus\Dispatcher] is not instantiable` (observed in a Laravel 12 app). The bus is now resolved lazily from the container inside `report()`'s guard, so the failure is swallowed and the original error is preserved.

## [0.1.0] — 2026-06-08
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ The client targets the kendo error-events ingestion endpoint shipped by KD-0771:

| Class | Responsibility |
|---|---|
| `ErrorTracker` | Public surface. `report(\Throwable)`: builds the scrubbed/normalized payload synchronously, then sends inline (sync) or dispatches `ReportErrorJob` (async). `send(array)`: the HTTP POST, swallow-on-failure. Reads config live from the `Config` repository. |
| `Scrubber` | Redacts JWT / Bearer / BSN / email from a string. |
| `ErrorTracker` | Public surface. `report(\Throwable)`: builds the scrubbed/normalized payload synchronously, then sends inline (sync) or dispatches `ReportErrorJob` (async). Any `\PDOException` (including Laravel's `QueryException`, which extends it) gets a carrier-strip first — the message becomes `class [SQLSTATE x] [driver code y]`, dropping the SQL string and bound parameter values before the Scrubber even runs. `send(array)`: the HTTP POST, swallow-on-failure. Reads config live from the `Config` repository. |
| `Scrubber` | Redacts JWT / Bearer / DSN password / API-key prefix / IPv4 / BSN (eleven-test validated) / email from a string. |
| `PathNormalizer` | Exact `base_path()` prefix strip of every frame (mirrors `laravel/nightwatch`'s `Location::normalizeFile()`). |
| `Jobs\ReportErrorJob` | Async carrier for the already-scrubbed payload. `$tries = 1` (0 retries); `failed()` logs to `error_log`, never requeues. |
| `ErrorTrackerServiceProvider` | Auto-discovered. Merges + publishes config; binds `ErrorTracker` + `PathNormalizer` (wired to the app's `base_path()`). |
Expand All @@ -54,6 +54,6 @@ The client targets the kendo error-events ingestion endpoint shipped by KD-0771:

SemVer. Pre-1.0 (`0.x`): minor bumps are treated as breaking (Composer's `^0.x` caret locks at minor). `main` is always release-ready; PRs update `CHANGELOG.md` under `[Unreleased]`; a release PR moves it to a versioned heading and tags the merge commit (`v0.x.y`). Packagist's webhook picks up the tag; `release.yml` re-runs CI and creates the GitHub release.

## Out of scope (v1)
## Out of scope

Client-side coalescing/debounce (server owns dedup + rate limit), per-project custom scrub rules (v1.5), a framework-agnostic core (Laravel-only), a JS/TS client (v1.5+), Sentry shim, context fields (schema-banned), API-key-prefix / IPv4 scrubbing (v1.5).
Client-side coalescing/debounce (server owns dedup + rate limit), per-project custom scrub rules (v1.5), a framework-agnostic core (Laravel-only), a JS/TS client (v1.5+), Sentry shim, context fields (schema-banned), phone numbers / session IDs as scrub patterns (no shape distinct enough to redact without a high false-positive rate), a `RequestException` message-body carrier-strip (raised as a "consider" in the KD-0887 discussion, referencing the war-room Nightwatch-egress recon — no concrete shape/threshold was specified, so it's deferred pending that follow-up rather than guessed at here).
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,16 @@ Before send, the message and stack trace are scrubbed of the following patterns
|---|---|
| JWT | `eyJhbGc...` (three base64url segments) |
| Bearer token | `Bearer <credential>` |
| BSN (Dutch citizen service number) | a 9-digit run |
| Database DSN password | `mysql://user:pass@host` — only the password is redacted |
| API-key prefix | `sk_live_...`, `AKIA...` |
| IPv4 address | `192.168.1.42` |
| BSN (Dutch citizen service number) | a 9-digit run passing the eleven-test (Dutch: elfproef) checksum |
| Email address | `[email protected]` |

BSN candidates are checksum-validated (the eleven-test) before redaction, so an arbitrary 9+-digit ID (an order number, invoice ID, or timestamp) is not falsely redacted, and a real BSN embedded in a longer digit run (e.g. a phone number) is still caught.

Free-text PII that isn't a fixed secret shape — a name, address, or care-data value embedded in a database error message — is not covered by pattern matching. `QueryException` and `PDOException` are instead handled by a per-exception-type carrier-strip: the message is replaced with just the exception class, SQLSTATE, and driver error code, dropping the SQL string and bound parameter values entirely.

## Path normalization

Each stack frame's absolute path has the app's own `base_path()` stripped (an **exact** prefix removal, mirroring `laravel/nightwatch`'s `Location::normalizeFile()`). The same exception thrown from `/var/www/html/app/Foo.php` and `/home/forge/app/Foo.php` normalizes to the identical `app/Foo.php`, so kendo fingerprints it once regardless of deploy root.
Expand Down
41 changes: 40 additions & 1 deletion src/ErrorTracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Illuminate\Contracts\Config\Repository as Config;
use Illuminate\Contracts\Container\Container;
use Illuminate\Http\Client\Factory as HttpFactory;
use PDOException;
use ScriptDevelopment\KendoErrorTracker\Jobs\ReportErrorJob;
use Throwable;

Expand Down Expand Up @@ -125,7 +126,7 @@ public function send(array $payload): void
*/
private function buildPayload(Throwable $throwable): array
{
$message = $this->scrubber->scrub($throwable->getMessage());
$message = $this->scrubber->scrub($this->safeMessage($throwable));
$stackTrace = $this->scrubber->scrub(
$this->pathNormalizer->normalize($throwable->getTraceAsString()),
);
Expand All @@ -145,6 +146,44 @@ private function buildPayload(Throwable $throwable): array
return array_filter($payload, static fn(mixed $value): bool => $value !== null);
}

/**
* Resolve the message to send: a database-carrier strip for any
* `PDOException` (including Laravel's `QueryException`, which extends
* it), otherwise the exception's own message unchanged (still passed
* through the Scrubber afterwards either way).
*
* A `QueryException` message embeds the full SQL string with bound
* parameter values interpolated in — free-text data (a name, an address,
* a care-data note) that is not a regex-able secret shape and would
* otherwise leak on the most common database-error path. The fingerprint
* (exception class + SQLSTATE + driver error code) survives; the bound
* values do not.
*/
private function safeMessage(Throwable $throwable): string
{
return $throwable instanceof PDOException
? $this->databaseCarrierMessage($throwable)
: $throwable->getMessage();
}

/**
* Build the class + SQLSTATE + driver-error-code fingerprint that
* replaces a database exception's message. `errorInfo` is PDO's
* `[SQLSTATE, driver code, driver message]` triple; `QueryException`
* copies it from its wrapped PDOException. Either piece may be absent
* (mocked/manually-constructed exceptions, or a previous exception that
* was not itself a PDOException), so both fall back to "unknown".
*/
private function databaseCarrierMessage(PDOException $throwable): string
{
$errorInfo = $throwable->errorInfo;

$sqlState = isset($errorInfo[0]) && is_scalar($errorInfo[0]) ? (string) $errorInfo[0] : 'unknown';
$driverCode = isset($errorInfo[1]) && is_scalar($errorInfo[1]) ? (string) $errorInfo[1] : 'unknown';

return sprintf('%s [SQLSTATE %s] [driver code %s]', $throwable::class, $sqlState, $driverCode);
}

/**
* Read a string config value, narrowing the repository's mixed return.
* Non-scalar / null values collapse to an empty string.
Expand Down
26 changes: 22 additions & 4 deletions src/PathNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use const DIRECTORY_SEPARATOR;

use function preg_replace;
use function str_replace;

/**
Expand All @@ -18,9 +19,20 @@
* mirroring `laravel/nightwatch`'s `Location::normalizeFile()` — not a guessed
* list of common deploy-root prefixes (that is the server's best-effort fallback
* for raw-HTTP callers, never the client's).
*
* A frame whose path does NOT start with `base_path()` (vendor installed
* outside the app root, a globally-installed tool) is left otherwise
* untouched by that strip, but still leaks the OS username via `/home/<user>/`
* or `/Users/<user>/` (M-3). A secondary redaction pass replaces just the
* username segment of those two shapes, everywhere in the trace, after the
* exact-prefix strip runs.
*/
final readonly class PathNormalizer
{
private const string HOME_USERNAME = '#/home/[^/\s]+/#';

private const string MAC_USERNAME = '#/Users/[^/\s]+/#';

private string $prefix;

public function __construct(string $basePath)
Expand All @@ -31,16 +43,22 @@ public function __construct(string $basePath)
}

/**
* Replace every occurrence of the base-path prefix in the trace string.
* Replace every occurrence of the base-path prefix in the trace string,
* then redact the username segment of any remaining `/home/<user>/` or
* `/Users/<user>/` path that did not start with the prefix.
*
* `getTraceAsString()` embeds absolute file paths inline (`#3 /abs/app/Foo.php(10): ...`),
* so a global replace of the prefix normalizes every frame at once. Paths
* that do not start with the prefix (vendor under a symlinked store, the
* trailing `{main}` marker) are left untouched — exactly nightwatch's
* "return unchanged when the prefix does not match" behavior.
* trailing `{main}` marker) are left otherwise untouched — exactly
* nightwatch's "return unchanged when the prefix does not match" behavior
* — but still pass through the username-redaction fallback below.
*/
public function normalize(string $trace): string
{
return str_replace($this->prefix, '', $trace);
$trace = str_replace($this->prefix, '', $trace);
$trace = (string) preg_replace(self::HOME_USERNAME, '/home/[REDACTED:user]/', $trace);

return (string) preg_replace(self::MAC_USERNAME, '/Users/[REDACTED:user]/', $trace);
}
}
Loading