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:
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).
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
passwordalso masksuser_passwordandPASSWORD_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-pwand an API key asx-api-key, whichphp_auth_pwandapi_keynow 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_keytherefore masksapikey(the header of Supabase and Kong),apiKey(a form field),x-apikeyandX-API-KEY. A term with no separator is compared as before, and a term that starts or ends with a separator, such as WordPress'sdb_, keeps its spelling: without it,dbwould maskfeedback. The cost is accepted: thelogged_inof the WordPress list also masksisLoggedInandloggedInAt. - 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=valuepair 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, besideuserandexpires),_hash(the signature of Symfony'sUriSigner, beside_expiration),sig(Azure's shared access signature, SAS) andsignature. The name must be exact, compared decoded and regardless of case:hashtagorcontent_hashstay. The rule holds in an address only: an array key, a JSON key and a source line callinghash('sha256', ...)are not affected. The list is theScrubber::URL_CREDENTIALSconstant; - 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.
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.