Skip to content

Scrubbing sensitive data

QuietGuard\Monitor\Support\Scrubber masks sensitive values by name before anything leaves the process. It is framework-agnostic. It walks arrays and, from version 0.3.1, also opens the strings that carry named values of their own: JSON written as a string, a URL.

Configuring keys

Pass the terms you want masked to the constructor. They are stored lower-cased, and a key matches as soon as its name contains one of them, case-insensitively:

php
use QuietGuard\Monitor\Support\Scrubber;

$scrubber = new Scrubber(['password', 'authorization', 'api_key', 'secret']);

How it works

scrub(array $data): array walks the array recursively. When a key (a string key) contains one of the configured terms, its whole value is replaced with the mask constant; otherwise nested arrays are scrubbed in turn and, from version 0.3.1, a string value is read as well (see Strings that carry values).

php
$clean = $scrubber->scrub([
    'user_id'  => 42,
    'password' => 'hunter2',
    'headers'  => [
        'Authorization' => 'Bearer abc',
        'Accept'        => 'application/json',
    ],
]);

// [
//   'user_id'  => 42,
//   'password' => '[scrubbed]',
//   'headers'  => [
//       'Authorization' => '[scrubbed]',   // matched case-insensitively
//       'Accept'        => 'application/json',
//   ],
// ]

Notes on the behaviour:

  • The mask is the public constant Scrubber::MASK, which is '[scrubbed]'.
  • Matching is on names, never on the shape of a value: the name of a key and, from version 0.3.1, the name of a query parameter or of a JSON key found inside a string. The one exception is the token-shaped URL path segment described below. Recognising a value by its shape is the job of the other half.
  • Matching is a case-insensitive substring check: the key name only has to contain a configured term, so a configured password also masks user_password and PASSWORD_CONFIRMATION.
  • From version 0.3.1, a hyphen and an underscore count as the same character. Symfony's request, which Laravel uses too, names every header lower-cased with hyphens: the password of an HTTP Basic authentication arrives there as php-auth-pw and an API key as x-api-key, which php_auth_pw and api_key now mask.
  • From version 0.3.1, a term that carries a hyphen or an underscore between two of its characters is also looked for without them, in a name stripped of its own: api_key therefore masks apikey (the header of Supabase and Kong), apiKey (a form field), x-apikey and X-API-KEY. A term with no separator is compared as before, and a term that starts or ends with a separator, such as WordPress's db_, keeps its spelling: without it, db would mask feedback. The cost is accepted: the logged_in of the WordPress list also masks isLoggedIn and loggedInAt.
  • A matched key has its entire value masked, whether that value is a scalar or a nested array.
  • Numeric/list keys are never treated as sensitive; only string keys are compared.

Strings that carry values

From version 0.3.1, three kinds of string are opened, because they carry named values that no key of the array names.

A JSON object or array written as a string, such as the snapshot a Livewire component sends back with every request, is decoded, masked by key like any array, then written back. When nothing inside was masked, it goes out byte for byte as it came; when it held a secret and cannot be written back, it is replaced whole with the mask. When it does not decode, because it is cut short or because a mask by shape took the place of a number in it ([redacted:card]), it is replaced whole with the mask as soon as a quoted name followed by a : in it matches ("password":), compared as a key is, including after a stray quote that shifts the pairs; otherwise it goes out as it came.

