feat: anonymization command for request/response XML dumps

This commit is contained in:
Björn Fromme
2026-03-16 12:03:00 +01:00
parent 5cbdc5eaf2
commit 0d7d90a442
5 changed files with 744 additions and 1 deletions
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\BpnXmlAnonymizer;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:bpn:xml-anonymize',
description: 'Anonymize personal data in BPN XML request/response dumps',
)]
class BpnXmlAnonymizeCommand extends Command
{
public function __construct(
private readonly BpnXmlAnonymizer $xmlAnonymizer,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('infile', InputArgument::REQUIRED, 'Path to the input XML file')
->addArgument('outfile', InputArgument::OPTIONAL, 'Path to the output XML file (defaults to infile)')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$inputFile = (string) $input->getArgument('infile');
$outputFile = $input->getArgument('outfile');
if (false === is_file($inputFile)) {
$io->error(sprintf('Input file not found: %s', $inputFile));
return Command::FAILURE;
}
if (false === is_readable($inputFile)) {
$io->error(sprintf('Input file is not readable: %s', $inputFile));
return Command::FAILURE;
}
$targetFile = null === $outputFile ? $inputFile : (string) $outputFile;
$this->xmlAnonymizer->resetState();
$processingResult = $this->anonymizeFile($io, $inputFile, $targetFile, false);
if (Command::SUCCESS !== $processingResult) {
return $processingResult;
}
$pairedInputFile = $this->derivePairedFilename($inputFile);
if (null !== $pairedInputFile && true === is_file($pairedInputFile)) {
if (false === is_readable($pairedInputFile)) {
$io->error(sprintf('Paired file is not readable: %s', $pairedInputFile));
return Command::FAILURE;
}
$pairedTargetFile = $this->derivePairedOutputFilename($targetFile, $inputFile, $pairedInputFile);
if (null === $pairedTargetFile) {
$io->error(sprintf(
'Unable to derive paired output filename from: %s',
$targetFile
));
return Command::FAILURE;
}
$processingResult = $this->anonymizeFile($io, $pairedInputFile, $pairedTargetFile, true);
if (Command::SUCCESS !== $processingResult) {
return $processingResult;
}
$io->note(sprintf('Paired file anonymized: %s -> %s', $pairedInputFile, $pairedTargetFile));
}
$io->success(sprintf(
'Anonymized %d persons from %s to %s',
$this->xmlAnonymizer->getLastIdentityCount(),
$inputFile,
$targetFile,
));
return Command::SUCCESS;
}
private function anonymizeFile(SymfonyStyle $io, string $inputFile, string $targetFile, bool $keepState): int
{
$xml = file_get_contents($inputFile);
if (false === $xml) {
$io->error(sprintf('Unable to read input file: %s', $inputFile));
return Command::FAILURE;
}
try {
$anonymizedXml = $this->xmlAnonymizer->anonymize($xml, false === $keepState);
} catch (\InvalidArgumentException|\RuntimeException $exception) {
$io->error(sprintf('Failed to anonymize %s: %s', $inputFile, $exception->getMessage()));
return Command::FAILURE;
}
$writeResult = file_put_contents($targetFile, $anonymizedXml);
if (false === $writeResult) {
$io->error(sprintf('Unable to write output file: %s', $targetFile));
return Command::FAILURE;
}
return Command::SUCCESS;
}
private function derivePairedFilename(string $filename): ?string
{
if (str_ends_with($filename, '_request.xml')) {
return substr($filename, 0, -12).'_response.xml';
}
if (str_ends_with($filename, '_response.xml')) {
return substr($filename, 0, -13).'_request.xml';
}
return null;
}
private function derivePairedOutputFilename(string $outputFile, string $inputFile, string $pairedInputFile): ?string
{
if (str_ends_with($inputFile, '_request.xml') && str_ends_with($pairedInputFile, '_response.xml')) {
if (false === str_ends_with($outputFile, '_request.xml')) {
return null;
}
return substr($outputFile, 0, -12).'_response.xml';
}
if (str_ends_with($inputFile, '_response.xml') && str_ends_with($pairedInputFile, '_request.xml')) {
if (false === str_ends_with($outputFile, '_response.xml')) {
return null;
}
return substr($outputFile, 0, -13).'_request.xml';
}
return null;
}
}
+301
View File
@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Faker\Factory;
use Faker\Generator;
class BpnXmlAnonymizer
{
private const SAFE_EMAIL_DOMAIN = 'example.com';
private Generator $faker;
/**
* @var array<string, array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string}>
*/
private array $identities = [];
private int $fallbackIdentityCounter = 0;
public function __construct()
{
$this->faker = Factory::create('de_DE');
}
public function anonymize(string $xml, bool $resetState = true): string
{
if (true === $resetState) {
$this->resetState();
}
$document = new \DOMDocument();
$document->preserveWhiteSpace = true;
$document->formatOutput = false;
if (false === @$document->loadXML($xml)) {
throw new \InvalidArgumentException('Invalid XML input.');
}
$xpath = new \DOMXPath($document);
$personNodes = $xpath->query('//anmelder | //teilnehmerliste/teilnehmer | //kundennamen/kunde');
if (false === $personNodes) {
throw new \RuntimeException('Unable to query person nodes.');
}
foreach ($personNodes as $index => $personNode) {
if (false === $personNode instanceof \DOMElement) {
continue;
}
$personId = $this->resolvePersonId($personNode, (int) $index);
$identity = $this->getOrCreateIdentity($personId);
$this->replaceNameFields($personNode, $identity);
$this->replaceEmailFields($personNode, $identity);
$this->replaceAddressFields($personNode, $identity);
}
$result = $document->saveXML();
if (false === $result) {
throw new \RuntimeException('Unable to serialize anonymized XML.');
}
return $result;
}
public function getLastIdentityCount(): int
{
return \count($this->identities);
}
public function resetState(): void
{
$this->identities = [];
$this->fallbackIdentityCounter = 0;
}
private function resolvePersonId(\DOMElement $personNode, int $nodeIndex): string
{
foreach (['personid', 'idperson', 'idadresseperson'] as $tagName) {
$child = $this->getDirectChild($personNode, $tagName);
if (null !== $child) {
$value = trim($child->textContent);
if ('' !== $value) {
return $value;
}
}
}
$fingerprintKey = $this->buildFingerprintKey($personNode);
if (null !== $fingerprintKey) {
return $fingerprintKey;
}
if (true === $personNode->hasAttribute('id')) {
$attributeValue = trim((string) $personNode->getAttribute('id'));
if ('' !== $attributeValue) {
return sprintf('attr-id:%s', $attributeValue);
}
}
++$this->fallbackIdentityCounter;
return sprintf('fallback:%d:%d', $nodeIndex, $this->fallbackIdentityCounter);
}
private function buildFingerprintKey(\DOMElement $personNode): ?string
{
$nameValues = [];
$nameNode = $this->getDirectChild($personNode, 'name');
if (null !== $nameNode) {
$nameValues[] = trim($nameNode->textContent);
}
if (true === $personNode->hasAttribute('name')) {
$nameValues[] = trim((string) $personNode->getAttribute('name'));
}
$firstNameNode = $this->getDirectChild($personNode, 'vorname');
if (null !== $firstNameNode) {
$nameValues[] = trim($firstNameNode->textContent);
}
if (true === $personNode->hasAttribute('vorname')) {
$nameValues[] = trim((string) $personNode->getAttribute('vorname'));
}
$communicationNode = $this->getDirectChild($personNode, 'kommunikation');
if (null !== $communicationNode) {
$emailNode = $this->getDirectChild($communicationNode, 'email');
if (null !== $emailNode) {
$nameValues[] = trim($emailNode->textContent);
}
}
$addressNode = $this->getDirectChild($personNode, 'anschrift');
if (null !== $addressNode) {
foreach (['strasse', 'plz', 'ort'] as $fieldName) {
$fieldNode = $this->getDirectChild($addressNode, $fieldName);
if (null !== $fieldNode) {
$nameValues[] = trim($fieldNode->textContent);
}
}
}
$normalizedParts = [];
foreach ($nameValues as $value) {
if ('' === $value) {
continue;
}
$normalizedParts[] = mb_strtolower($value);
}
if (0 === \count($normalizedParts)) {
return null;
}
return sprintf('fp:%s', md5(implode('|', $normalizedParts)));
}
/**
* @param string $personId
*
* @return array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string}
*/
private function getOrCreateIdentity(string $personId): array
{
if (true === isset($this->identities[$personId])) {
return $this->identities[$personId];
}
$firstName = $this->faker->firstName();
$lastName = $this->faker->lastName();
$street = sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber());
$postalCode = $this->faker->postcode();
$city = $this->faker->city();
$mobilePhone = $this->faker->numerify('01#########');
$emailLocal = sprintf('%s.%s', $this->slug($firstName), $this->slug($lastName));
$email = sprintf('%s@%s', $emailLocal, self::SAFE_EMAIL_DOMAIN);
$this->identities[$personId] = [
'firstName' => $firstName,
'lastName' => $lastName,
'street' => $street,
'postalCode' => $postalCode,
'city' => $city,
'email' => strtolower($email),
'mobilePhone' => $mobilePhone,
];
return $this->identities[$personId];
}
/**
* @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
*/
private function replaceNameFields(\DOMElement $personNode, array $identity): void
{
$nameNode = $this->getDirectChild($personNode, 'name');
if (null !== $nameNode) {
$nameNode->nodeValue = $identity['lastName'];
}
$firstNameNode = $this->getDirectChild($personNode, 'vorname');
if (null !== $firstNameNode) {
$firstNameNode->nodeValue = $identity['firstName'];
}
if (true === $personNode->hasAttribute('name')) {
$personNode->setAttribute('name', $identity['lastName']);
}
if (true === $personNode->hasAttribute('vorname')) {
$personNode->setAttribute('vorname', $identity['firstName']);
}
}
/**
* @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
*/
private function replaceEmailFields(\DOMElement $personNode, array $identity): void
{
$communicationNode = $this->getDirectChild($personNode, 'kommunikation');
if (null === $communicationNode) {
return;
}
$emailNode = $this->getDirectChild($communicationNode, 'email');
if (null !== $emailNode) {
$emailNode->nodeValue = $identity['email'];
}
$mobileNode = $this->getDirectChild($communicationNode, 'telefonmobil');
if (null !== $mobileNode) {
$mobileNode->nodeValue = $identity['mobilePhone'];
}
}
/**
* @param array{firstName: string, lastName: string, street: string, postalCode: string, city: string, email: string, mobilePhone: string} $identity
*/
private function replaceAddressFields(\DOMElement $personNode, array $identity): void
{
$addressNode = $this->getDirectChild($personNode, 'anschrift');
if (null === $addressNode) {
return;
}
$streetNode = $this->getDirectChild($addressNode, 'strasse');
if (null !== $streetNode) {
$streetNode->nodeValue = $identity['street'];
}
$postalCodeNode = $this->getDirectChild($addressNode, 'plz');
if (null !== $postalCodeNode) {
$postalCodeNode->nodeValue = $identity['postalCode'];
}
$cityNode = $this->getDirectChild($addressNode, 'ort');
if (null !== $cityNode) {
$cityNode->nodeValue = $identity['city'];
}
}
private function getDirectChild(\DOMElement $parent, string $name): ?\DOMElement
{
foreach ($parent->childNodes as $childNode) {
if (false === $childNode instanceof \DOMElement) {
continue;
}
if ($childNode->tagName === $name) {
return $childNode;
}
}
return null;
}
private function slug(string $value): string
{
$normalizedValue = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
if (false === $normalizedValue) {
$normalizedValue = $value;
}
$normalizedValue = strtolower($normalizedValue);
$normalizedValue = preg_replace('/[^a-z0-9]+/', '.', $normalizedValue);
$normalizedValue = trim((string) $normalizedValue, '.');
if ('' === $normalizedValue) {
return 'user';
}
return $normalizedValue;
}
}