> */ private array $identities = []; private int $fallbackIdentityCounter = 0; /** * Maps a person id as it appears in the source to its replacement. * * Person ids show up in several places for the same person - the root , the * anmelder's , each participant's own - and referential integrity only * survives if every occurrence of one source value maps to the same replacement. * * @var array */ private array $personIdReplacements = []; /** * Maps an address id as it appears in the source to its replacement. * * @var array */ private array $addressIdReplacements = []; 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('//anfrage | //anmelder | //teilnehmerliste/teilnehmer | //kundennamen/kunde | //adressdaten'); if (false === $personNodes) { throw new \RuntimeException('Unable to query person nodes.'); } // Resolving runs to completion before anything is replaced. resolvePersonId() falls // back to walking up the ancestors when a node carries no person id of its own, so a // single interleaved pass could read an id that an earlier replacement had already // faked and split one person into two identities. $resolved = []; foreach ($personNodes as $index => $personNode) { if (false === $personNode instanceof \DOMElement) { continue; } $identity = $this->getOrCreateIdentity($this->resolvePersonId($personNode, (int) $index)); $this->collectIdReplacements($personNode, $identity); $resolved[] = [$personNode, $identity]; } foreach ($resolved as [$personNode, $identity]) { if ('anfrage' === $personNode->tagName) { $this->replaceRequestFields($personNode, $identity); continue; } $this->replaceNameFields($personNode, $identity); $this->replaceEmailFields($personNode, $identity); $this->replaceAddressFields($personNode, $identity); $this->replaceDemographicFields($personNode, $identity); $this->replaceFinancialFields($personNode, $identity); $this->replaceMetadataFields($personNode, $identity); } // Person and address ids are swept document-wide rather than per person node: the // same ids also appear outside any of them, as direct children of . $this->replaceIdReferences($xpath); $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; $this->personIdReplacements = []; $this->addressIdReplacements = []; } private function resolvePersonId(\DOMElement $personNode, int $nodeIndex): string { if ('anfrage' === $personNode->tagName || 'adressdaten' === $personNode->tagName) { $emailNode = $this->getDirectChild($personNode, 'email'); if (null !== $emailNode) { $emailValue = trim($emailNode->textContent); if ('' !== $emailValue) { return sprintf('email:%s', mb_strtolower($emailValue)); } } if ('adressdaten' === $personNode->tagName) { $communicationNode = $this->getDirectChild($personNode, 'kommunikation'); if (null !== $communicationNode) { $nestedEmailNode = $this->getDirectChild($communicationNode, 'email'); if (null !== $nestedEmailNode) { $emailValue = trim($nestedEmailNode->textContent); if ('' !== $emailValue) { return sprintf('email:%s', mb_strtolower($emailValue)); } } } } } $personIdValue = $this->findPersonIdInAncestors($personNode); if (null !== $personIdValue) { return $personIdValue; } $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))); } /** * @return array */ private function getOrCreateIdentity(string $personId): array { if (true === isset($this->identities[$personId])) { return $this->identities[$personId]; } $identityIndex = \count($this->identities) + 1; $firstName = $this->faker->firstName(); $lastName = $this->faker->lastName(); $salutation = 'Divers'; $title = ''; $gender = 'D'; $nationality = 'XX'; $street = sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber()); $postalCode = $this->faker->postcode(); $city = $this->faker->city(); $district = ''; $country = 'XX'; $mobilePhone = sprintf('01%09d', $identityIndex); $phone = sprintf('02%09d', $identityIndex); $newsletter = 'False'; $iban = sprintf('DE%020d', $identityIndex); $userName = sprintf('anon-user-%d', $identityIndex); $requestKey = str_pad((string) $identityIndex, 32, '0', STR_PAD_LEFT); $requestPassword = str_pad((string) ($identityIndex + 1), 32, '0', STR_PAD_LEFT); $personIdValue = (string) (700000 + $identityIndex); $addressIdValue = (string) (800000 + $identityIndex); $customerIdValue = (string) (900000 + $identityIndex); $height = '170'; $weight = '70'; $shoeSize = '42'; $email = sprintf('%s@%s', $userName, self::SAFE_EMAIL_DOMAIN); $this->identities[$personId] = [ 'firstName' => $firstName, 'lastName' => $lastName, 'salutation' => $salutation, 'title' => $title, 'gender' => $gender, 'nationality' => $nationality, 'street' => $street, 'postalCode' => $postalCode, 'city' => $city, 'district' => $district, 'country' => $country, 'email' => strtolower($email), 'mobilePhone' => $mobilePhone, 'phone' => $phone, 'newsletter' => $newsletter, 'iban' => $iban, 'userName' => $userName, 'requestKey' => $requestKey, 'requestPassword' => $requestPassword, 'personId' => $personIdValue, 'addressId' => $addressIdValue, 'customerId' => $customerIdValue, 'height' => $height, 'weight' => $weight, 'shoeSize' => $shoeSize, ]; return $this->identities[$personId]; } /** * @param array $identity */ private function replaceNameFields(\DOMElement $personNode, array $identity): void { $nameNode = $this->getDirectChild($personNode, 'name'); if (null !== $nameNode) { $this->replaceNodeValue($nameNode, $identity['lastName']); } $firstNameNode = $this->getDirectChild($personNode, 'vorname'); if (null !== $firstNameNode) { $this->replaceNodeValue($firstNameNode, $identity['firstName']); } if (true === $personNode->hasAttribute('name')) { $this->replaceAttributeValue($personNode, 'name', $identity['lastName']); } if (true === $personNode->hasAttribute('vorname')) { $this->replaceAttributeValue($personNode, 'vorname', $identity['firstName']); } if (true === $personNode->hasAttribute('id') && 'kunde' === $personNode->tagName) { $this->replaceAttributeValue($personNode, 'id', $identity['customerId']); } } /** * @param array $identity */ private function replaceEmailFields(\DOMElement $personNode, array $identity): void { $communicationNode = $this->getDirectChild($personNode, 'kommunikation'); if (null === $communicationNode) { if ('anfrage' === $personNode->tagName) { $emailNode = $this->getDirectChild($personNode, 'email'); if (null !== $emailNode) { $this->replaceNodeValue($emailNode, $identity['email']); } } return; } $emailNode = $this->getDirectChild($communicationNode, 'email'); if (null !== $emailNode) { $this->replaceNodeValue($emailNode, $identity['email']); } $mobileNode = $this->getDirectChild($communicationNode, 'telefonmobil'); if (null !== $mobileNode) { $this->replaceNodeValue($mobileNode, $identity['mobilePhone']); } $phoneNode = $this->getDirectChild($communicationNode, 'telefonprivat'); if (null !== $phoneNode) { $this->replaceNodeValue($phoneNode, $identity['phone']); } $newsletterNode = $this->getDirectChild($communicationNode, 'newsletter'); if (null !== $newsletterNode) { $this->replaceNodeValue($newsletterNode, $identity['newsletter']); } } /** * @param array $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) { $this->replaceNodeValue($streetNode, $identity['street']); } $postalCodeNode = $this->getDirectChild($addressNode, 'plz'); if (null !== $postalCodeNode) { $this->replaceNodeValue($postalCodeNode, $identity['postalCode']); } $cityNode = $this->getDirectChild($addressNode, 'ort'); if (null !== $cityNode) { $this->replaceNodeValue($cityNode, $identity['city']); } $districtNode = $this->getDirectChild($addressNode, 'ortsteil'); if (null !== $districtNode) { $this->replaceNodeValue($districtNode, $identity['district']); } $countryNode = $this->getDirectChild($addressNode, 'land'); if (null !== $countryNode) { $this->replaceNodeValue($countryNode, $identity['country']); } $addressIdNode = $this->getDirectChild($addressNode, 'id'); if (null !== $addressIdNode) { $this->replaceNodeValue($addressIdNode, $identity['addressId']); } } /** * @param array $identity */ private function replaceDemographicFields(\DOMElement $personNode, array $identity): void { $salutationNode = $this->getDirectChild($personNode, 'anrede'); if (null !== $salutationNode) { $this->replaceNodeValue($salutationNode, $identity['salutation']); } $titleNode = $this->getDirectChild($personNode, 'titel'); if (null !== $titleNode) { $this->replaceNodeValue($titleNode, $identity['title']); } $genderNode = $this->getDirectChild($personNode, 'geschlecht'); if (null !== $genderNode) { $this->replaceNodeValue($genderNode, $identity['gender']); } $nationalityNode = $this->getDirectChild($personNode, 'nationalitaet'); if (null !== $nationalityNode) { $this->replaceNodeValue($nationalityNode, $identity['nationality']); } $heightNode = $this->getDirectChild($personNode, 'sonstiges1'); if (null !== $heightNode) { $this->replaceNodeValue($heightNode, $identity['height']); } $weightNode = $this->getDirectChild($personNode, 'sonstiges2'); if (null !== $weightNode) { $this->replaceNodeValue($weightNode, $identity['weight']); } $shoeSizeNode = $this->getDirectChild($personNode, 'sonstiges3'); if (null !== $shoeSizeNode) { $this->replaceNodeValue($shoeSizeNode, $identity['shoeSize']); } } /** * @param array $identity */ private function replaceFinancialFields(\DOMElement $personNode, array $identity): void { $bankNode = $this->getDirectChild($personNode, 'bankverbindung'); if (null === $bankNode) { return; } $ibanNode = $this->getDirectChild($bankNode, 'iban'); if (null !== $ibanNode) { $this->replaceNodeValue($ibanNode, $identity['iban']); } } /** * Replaces identifying attributes on the person node. * * teilnehmer/@id is deliberately left alone: it is not an identifier but the * participant's 1-based slot within the booking, and zuordnung, status_teilnehmer, * einzelpreis and the outbound payload all index against it. Rewriting it makes the * dump self-contradictory - room and service assignments then resolve to nobody - so * anonymized dumps stop being usable for debugging. * * kunde/@id is a real customer id and is replaced. * * @param array $identity */ private function replaceMetadataFields(\DOMElement $personNode, array $identity): void { if ('kunde' === $personNode->tagName) { $this->replaceAttributeValue($personNode, 'id', $identity['customerId']); } } /** * @param array $identity */ private function replaceRequestFields(\DOMElement $personNode, array $identity): void { $userNode = $this->getDirectChild($personNode, 'user'); if (null !== $userNode) { $this->replaceNodeValue($userNode, $identity['userName']); } $keyNode = $this->getDirectChild($personNode, 'key'); if (null !== $keyNode) { $this->replaceNodeValue($keyNode, $identity['requestKey']); } $emailNode = $this->getDirectChild($personNode, 'email'); if (null !== $emailNode) { $this->replaceNodeValue($emailNode, $identity['email']); } $passwordNode = $this->getDirectChild($personNode, 'passwort'); if (null !== $passwordNode) { $this->replaceNodeValue($passwordNode, $identity['requestPassword']); } } private function replaceNodeValue(\DOMElement $node, string $replacement): void { if ('' === trim($node->textContent)) { return; } $node->nodeValue = $replacement; } private function replaceAttributeValue(\DOMElement $node, string $attributeName, string $replacement): void { if ('' === trim((string) $node->getAttribute($attributeName))) { return; } $node->setAttribute($attributeName, $replacement); } /** * Records how this person's ids must be replaced wherever they occur. * * Every id the node carries itself is claimed. When it carries none, the nearest * ancestor's is claimed instead - which is the same rule resolvePersonId() used to pick * this node's identity in the first place, so the two can never disagree about who a * given id belongs to. A response whose only person sits in keeps its ids * at the level, and this is what ties them together. * * @param array $identity */ private function collectIdReplacements(\DOMElement $personNode, array $identity): void { $ownPersonIds = []; foreach (self::PERSON_ID_TAGS as $tagName) { $value = $this->getDirectChildValue($personNode, $tagName); if (null !== $value) { $ownPersonIds[] = $value; } } if ([] === $ownPersonIds) { $inherited = $this->findPersonIdInAncestors($personNode); if (null !== $inherited) { $ownPersonIds[] = $inherited; } } foreach ($ownPersonIds as $value) { $this->personIdReplacements[$value] ??= $identity['personId']; } $addressId = $this->getDirectChildValue($personNode, self::ADDRESS_ID_TAG) ?? $this->findIdInAncestors($personNode, [self::ADDRESS_ID_TAG]); if (null !== $addressId) { $this->addressIdReplacements[$addressId] ??= $identity['addressId']; } } /** * Replaces every person and address id in the document. * * Runs after all person nodes have been resolved, so the lookup keys are the untouched * source values. An id belonging to nobody the person-node query reached still gets a * replacement of its own rather than being left in place - it identifies a real person * either way. */ private function replaceIdReferences(\DOMXPath $xpath): void { $tagNames = [...self::PERSON_ID_TAGS, self::ADDRESS_ID_TAG]; $nodes = $xpath->query('//'.implode(' | //', $tagNames)); if (false === $nodes) { return; } foreach ($nodes as $node) { if (false === $node instanceof \DOMElement) { continue; } $value = trim($node->textContent); if ('' === $value) { continue; } $isAddressId = self::ADDRESS_ID_TAG === $node->tagName; $replacements = $isAddressId ? $this->addressIdReplacements : $this->personIdReplacements; if (false === isset($replacements[$value])) { $identity = $this->getOrCreateIdentity(sprintf('%s:%s', $node->tagName, $value)); $replacements[$value] = $isAddressId ? $identity['addressId'] : $identity['personId']; if (true === $isAddressId) { $this->addressIdReplacements[$value] = $replacements[$value]; } else { $this->personIdReplacements[$value] = $replacements[$value]; } } $this->replaceNodeValue($node, $replacements[$value]); } } private function getDirectChildValue(\DOMElement $parent, string $name): ?string { $child = $this->getDirectChild($parent, $name); if (null === $child) { return null; } $value = trim($child->textContent); return '' === $value ? null : $value; } private function findPersonIdInAncestors(\DOMElement $node): ?string { return $this->findIdInAncestors($node, self::PERSON_ID_TAGS); } /** * Finds the nearest id of the given kind on the node or one of its ancestors. * * @param list $tagNames */ private function findIdInAncestors(\DOMElement $node, array $tagNames): ?string { foreach ($tagNames as $tagName) { $value = $this->getDirectChildValue($node, $tagName); if (null !== $value) { return $value; } } $parent = $node->parentNode; if (false === $parent instanceof \DOMElement) { return null; } return $this->findIdInAncestors($parent, $tagNames); } 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; } }