A string that starts with an absolute URL, http or https, leading whitespace included, goes through scrubUrl(string $url): string, which you can also call directly:

  • the value of every query-string parameter whose name matches is masked, and so is the value of every name=value pair of a fragment whose name matches (#access_token=...). The mask is encoded there, %5Bscrubbed%5D, so that the address stays an address;
  • from version 0.3.1, four parameter names are masked whatever the list holds, because an address carrying one is itself a credential until it expires: hash (Symfony's login link, beside user and expires), _hash (the signature of Symfony's UriSigner, beside _expiration), sig (Azure's shared access signature, SAS) and signature. The name must be exact, compared decoded and regardless of case: hashtag or content_hash stay. The rule holds in an address only: an array key, a JSON key and a source line calling hash('sha256', ...) are not affected. The list is the Scrubber::URL_CREDENTIALS constant;
  • every path segment made of forty letters or digits in a row, or more, is masked outright, whatever the list says: that is where Laravel's password reset link (Breeze, Fortify, Jetstream) carries its token, /reset-password/{token}. A number, a slug, a UUID or a ULID stays, since the path is what says where the application broke;
  • the scheme, the host, the port and the user of a user:password@ travel as they came; its password is masked (user:%5Bscrubbed%5D@), since a URL typed into a form field or a webhook setting carries it there. A credential written in place of the user (https://ghp_...@github.com) therefore stays, with the user. A password that contains an unencoded /, ? or # is not recognised, since the address ends before it: write it percent-encoded (%2F, %3F, %23), as a URL requires;
  • the URL ends at the first whitespace, and the text after it is handed back as it came, except a pair opened by &, ? or # whose name matches: an address written by hand with an unencoded space (?q=hello world&token=abc) pushes the rest of its own query string there;
  • a relative address (/reset?token=...), or a URL that does not open the string, is not reached.
php
$scrubber = new Scrubber(['token', 'signature']);

$scrubber->scrubUrl('https://shop.test/reset-password/'.$token.'?email=alex%40example.com');
// https://shop.test/reset-password/%5Bscrubbed%5D?email=alex%40example.com

$scrubber->scrubUrl('https://shop.test/reset?email=alex%40example.com&token=abc123&signature=9f8e7d');
// https://shop.test/reset?email=alex%40example.com&token=%5Bscrubbed%5D&signature=%5Bscrubbed%5D

A string that starts with an address of another scheme, most often a DSN (mysql://root:secret@db/app, redis://:secret@cache:6379), leading whitespace included, has the password of its user:password@ masked the same way, and so is the value of every query-string parameter whose name matches, since a DSN writes its options there, a password among them (tcp://127.0.0.1:6379?password=..., which Predis reads). The token-shaped path segment rule stays the web's: a DSN's path is a database name or a file.

Source code lines

scrubLines(array $lines): array masks the lines of a code snippet, and scrubSnippets(array $frames): array applies it to the snippet (code.lines) each frame may carry. A line is replaced whole with the mask when an identifier containing a term of the list, or a word of Scrubber::LINE_NEEDLES (key, auth, credential, salt, private, dsn, bearer, sk_, pk_), is given a value:

  • an assignment or a key separator: =, => or a single : ($secret = '...', 'api_key' => '...');
  • from version 0.3.1, an array index named in quotes and followed by = ($headers['Authorization'] = '...');
  • a quoted name before a comma (define('API_KEY', '...'));
  • a call whose first argument is a literal (setApiKey('sk_live_...')).

A comparison by == or === with the name on the left counts as a value, for a variable as for an index (if ($token == $expected), if ($config['password'] === '...')): its other side may be the secret itself, written into the code. !=, !== and a comparison with the name on the right (if ('...' === $token)) do not count, and the line travels. A line that only uses the name stays: Hash::check($password, $hash), return csrf_token();, return $headers['Authorization'];. From version 0.3.1, :: is a scope and not a separator, so Auth::user() or TokenMismatchException::expired() stay too; version 0.3.0 masked those lines, the line of the throw included. From the same version, a term is also tried in its other spelling when a hyphen or an underscore inside it sits between two letters or digits, php_auth_pw and php-auth-pw, so that a hardcoded 'php-auth-pw' => '...' goes out masked. The line itself is never rewritten, and a term that starts or ends with a separator, such as WordPress's db_ or the words sk_ and pk_, keeps its one spelling: the other one starts or ends an ordinary word ('db-new', 'disk-usage' => 90).

Any other shape travels as it is: a literal that names none of these words, or an assignment through an index that is not in quotes ($data[$key] = '...').

Where it is applied

The Reporter runs the scrubber automatically:

  • reportException() scrubs the exception context and the snippet lines of its trace (scrubSnippets()) before sending.
  • sendLogs() scrubs the logs array before sending. From version 0.3.1, the strings in it are opened like anywhere else, the message included: when it starts with a URL or is JSON, the values it carries are masked by the same rules, and the rest of its text is kept.
  • sendDependencies() does not scrub: a dependency manifest contains package names and versions, not secrets.

If you build payloads yourself, call scrub() on the relevant array before handing it to a transport.

The other half: masking by shape

A key name never sees an address written into the free text of an error message, into a URL segment, or into a field somebody called reference. Support\ValueRedactor reads the value instead, and the Reporter runs it over the whole payload as the last thing before the wire, exceptions and logs alike.

It ships on, with email, iban, nir, card and phone. Shapes carrying a check digit are verified rather than merely matched, so a sixteen digit order reference is not taken for a card number, and each mask names what it hid ([redacted:email]) so the message stays readable. Configure it through Config: redact selects the shapes, customRedactions adds your own.

You are reading the PHP Core v1.0 documentation.