adt/log-sanitizer

Removes sensitive data from payloads before they are logged or persisted: masking by key name, per-request secrets, card number detection.

Maintainers

Package info

github.com/AppsDevTeam/log-sanitizer

pkg:composer/adt/log-sanitizer

Transparency log

Statistics

Installs: 25

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-31 07:48 UTC

This package is auto-updated.

Last update: 2026-08-31 07:49:08 UTC


README

Odstraní citlivá data z payloadu, než se uloží do logu.

composer require adt/log-sanitizer

Proč

Request/response logy, auditní záznamy a transakční logy ukládají obsah, který složil někdo jiný — takže dopředu nevíš, co v něm bude. Do logu se tak běžně dostane token, heslo z formuláře nebo číslo karty a leží tam po celou dobu retence, čitelné pro každého, kdo má na tabulku přístup.

Balíček je bez závislostí (jen ext-mbstring), aby ho mohla použít knihovna, presenter i konzolový příkaz bez ohledu na framework.

Použití

$sanitizer = new SensitiveDataSanitizer();

// pole i vnořená struktura
$sanitizer->sanitize(['email' => 'a@b.cz', 'password' => 'Tajne123']);
// => ['email' => 'a@b.cz', 'password' => '***']

// surové tělo requestu: JSON dovnitř, JSON zpátky
$sanitizer->sanitizeJson($httpRequest->getRawBody());

// hlavičky: nositele přístupu vyhodí úplně
$sanitizer->sanitizeHeaders($headers);

Klíče zůstávají, mění se jen hodnoty — v logu má být vidět, že pole přišlo, jen ne s čím.

Maskování podle názvu klíče

Název se rozpadne na slova (camelCase i snake_case) a pak:

  • termín od 5 znaků se hledá jako podřetězecpassword zabere i na passwordConfirm, token na accessToken
  • kratší termín musí trefit celé slovopin zabere na card_pin, ale ne na shippingAddress

To druhé je důvod, proč tu není čistě podřetězcová shoda: str_contains('shipping', 'pin') je true, takže naivní implementace zamaskuje doručovací adresu a v logu zůstane díra, o které nikdo neví. Totéž company vs. pan.

Vlastní klíče:

$sanitizer->addSensitiveKeys('rodneCislo', 'iban');

// escape hatch, když porovnání podle slov nestačí
new SensitiveDataSanitizer(sensitivePatterns: ['/^x-internal-/i']);

Hodnoty bez stabilního názvu klíče

Když hodnota vzniká za běhu a může se objevit kdekoli — typicky vydaný token — zaregistruj ji a zmizí i z nesouvisejících polí a z textu:

$token = $this->tokenService->create(...);
$sanitizer->hideValue($token);
$this->sendJsonResponse(['token' => $token]);

Hodnoty krátší než 8 znaků se ignorují, jinak by zamaskovaly půl logu.

Čísla karet

Zapnuté ve výchozím stavu. Hledá v hodnotách 13–19 ciferná čísla (i s mezerami a pomlčkami), ověří Luhnovou kontrolou a nechá poslední čtyři číslice:

$sanitizer->sanitize('platba kartou 4111 1111 1111 1111');
// => 'platba kartou ************1111'

Luhn je tam proto, aby se nemaskovalo každé delší číslo — objednávky, EAN ani IČ neprojdou.

Samotný Luhn ale nestačí: projde jím náhodou každé desáté číslo. Časová značka 20250909095540 má 14 cifer, spadá do rozsahu PAN, a bez další kontroly by se v 10 % případů zamaskovala — měřeno na 20 000 reálných značkách. Sanitizer proto vylučuje řetězce, které vypadají jako YYYYMMDDHHMMSS nebo jako unixový čas v milisekundách. Žádné karetní schéma nezačíná 19xx ani 20xx, takže tím o skutečné karty nepřijdeš.

Už maskovaný PAN z terminálu (************1111) zůstává, jak přišel.

U dat, kde by maskování vadilo, se dá vypnout: withoutCardNumberMasking().

Karetní klíče

Klíče pan a card_number (včetně MaskedPAN, cardNumber…) se nenahrazují paušálně, ale jejich hodnota projde zkrácením:

['PAN'       => '4111111111111111']  =>  ['PAN'       => '************1111']
['MaskedPAN' => '************3035']  =>  ['MaskedPAN' => '************3035']

Zkrácení je metoda, kterou PCI DSS připouští, a poslední čtyřčíslí je potřeba k párování a reklamacím — paušální *** by je zničilo bez jakéhokoli zisku. Když číslo pod takovým klíčem neprojde Luhnem, zamaskuje se celé; při vypnutém maskování karet neprojde vůbec.

SAD (cvv, pin, track…) se naopak maskuje vždy celý — ten se ukládat nesmí ani zkrácený.

Rozpoznání schématu a upozornění

PAN má strukturu podle ISO/IEC 7812 a schémata mají charakteristické prefixy a délky (Visa 4…, MasterCard 51–55 a 2221–2720, Amex 34/37…). Prefix zúží falešné shody 8,2× — z 10 % na 1,2 % měřeno na časových značkách a náhodných ID.

Maskuje se přesto široce, jen podle délky a Luhna. Seznam prefixů stárne: dvojková řada MasterCard přišla až v roce 2017 a regexy, které ji neměly, tiše propouštěly platné karty. U sanitizeru je falešně negativní shoda dražší než falešně pozitivní.

Prefix se proto používá k něčemu jinému — k upozornění s vysokou jistotou:

$sanitizer->onCardNumberDetected(function (string $scheme, int $length): void {
    $this->logger->warning("V payloadu byl nemaskovaný PAN ($scheme, $length cifer).");
});

Listener dostane název schématu a délku, nikdy hodnotu — jinak by varování bylo dalším místem, kde PAN uniká. Smysl je nemaskovat potichu: PAN v logu znamená rozbitou integraci výš a někdo se to musí dozvědět, jinak zůstane databáze čistá a zdroj posílá PAN dál i jinam.

Rozpoznání jde použít i samostatně:

SensitiveDataSanitizer::detectCardScheme('4111111111111111');   // 'Visa'
SensitiveDataSanitizer::detectCardScheme('20260831054055');     // null

Prázdné hodnoty

Prázdná hodnota (null, '', []) se nemaskuje ani pod citlivým klíčem — skrýt není co a v logu je rozdíl mezi „pole bylo prázdné" a „pole mělo hodnotu" diagnosticky užitečný.

Co ještě dělá s řetězci

  • neplatné UTF-8 převede — jinak útočný request rozbije json_encode() při zápisu logu a log se neuloží vůbec
  • řídicí znaky odstraní; \n a \t nechá
  • totéž platí pro názvy klíčů — nevalidní UTF-8 v názvu pole rozbije json_encode() stejně jako v hodnotě; maskování se na klíče neaplikuje
  • base64 nad 255 znaků nahradí md5:<hash> — obrázky a přílohy log jen nafukují, hash stačí k rozpoznání, že šlo o tentýž obsah

Testy

make test