diff --git a/src/Util/ProjectZomboid/ProjectZomboidRedactor.php b/src/Util/ProjectZomboid/ProjectZomboidRedactor.php index 03b0d54..5a9ec9f 100644 --- a/src/Util/ProjectZomboid/ProjectZomboidRedactor.php +++ b/src/Util/ProjectZomboid/ProjectZomboidRedactor.php @@ -7,15 +7,24 @@ use IndifferentKetchup\Codex\Util\RedactorInterface; /** * Render-time PII filter for Project Zomboid log content. * - * Applies up to three sequential regex passes over the raw log string, + * Applies up to four sequential regex passes over the raw log string, * each controlled by a boolean toggle (all enabled by default): * - * 1. Steam ID pass — replaces 17-digit Steam IDs with a placeholder token. - * 2. Player name pass — replaces player display names with a placeholder + * 1. IP address pass — replaces IPv4 addresses (with optional :port + * suffix) and IPv6 addresses (full, abbreviated, bracketed, and + * IPv4-mapped forms; all with optional :port when bracketed) with + * a placeholder token. Pattern-disjoint from the other passes. + * 2. Steam ID pass — replaces 17-digit Steam IDs with a placeholder + * token. + * 3. Player name pass — replaces player display names with a placeholder * token. This pass anchors on the already-redacted Steam ID token, so * the ordering Steam ID -> name -> coordinates is mandatory. - * 3. Coordinates pass — replaces world coordinate triplets with a placeholder - * token. + * 4. Coordinates pass — replaces world coordinate triplets with a + * placeholder token. + * + * Pass 1 runs first by convention, not dependency: it shares no anchors + * with passes 2-4 and could run anywhere in the chain without affecting + * their output. * * All regex passes use the /u flag for Unicode safety. * @@ -24,6 +33,29 @@ use IndifferentKetchup\Codex\Util\RedactorInterface; */ class ProjectZomboidRedactor implements RedactorInterface { + /** Generic placeholder substituted for every matched IPv4 or IPv6 address (with port suffix consumed when present). */ + public const string IP_REPLACEMENT = '[REDACTED_IP]'; + + /** Strict IPv4 with valid 0-255 octets and optional :port suffix. Lookarounds reject matches embedded in longer alphanumeric or dotted-decimal tokens; the (?4) sequence like 1.2.3.4.5 while still allowing a trailing sentence period after the IP/port. */ + public const string IPV4_REGEX = '/' + . '(?[0-9a-fA-F:.]+)\](?::\d{1,5})?' + . '|' + . '(?(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F.]*)' + . ')' + . '(?![A-Za-z0-9_:])(?!\.\d)' + . '/u'; + /** Regex matching a 17-digit SteamID64 anchored on the 76561198 universe prefix, with lookaround boundaries that reject embedded occurrences. */ public const string STEAM_ID_REGEX = '/(?\d+),(?\d+),(?-?\d+)(?=\) (?:hit|restore|store|true|false))/u'; + private bool $redactIpAddresses = true; private bool $redactSteamIds = true; private bool $redactPlayerNames = true; private bool $redactCoordinates = true; + /** + * Enable or disable the IP address redaction pass (covers IPv4 and IPv6). + * + * @param bool $on Pass true to enable, false to disable. + * @return static + */ + public function redactIpAddresses(bool $on): static + { + $this->redactIpAddresses = $on; + return $this; + } + /** * Enable or disable the Steam ID redaction pass. * @@ -97,14 +142,31 @@ class ProjectZomboidRedactor implements RedactorInterface /** * Redact PII from the given Project Zomboid log content. * - * Passes are applied in the mandatory order: Steam ID -> player name -> - * coordinates. See class docblock for rationale. + * Passes are applied in the order: IP address -> Steam ID -> player + * name -> coordinates. The Steam ID -> name -> coordinates ordering + * is mandatory (see class docblock); the IP pass is pattern-disjoint + * and runs first by convention. * * @param string $content Raw log content that may contain PII. * @return string Content with enabled PII categories replaced by tokens. */ public function redact(string $content): string { + if ($this->redactIpAddresses) { + $content = preg_replace_callback( + self::IPV6_REGEX, + static function (array $matches): string { + $candidate = ($matches['bracketed'] ?? '') !== '' + ? $matches['bracketed'] + : ($matches['bare'] ?? ''); + return filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false + ? self::IP_REPLACEMENT + : $matches[0]; + }, + $content + ); + $content = preg_replace(self::IPV4_REGEX, self::IP_REPLACEMENT, $content); + } if ($this->redactSteamIds) { $content = preg_replace(self::STEAM_ID_REGEX, self::STEAM_ID_REPLACEMENT, $content); } diff --git a/test/tests/Util/Redactor/ProjectZomboidRedactorIpv4Test.php b/test/tests/Util/Redactor/ProjectZomboidRedactorIpv4Test.php new file mode 100644 index 0000000..7385777 --- /dev/null +++ b/test/tests/Util/Redactor/ProjectZomboidRedactorIpv4Test.php @@ -0,0 +1,114 @@ +redact($input); + + $this->assertSame($expected, $output); + } + + public function testRedactsIpv4WithPortSuffix(): void + { + $input = 'Connected to 10.0.0.42:27015.'; + $expected = 'Connected to [REDACTED_IP].'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testRedactsMultipleIpv4OnOneLine(): void + { + $input = 'Peer 192.168.1.10 -> 192.168.1.20 via 10.0.0.1:8080.'; + $expected = 'Peer [REDACTED_IP] -> [REDACTED_IP] via [REDACTED_IP].'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testRedactsLoopbackAndBoundaryAddresses(): void + { + $input = implode("\n", [ + '127.0.0.1', + '0.0.0.0', + '255.255.255.255', + ]); + $expected = implode("\n", [ + '[REDACTED_IP]', + '[REDACTED_IP]', + '[REDACTED_IP]', + ]); + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testDoesNotRedactOutOfRangeOctets(): void + { + // 999 is not a valid octet under the 0-255 alternation; the address + // must therefore be left untouched. + $input = 'Bogus: 999.999.999.999'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($input, $output); + } + + public function testDoesNotRedactInsideLongerDottedSequence(): void + { + // Five dotted segments are not an IPv4 address; the lookarounds must + // reject any partial match inside the longer sequence. + $input = 'Path frag 1.2.3.4.5 should not match.'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($input, $output); + } + + public function testDoesNotRedactThreeSegmentBuildNumbers(): void + { + // PZ build numbers are 3-segment (e.g. 41.78.16) and must not match. + $input = 'Build 41.78.16 starting up.'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($input, $output); + } + + public function testToggleOffLeavesIpv4Intact(): void + { + $input = 'Connection from 192.168.1.1:27015 closed.'; + + $output = (new ProjectZomboidRedactor()) + ->redactIpAddresses(false) + ->redact($input); + + $this->assertSame($input, $output); + } + + public function testIdempotence(): void + { + $input = implode("\n", [ + 'Connection from 192.168.1.1:27015 closed.', + 'Peer 10.0.0.42 -> 10.0.0.43 via 172.16.0.1:8080.', + ]); + + $redactor = new ProjectZomboidRedactor(); + $once = $redactor->redact($input); + $twice = $redactor->redact($once); + + $this->assertSame($once, $twice); + } +} diff --git a/test/tests/Util/Redactor/ProjectZomboidRedactorIpv6Test.php b/test/tests/Util/Redactor/ProjectZomboidRedactorIpv6Test.php new file mode 100644 index 0000000..10c7200 --- /dev/null +++ b/test/tests/Util/Redactor/ProjectZomboidRedactorIpv6Test.php @@ -0,0 +1,135 @@ +redact($input); + + $this->assertSame($expected, $output); + } + + public function testRedactsAbbreviatedIpv6(): void + { + $input = 'Server peer 2001:db8::1 connected.'; + $expected = 'Server peer [REDACTED_IP] connected.'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testRedactsLoopbackIpv6(): void + { + $input = 'localhost ::1 reachable.'; + $expected = 'localhost [REDACTED_IP] reachable.'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testRedactsBracketedIpv6WithPort(): void + { + $input = 'Bound to [2001:db8::1]:8080 ok.'; + $expected = 'Bound to [REDACTED_IP] ok.'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testRedactsBracketedLoopbackWithPort(): void + { + $input = 'Listening on [::1]:27015.'; + $expected = 'Listening on [REDACTED_IP].'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testRedactsIpv4MappedIpv6(): void + { + // IPv4-mapped form must be handled by the IPv6 pass before the IPv4 + // pass so the leading "::ffff:" doesn't get orphaned. With the IPv6 + // pass first, the whole token collapses into a single placeholder. + $input = 'Mapped ::ffff:192.168.1.1 ok.'; + $expected = 'Mapped [REDACTED_IP] ok.'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testDoesNotRedactJavaScopeOperator(): void + { + // Java method references and PHP scope operators look superficially + // like leading-:: IPv6 forms but fail filter_var validation; the + // word-boundary lookbehind also rejects matches that follow letters. + $input = 'Foo::bar called Object::toString.'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($input, $output); + } + + public function testDoesNotRedactTimestampShape(): void + { + // PZ log timestamps include hh:mm:ss.v segments which match the coarse + // IPv6 candidate pattern but are rejected by filter_var. + $input = '[16-04-26 12:00:00.000][LOG] startup complete'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($input, $output); + } + + public function testDoesNotRedactSteamIdAsIpv6(): void + { + // 17-digit Steam IDs share no characters with IPv6 syntax, but assert + // explicitly so a future change to the IPv6 regex doesn't accidentally + // collide with the Steam ID pass. + $input = 'Player 76561198111111111 joined.'; + $expected = 'Player 76561198000000000 joined.'; + + $output = (new ProjectZomboidRedactor())->redact($input); + + $this->assertSame($expected, $output); + } + + public function testToggleOffLeavesIpv6Intact(): void + { + $input = 'Bound to [2001:db8::1]:8080 ok.'; + + $output = (new ProjectZomboidRedactor()) + ->redactIpAddresses(false) + ->redact($input); + + $this->assertSame($input, $output); + } + + public function testIdempotence(): void + { + $input = implode("\n", [ + 'Server peer 2001:db8::1 connected.', + 'Listening on [::1]:27015.', + 'Mapped ::ffff:192.168.1.1 ok.', + '[16-04-26 12:00:00.000][LOG] startup complete', + ]); + + $redactor = new ProjectZomboidRedactor(); + $once = $redactor->redact($input); + $twice = $redactor->redact($once); + + $this->assertSame($once, $twice); + } +}