Ir al contenido

Enmascaramiento de datos sensibles

QuietGuard\Monitor\Support\Scrubber enmascara los valores sensibles por nombre antes de que nada salga del proceso. Es agnóstico respecto al framework. Recorre arrays y, a partir de la versión 0.3.1, abre también las cadenas que llevan valores con nombre propios: un JSON escrito como cadena, una URL.

Configurar las claves

Pase los términos a enmascarar al constructor. Se almacenan en minúsculas, y una clave coincide en cuanto su nombre contiene uno de ellos, sin distinguir mayúsculas de minúsculas:

php
use QuietGuard\Monitor\Support\Scrubber;

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

Funcionamiento

scrub(array $data): array recorre el array de forma recursiva. Cuando una clave (de tipo cadena) contiene uno de los términos configurados, todo su valor se sustituye por la constante de máscara; en caso contrario, los arrays anidados se enmascaran a su vez y, a partir de la versión 0.3.1, un valor de tipo cadena también se lee (vea Las cadenas que llevan valores).

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

// [
//   'user_id'  => 42,
//   'password' => '[scrubbed]',
//   'headers'  => [
//       'Authorization' => '[scrubbed]',   // comparación sin distinción de mayúsculas
//       'Accept'        => 'application/json',
//   ],
// ]

Observaciones sobre el comportamiento:

  • La máscara es la constante pública Scrubber::MASK, cuyo valor es '[scrubbed]'.
  • La comparación se hace sobre nombres, nunca sobre la forma de un valor: el nombre de una clave y, a partir de la versión 0.3.1, el de un parámetro de consulta o de una clave JSON encontrados dentro de una cadena. La única excepción es el segmento de ruta de URL con forma de token, descrito más abajo. Reconocer un valor por su forma es el papel de la otra mitad.
  • La comparación es una búsqueda de subcadena sin distinción de mayúsculas: basta con que el nombre de la clave contenga un término configurado, de modo que un password configurado también enmascara user_password y PASSWORD_CONFIRMATION.
  • A partir de la versión 0.3.1, un guion y un guion bajo cuentan como el mismo carácter. La petición de Symfony, que Laravel también utiliza, nombra cada cabecera en minúsculas con guiones: la contraseña de una autenticación HTTP Basic llega ahí como php-auth-pw y una clave de API como x-api-key, que php_auth_pw y api_key enmascaran ahora.
  • A partir de la versión 0.3.1, un término que lleva un guion o un guion bajo entre dos de sus caracteres se busca también sin ellos, en un nombre despojado de los suyos: api_key enmascara así apikey (la cabecera de Supabase y de Kong), apiKey (un campo de formulario), x-apikey y X-API-KEY. Un término sin separador se compara como antes, y un término que empieza o termina con un separador, como el db_ de WordPress, conserva su grafía: sin él, db enmascararía feedback. El coste se acepta: el logged_in de la lista de WordPress también enmascara isLoggedIn y loggedInAt.
  • Una clave que coincide ve todo su valor enmascarado, ya sea un escalar o un array anidado.
  • Las claves numéricas / de lista nunca se consideran sensibles; solo las claves de tipo cadena se comparan.

Las cadenas que llevan valores

A partir de la versión 0.3.1, se abren tres tipos de cadena, porque llevan valores con nombre que ninguna clave del array nombra.

Un objeto o un array JSON escrito como cadena, como la instantánea (snapshot) que un componente Livewire devuelve con cada petición, se decodifica, se enmascara por clave como cualquier array y se vuelve a escribir. Cuando no se enmascaró nada dentro, sale byte a byte tal como llegó; cuando contenía un secreto y no puede volver a escribirse, se sustituye entero por la máscara. Cuando no se decodifica, porque está truncado o porque una máscara por forma ocupó en él el lugar de un número ([redacted:card]), se sustituye entero por la máscara en cuanto un nombre entre comillas seguido de : coincide en él ("password":), comparado como una clave, también después de una comilla suelta que desplaza los pares; si no, sale tal como llegó.

