adt / log-sanitizer
Removes sensitive data from payloads before they are logged or persisted: masking by key name, per-request secrets, card number detection.
Requires
- php: >=8.4
- ext-mbstring: *
Requires (Dev)
- nette/tester: ^2.5
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ězec —
passwordzabere i napasswordConfirm,tokennaaccessToken - kratší termín musí trefit celé slovo —
pinzabere nacard_pin, ale ne nashippingAddress
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í;
\na\tnechá - 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