From 90fc66277e3993f6ea1ac58c0aa2e06db047beda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 4 Mar 2026 10:47:20 +0100 Subject: [PATCH] feat: anonymization command for request/response XML dumps --- composer.json | 1 + composer.lock | 65 +++++- src/Command/BpnXmlAnonymizeCommand.php | 158 +++++++++++++ src/Service/BpnXmlAnonymizer.php | 301 +++++++++++++++++++++++++ tests/Service/BpnXmlAnonymizerTest.php | 220 ++++++++++++++++++ 5 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 src/Command/BpnXmlAnonymizeCommand.php create mode 100644 src/Service/BpnXmlAnonymizer.php create mode 100644 tests/Service/BpnXmlAnonymizerTest.php diff --git a/composer.json b/composer.json index a9ef29c..2748b64 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.3", "easycorp/easyadmin-bundle": "^4.27", + "fakerphp/faker": "^1.24", "league/flysystem-bundle": "^3.4", "league/flysystem-sftp-v3": "^3.29", "league/oauth2-server-bundle": "^1.0", diff --git a/composer.lock b/composer.lock index 8436b5e..3bd2880 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e3fae85e0cff82354aeffcb409015eec", + "content-hash": "ff6bf7b0734ba021721679b2c94873a8", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -1578,6 +1578,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, { "name": "lcobucci/clock", "version": "3.5.0", diff --git a/src/Command/BpnXmlAnonymizeCommand.php b/src/Command/BpnXmlAnonymizeCommand.php new file mode 100644 index 0000000..6ce6ab6 --- /dev/null +++ b/src/Command/BpnXmlAnonymizeCommand.php @@ -0,0 +1,158 @@ +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; + } +} diff --git a/src/Service/BpnXmlAnonymizer.php b/src/Service/BpnXmlAnonymizer.php new file mode 100644 index 0000000..586ea5a --- /dev/null +++ b/src/Service/BpnXmlAnonymizer.php @@ -0,0 +1,301 @@ + + */ + 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; + } +} diff --git a/tests/Service/BpnXmlAnonymizerTest.php b/tests/Service/BpnXmlAnonymizerTest.php new file mode 100644 index 0000000..70190c0 --- /dev/null +++ b/tests/Service/BpnXmlAnonymizerTest.php @@ -0,0 +1,220 @@ + + + + Schlegelmilch + Mandy + + Am Jägersteig 19 + 40724 + Hilden + + + mandy.schlegelmilch@ep-reisen.de + 02212722760 + + 255752 + + + + Schlegelmilch + Mandy + + Am Jägersteig 19 + 40724 + Hilden + + + mandy.schlegelmilch@ep-reisen.de + 02212722760 + + 255752 + + + Danielzik + Leon + + leondanielzik@gmail.com + + 306886 + + + +XML; + + $anonymizer = new BpnXmlAnonymizer(); + $result = $anonymizer->anonymize($xml); + + $document = new \DOMDocument(); + $this->assertTrue($document->loadXML($result)); + + $xpath = new \DOMXPath($document); + + $applicantName = trim((string) $xpath->evaluate('string(//anmelder/name)')); + $participantOneName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/name)')); + $participantOneEmail = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/kommunikation/email)')); + $applicantMobilePhone = trim((string) $xpath->evaluate('string(//anmelder/kommunikation/telefonmobil)')); + $participantOneMobilePhone = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/kommunikation/telefonmobil)')); + $participantTwoName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="2"]/name)')); + + $this->assertNotSame('Schlegelmilch', $applicantName); + $this->assertSame($applicantName, $participantOneName); + $this->assertStringEndsWith('@example.com', $participantOneEmail); + $this->assertNotSame('02212722760', $participantOneMobilePhone); + $this->assertSame($applicantMobilePhone, $participantOneMobilePhone); + $this->assertNotSame($participantOneName, $participantTwoName); + $this->assertSame(2, $anonymizer->getLastIdentityCount()); + } + + public function testAnonymizeReplacesKundeAttributes(): void + { + $xml = <<<'XML' + + + + + + + + + + + +XML; + + $anonymizer = new BpnXmlAnonymizer(); + $result = $anonymizer->anonymize($xml); + + $document = new \DOMDocument(); + $this->assertTrue($document->loadXML($result)); + + $xpath = new \DOMXPath($document); + + $firstCustomerLastName = trim((string) $xpath->evaluate('string(//kunde[@id="1"]/@name)')); + $firstCustomerFirstName = trim((string) $xpath->evaluate('string(//kunde[@id="1"]/@vorname)')); + + $this->assertNotSame('Fromme', $firstCustomerLastName); + $this->assertNotSame('Björn', $firstCustomerFirstName); + } + + public function testAnonymizeThrowsOnInvalidXml(): void + { + $anonymizer = new BpnXmlAnonymizer(); + + $this->expectException(\InvalidArgumentException::class); + + $anonymizer->anonymize(' + + + Schlegelmilch + Mandy + + Am Jägersteig 19 + 40724 + Hilden + + + mandy.schlegelmilch@ep-reisen.de + + + + + Schlegelmilch + Mandy + + Am Jägersteig 19 + 40724 + Hilden + + + mandy.schlegelmilch@ep-reisen.de + + + + +XML; + + $anonymizer = new BpnXmlAnonymizer(); + $result = $anonymizer->anonymize($xml); + + $document = new \DOMDocument(); + $this->assertTrue($document->loadXML($result)); + + $xpath = new \DOMXPath($document); + + $applicantName = trim((string) $xpath->evaluate('string(//anmelder/name)')); + $participantName = trim((string) $xpath->evaluate('string(//teilnehmer[@id="1"]/name)')); + + $this->assertSame($applicantName, $participantName); + $this->assertSame(1, $anonymizer->getLastIdentityCount()); + } + + public function testAnonymizeCanReuseIdentityAcrossMultipleXmlDocuments(): void + { + $requestXml = <<<'XML' + + + + Schlegelmilch + Mandy + + mandy.schlegelmilch@ep-reisen.de + + 255752 + + +XML; + + $responseXml = <<<'XML' + + + + + Schlegelmilch + Mandy + + mandy.schlegelmilch@ep-reisen.de + + 255752 + + + +XML; + + $anonymizer = new BpnXmlAnonymizer(); + $anonymizedRequest = $anonymizer->anonymize($requestXml, true); + $anonymizedResponse = $anonymizer->anonymize($responseXml, false); + + $requestDocument = new \DOMDocument(); + $this->assertTrue($requestDocument->loadXML($anonymizedRequest)); + $requestXpath = new \DOMXPath($requestDocument); + $requestName = trim((string) $requestXpath->evaluate('string(//anmelder/name)')); + + $responseDocument = new \DOMDocument(); + $this->assertTrue($responseDocument->loadXML($anonymizedResponse)); + $responseXpath = new \DOMXPath($responseDocument); + $responseName = trim((string) $responseXpath->evaluate('string(//teilnehmer[@id="1"]/name)')); + + $this->assertSame($requestName, $responseName); + $this->assertSame(1, $anonymizer->getLastIdentityCount()); + } +}