Una cadena que empieza por una URL absoluta, http o https, espacios iniciales incluidos, pasa por scrubUrl(string $url): string, que también puede llamar directamente:

  • el valor de cada parámetro de la cadena de consulta cuyo nombre coincide se enmascara, y también el de cada par nombre=valor de un fragmento cuyo nombre coincide (#access_token=...). La máscara va codificada, %5Bscrubbed%5D, para que la dirección siga siendo una dirección;
  • a partir de la versión 0.3.1, cuatro nombres de parámetro se enmascaran sea cual sea la lista, porque una dirección que lleva uno es en sí misma una credencial hasta que caduca: hash (el enlace de inicio de sesión de Symfony, junto a user y expires), _hash (la firma del UriSigner de Symfony, junto a _expiration), sig (la firma de acceso compartido de Azure, SAS) y signature. El nombre debe ser exacto, comparado decodificado y sin distinguir mayúsculas: hashtag o content_hash se conservan. La regla solo vale en una dirección: una clave de array, una clave JSON y una línea de código fuente que llama a hash('sha256', ...) no se ven afectadas. La lista es la constante Scrubber::URL_CREDENTIALS;
  • todo segmento de ruta formado por cuarenta letras o cifras seguidas, o más, se enmascara de oficio, diga lo que diga la lista: ahí lleva su token el enlace de restablecimiento de contraseña de Laravel (Breeze, Fortify, Jetstream), /reset-password/{token}. Un número, un slug, un UUID o un ULID se quedan, ya que la ruta es lo que dice dónde falló la aplicación;
  • el esquema, el host, el puerto y el usuario de un usuario:contraseña@ viajan tal cual; su contraseña se enmascara (usuario:%5Bscrubbed%5D@), ya que una URL escrita en un campo de formulario o en un ajuste de webhook la lleva ahí. Una credencial escrita en lugar del usuario (https://ghp_...@github.com) se queda por tanto, con el usuario. Una contraseña que contiene un /, un ? o un # sin codificar no se reconoce, porque la dirección termina antes: escríbala codificada (%2F, %3F, %23), como exige una URL;
  • la URL termina en el primer espacio, y el texto que la sigue se devuelve tal cual, salvo un par abierto por &, ? o # cuyo nombre coincide: una dirección escrita a mano con un espacio sin codificar (?q=hola mundo&token=abc) empuja ahí el final de su propia cadena de consulta;
  • una dirección relativa (/reset?token=...), o una URL que no abre la cadena, no se alcanza.
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

Una cadena que empieza por una dirección de otro esquema, casi siempre un DSN (mysql://root:secret@db/app, redis://:secret@cache:6379), espacios iniciales incluidos, ve enmascarada del mismo modo la contraseña de su usuario:contraseña@, y también el valor de cada parámetro de su cadena de consulta cuyo nombre coincide, ya que un DSN escribe ahí sus opciones, una contraseña entre ellas (tcp://127.0.0.1:6379?password=..., que lee Predis). La regla de los segmentos de ruta con forma de token sigue siendo la de la web: la ruta de un DSN es un nombre de base de datos o un archivo.

Las líneas de código fuente

scrubLines(array $lines): array enmascara las líneas de un fragmento de código, y scrubSnippets(array $frames): array lo aplica al fragmento (code.lines) que cada frame puede llevar. Una línea se sustituye entera por la máscara cuando un identificador que contiene un término de la lista, o una palabra de Scrubber::LINE_NEEDLES (key, auth, credential, salt, private, dsn, bearer, sk_, pk_), recibe un valor:

  • una asignación o un separador de clave: =, => o un : solo ($secret = '...', 'api_key' => '...');
  • a partir de la versión 0.3.1, un índice nombrado entre comillas y seguido de un = ($headers['Authorization'] = '...');
  • un nombre entre comillas antes de una coma (define('API_KEY', '...'));
  • una llamada cuyo primer argumento es un literal (setApiKey('sk_live_...')).

Una comparación con == o === cuyo nombre está a la izquierda cuenta como un valor, para una variable como para un índice (if ($token == $expected), if ($config['password'] === '...')): su otro miembro puede ser el propio secreto, escrito en el código. !=, !== y una comparación cuyo nombre está a la derecha (if ('...' === $token)) no cuentan, y la línea viaja. Una línea que solo usa el nombre se queda: Hash::check($password, $hash), return csrf_token();, return $headers['Authorization'];. A partir de la versión 0.3.1, :: es un ámbito y no un separador, de modo que Auth::user() o TokenMismatchException::expired() se quedan también; la versión 0.3.0 enmascaraba esas líneas, la del lanzamiento incluida. A partir de la misma versión, un término se prueba también con su otra grafía cuando un guion o un guion bajo separa en él dos letras o cifras, php_auth_pw y php-auth-pw, para que un 'php-auth-pw' => '...' escrito en duro salga enmascarado. La línea en sí nunca se reescribe, y un término que empieza o termina por un separador, como el db_ de WordPress o las palabras sk_ y pk_, conserva su única grafía: la otra empieza o termina una palabra corriente ('db-new', 'disk-usage' => 90).

Cualquier otra forma viaja tal cual: un literal que no nombra ninguna de estas palabras, o una asignación por un índice que no va entre comillas ($data[$key] = '...').

Dónde se aplica

El Reporter ejecuta el scrubber automáticamente:

  • reportException() enmascara el contexto de la excepción y las líneas de fragmento de su traza (scrubSnippets()) antes del envío.
  • sendLogs() enmascara el array de logs antes del envío. A partir de la versión 0.3.1, las cadenas se abren ahí como en cualquier otro sitio, el mensaje incluido: cuando empieza por una URL o es JSON, los valores que lleva se enmascaran según las mismas reglas, y el resto de su texto se conserva.
  • sendDependencies() no enmascara: un manifiesto de dependencias contiene nombres y versiones de paquetes, no secretos.

Si construye los payloads usted mismo, llame a scrub() sobre el array correspondiente antes de confiarlo a un transporte.

La otra mitad: el enmascarado por forma

Un nombre de clave nunca ve una dirección escrita en el texto libre de un mensaje de error, en un segmento de URL o en un campo que alguien llamó reference. Support\ValueRedactor lee el valor, y el Reporter lo aplica a todo el payload como última operación antes del envío, tanto en excepciones como en logs.

Viene activo, con email, iban, nir, card y phone. Las formas que llevan un dígito de control se verifican en vez de solo reconocerse, de modo que una referencia de pedido de dieciséis dígitos no se toma por un número de tarjeta, y cada máscara nombra lo que ocultó ([redacted:email]) para que el mensaje siga siendo legible. Se configura desde Config: redact elige las formas, customRedactions añade las suyas.

Está leyendo la documentación PHP Core v1.0.