diff --git a/bin/console b/bin/console index c4dffcd5..2bb58bf8 100755 --- a/bin/console +++ b/bin/console @@ -3,6 +3,7 @@ declare(strict_types=1); +use Deviantintegral\Har\Command\SanitizeCommand; use Deviantintegral\Har\Command\SplitCommand; require_once __DIR__ . '/../vendor/autoload.php'; @@ -11,6 +12,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; $application = new Symfony\Component\Console\Application('HAR Tools', '@git-version@ (built: @build-time@)'); $application->add(new SplitCommand()); +$application->add(new SanitizeCommand()); // Run it $application->run(); diff --git a/infection.json5 b/infection.json5 index cac47a15..cdd3c172 100644 --- a/infection.json5 +++ b/infection.json5 @@ -13,6 +13,28 @@ // so yield $index => $cloned is equivalent to yield $cloned (PHP auto-generates same keys) "Deviantintegral\\Har\\Har::splitLogEntries" ] + }, + "MethodCallRemoval": { + "ignore": [ + // Equivalent mutation: params are modified in place via setValue(), and setQueryString + // just sets the same array back - removing it doesn't change behavior + "Deviantintegral\\Har\\HarSanitizer::sanitizeQueryParams" + ] + }, + "LogicalAnd": { + "ignore": [ + // Equivalent mutation: when hasText() is false but isJsonMimeType() is true, + // sanitizeJsonText(null) returns null, so setText is not called - same behavior + "Deviantintegral\\Har\\HarSanitizer::sanitizePostData", + "Deviantintegral\\Har\\HarSanitizer::sanitizeContent" + ] + }, + "ReturnRemoval": { + "ignore": [ + // Equivalent mutation: removing early return for empty string falls through to + // json_decode('') which fails with JSON_ERROR_SYNTAX, then error check returns null anyway + "Deviantintegral\\Har\\HarSanitizer::sanitizeJsonText" + ] } } } diff --git a/src/Command/SanitizeCommand.php b/src/Command/SanitizeCommand.php new file mode 100644 index 00000000..7aaf600e --- /dev/null +++ b/src/Command/SanitizeCommand.php @@ -0,0 +1,99 @@ +setName('har:sanitize') + ->setDescription('Sanitize sensitive data from a HAR file') + ->setHelp('Redact sensitive values like authorization headers, API keys, and passwords from HAR files.') + ->addArgument('har', InputArgument::REQUIRED, 'The source HAR file to sanitize.') + ->addArgument('output', InputArgument::OPTIONAL, 'The output file path. Defaults to stdout.') + ->addOption('header', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Header name to redact (can be specified multiple times).') + ->addOption('query-param', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Query parameter name to redact (can be specified multiple times).') + ->addOption('body-field', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Body field name to redact (can be specified multiple times).') + ->addOption('case-sensitive', null, InputOption::VALUE_NONE, 'Use case-sensitive matching for field names. Defaults to case-insensitive.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $source = $input->getArgument('har'); + + if (!file_exists($source)) { + $io->error(\sprintf('File not found: %s', $source)); + + return Command::FAILURE; + } + + if (is_dir($source)) { + $io->error(\sprintf('Path is a directory, not a file: %s', $source)); + + return Command::FAILURE; + } + + $contents = file_get_contents($source); + if (false === $contents) { + $io->error(\sprintf('Unable to read file: %s', $source)); + + return Command::FAILURE; + } + + $serializer = new Serializer(); + $har = $serializer->deserializeHar($contents); + + $sanitizer = new HarSanitizer(); + + if ($input->getOption('case-sensitive')) { + $sanitizer->setCaseSensitive(true); + } + + $headers = $input->getOption('header'); + if (!empty($headers)) { + $sanitizer->redactHeaders($headers); + } + + $queryParams = $input->getOption('query-param'); + if (!empty($queryParams)) { + $sanitizer->redactQueryParams($queryParams); + } + + $bodyFields = $input->getOption('body-field'); + if (!empty($bodyFields)) { + $sanitizer->redactBodyFields($bodyFields); + } + + $sanitized = $sanitizer->sanitize($har); + $result = $serializer->serializeHar($sanitized); + + $outputPath = $input->getArgument('output'); + if (null !== $outputPath) { + if (false === file_put_contents($outputPath, $result)) { + $io->error(\sprintf('Unable to write to file: %s', $outputPath)); + + return Command::FAILURE; + } + $io->success(\sprintf('Sanitized HAR written to %s', $outputPath)); + } else { + $output->write($result); + } + + return Command::SUCCESS; + } +} diff --git a/src/Entry.php b/src/Entry.php index e5f5c6f5..20e1eb35 100644 --- a/src/Entry.php +++ b/src/Entry.php @@ -177,4 +177,30 @@ public function setInitiator(Initiator $_initiator): self return $this; } + + /** + * Deep clone all object properties when cloning Entry. + */ + public function __clone(): void + { + if (isset($this->request)) { + $this->request = clone $this->request; + } + + if (isset($this->response)) { + $this->response = clone $this->response; + } + + if (isset($this->cache)) { + $this->cache = clone $this->cache; + } + + if (isset($this->timings)) { + $this->timings = clone $this->timings; + } + + if (isset($this->_initiator)) { + $this->_initiator = clone $this->_initiator; + } + } } diff --git a/src/HarSanitizer.php b/src/HarSanitizer.php new file mode 100644 index 00000000..4100a7cc --- /dev/null +++ b/src/HarSanitizer.php @@ -0,0 +1,342 @@ +headersToRedact = $headerNames; + + return $this; + } + + /** + * Set query parameters that should be redacted. + * + * @param string[] $paramNames parameter names to redact + */ + public function redactQueryParams(array $paramNames): self + { + $this->queryParamsToRedact = $paramNames; + + return $this; + } + + /** + * Set body fields that should be redacted. + * + * Supports both form-encoded POST parameters and JSON body fields. + * JSON fields are redacted recursively at any nesting level. + * + * @param string[] $fieldNames field names to redact + */ + public function redactBodyFields(array $fieldNames): self + { + $this->bodyFieldsToRedact = $fieldNames; + + return $this; + } + + /** + * Set cookies that should be redacted. + * + * Applies to both request and response cookies. + * + * @param string[] $cookieNames cookie names to redact + */ + public function redactCookies(array $cookieNames): self + { + $this->cookiesToRedact = $cookieNames; + + return $this; + } + + /** + * Set the value to use for redacted fields. + * + * Defaults to "[REDACTED]". + */ + public function setRedactedValue(string $value): self + { + $this->redactedValue = $value; + + return $this; + } + + /** + * Set whether name matching should be case-sensitive. + * + * Defaults to false (case-insensitive matching). + */ + public function setCaseSensitive(bool $caseSensitive): self + { + $this->caseSensitive = $caseSensitive; + + return $this; + } + + /** + * Sanitize a HAR by redacting configured sensitive fields. + * + * Returns a new Har instance with redacted values. The original is not modified. + */ + public function sanitize(Har $har): Har + { + // Clone to avoid modifying the original + $sanitized = clone $har; + + foreach ($sanitized->getLog()->getEntries() as $entry) { + $this->sanitizeEntry($entry); + } + + return $sanitized; + } + + /** + * Sanitize a single entry. + */ + private function sanitizeEntry(Entry $entry): void + { + $this->sanitizeRequest($entry->getRequest()); + $this->sanitizeResponse($entry->getResponse()); + } + + /** + * Sanitize request headers, query params, body fields, and cookies. + */ + private function sanitizeRequest(Request $request): void + { + if (!empty($this->headersToRedact)) { + $this->sanitizeHeaders($request); + } + + if (!empty($this->queryParamsToRedact)) { + $this->sanitizeQueryParams($request); + } + + if (!empty($this->bodyFieldsToRedact) && $request->hasPostData()) { + $this->sanitizePostData($request->getPostData()); + } + + if (!empty($this->cookiesToRedact)) { + $this->sanitizeCookies($request); + } + } + + /** + * Sanitize response headers, body fields, and cookies. + */ + private function sanitizeResponse(Response $response): void + { + if (!empty($this->headersToRedact)) { + $this->sanitizeHeaders($response); + } + + if (!empty($this->bodyFieldsToRedact)) { + $this->sanitizeContent($response->getContent()); + } + + if (!empty($this->cookiesToRedact)) { + $this->sanitizeCookies($response); + } + } + + /** + * Sanitize headers on a message (request or response). + */ + private function sanitizeHeaders(MessageInterface $message): void + { + $headers = $message->getHeaders(); + + foreach ($headers as $header) { + if ($this->shouldRedact($header->getName(), $this->headersToRedact)) { + $header->setValue($this->redactedValue); + } + } + + // Recalculate headers size + $message->setHeaders($headers); + } + + /** + * Sanitize query parameters on a request. + */ + private function sanitizeQueryParams(Request $request): void + { + $params = $request->getQueryString(); + + foreach ($params as $param) { + if ($this->shouldRedact($param->getName(), $this->queryParamsToRedact)) { + $param->setValue($this->redactedValue); + } + } + + $request->setQueryString($params); + } + + /** + * Check if a name should be redacted based on the configured names. + * + * @param string[] $namesToRedact + */ + private function shouldRedact(string $name, array $namesToRedact): bool + { + foreach ($namesToRedact as $toRedact) { + if ($this->caseSensitive) { + if ($name === $toRedact) { + return true; + } + } else { + if (0 === strcasecmp($name, $toRedact)) { + return true; + } + } + } + + return false; + } + + /** + * Sanitize POST data (form params and JSON body). + */ + private function sanitizePostData(PostData $postData): void + { + // Sanitize form-encoded parameters + if ($postData->hasParams()) { + foreach ($postData->getParams() as $param) { + if ($this->shouldRedact($param->getName(), $this->bodyFieldsToRedact)) { + $param->setValue($this->redactedValue); + } + } + } + + // Sanitize JSON body + if ($postData->hasText() && $this->isJsonMimeType($postData->getMimeType())) { + $sanitizedText = $this->sanitizeJsonText($postData->getText()); + if (null !== $sanitizedText) { + $postData->setText($sanitizedText); + } + } + } + + /** + * Sanitize response content (JSON body). + */ + private function sanitizeContent(Content $content): void + { + if ($content->hasText() && $this->isJsonMimeType($content->getMimeType())) { + $sanitizedText = $this->sanitizeJsonText($content->getText()); + if (null !== $sanitizedText) { + $content->setText($sanitizedText); + } + } + } + + /** + * Check if a MIME type indicates JSON content. + */ + private function isJsonMimeType(string $mimeType): bool + { + // Match application/json, text/json, and variants like application/vnd.api+json + return str_contains($mimeType, 'json'); + } + + /** + * Sanitize JSON text by redacting configured fields. + * + * @return string|null the sanitized JSON, or null if parsing failed + */ + private function sanitizeJsonText(?string $text): ?string + { + if (null === $text || '' === $text) { + return null; + } + + $data = json_decode($text, true); + if (\JSON_ERROR_NONE !== json_last_error()) { + return null; + } + + $sanitized = $this->redactArrayFields($data); + + return json_encode($sanitized, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + } + + /** + * Recursively redact fields in an array. + * + * @param mixed $data the data to sanitize + * + * @return mixed the sanitized data + */ + private function redactArrayFields(mixed $data): mixed + { + if (!\is_array($data)) { + return $data; + } + + foreach ($data as $key => $value) { + if (\is_string($key) && $this->shouldRedact($key, $this->bodyFieldsToRedact)) { + $data[$key] = $this->redactedValue; + } elseif (\is_array($value)) { + $data[$key] = $this->redactArrayFields($value); + } + } + + return $data; + } + + /** + * Sanitize cookies on a request or response. + */ + private function sanitizeCookies(Request|Response $message): void + { + foreach ($message->getCookies() as $cookie) { + if ($this->shouldRedact($cookie->getName(), $this->cookiesToRedact)) { + $cookie->setValue($this->redactedValue); + } + } + } +} diff --git a/src/PostData.php b/src/PostData.php index 833badef..45a7c4ab 100644 --- a/src/PostData.php +++ b/src/PostData.php @@ -86,4 +86,14 @@ public function getBodySize(): int return 0; } + + /** + * Deep clone all object properties when cloning PostData. + */ + public function __clone(): void + { + if (isset($this->params)) { + $this->params = array_map(fn (Params $p) => clone $p, $this->params); + } + } } diff --git a/src/Request.php b/src/Request.php index d3d14e4f..352e6323 100644 --- a/src/Request.php +++ b/src/Request.php @@ -165,4 +165,28 @@ public function setPostData(PostData $postData): self return $this; } + + /** + * Deep clone all object properties when cloning Request. + */ + public function __clone(): void + { + // Clone headers + $this->headers = array_map(fn (Header $h) => clone $h, $this->headers); + + // Clone cookies + if (isset($this->cookies)) { + $this->cookies = array_map(fn (Cookie $c) => clone $c, $this->cookies); + } + + // Clone query string params + if (isset($this->queryString)) { + $this->queryString = array_map(fn (Params $p) => clone $p, $this->queryString); + } + + // Clone postData + if (isset($this->postData)) { + $this->postData = clone $this->postData; + } + } } diff --git a/src/Response.php b/src/Response.php index d7cfb5ee..4385bd91 100644 --- a/src/Response.php +++ b/src/Response.php @@ -106,4 +106,23 @@ public function setRedirectURL(\Psr\Http\Message\UriInterface $redirectURL): sel return $this; } + + /** + * Deep clone all object properties when cloning Response. + */ + public function __clone(): void + { + // Clone headers + $this->headers = array_map(fn (Header $h) => clone $h, $this->headers); + + // Clone cookies + if (isset($this->cookies)) { + $this->cookies = array_map(fn (Cookie $c) => clone $c, $this->cookies); + } + + // Clone content + if (isset($this->content)) { + $this->content = clone $this->content; + } + } } diff --git a/tests/src/Functional/SanitizeCommandTest.php b/tests/src/Functional/SanitizeCommandTest.php new file mode 100644 index 00000000..373b26bb --- /dev/null +++ b/tests/src/Functional/SanitizeCommandTest.php @@ -0,0 +1,716 @@ +tempDir = sys_get_temp_dir().'/har_sanitize_test_'.uniqid(); + mkdir($this->tempDir, recursive: true); + + $command = new SanitizeCommand(); + $this->commandTester = new CommandTester($command); + } + + protected function tearDown(): void + { + if (is_dir($this->tempDir)) { + $this->recursiveRemoveDirectory($this->tempDir); + } + + parent::tearDown(); + } + + public function testSanitizeHeadersToFile(): void + { + $harFile = __DIR__.'/../../fixtures/www.softwareishard.com-single-entry.har'; + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--header' => ['Accept-Encoding', 'User-Agent'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + $this->assertFileExists($outputFile); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + if (isset($headerMap['Accept-Encoding'])) { + $this->assertEquals('[REDACTED]', $headerMap['Accept-Encoding']); + } + if (isset($headerMap['User-Agent'])) { + $this->assertEquals('[REDACTED]', $headerMap['User-Agent']); + } + } + + public function testSanitizeHeadersToStdout(): void + { + $harFile = __DIR__.'/../../fixtures/www.softwareishard.com-single-entry.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + '--header' => ['Accept-Encoding'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $output = $this->commandTester->getDisplay(); + $this->assertJson($output); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar($output); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + if (isset($headerMap['Accept-Encoding'])) { + $this->assertEquals('[REDACTED]', $headerMap['Accept-Encoding']); + } + } + + public function testSanitizeMultipleHeaders(): void + { + $harFile = __DIR__.'/../../fixtures/www.softwareishard.com-single-entry.har'; + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--header' => ['Accept-Encoding', 'Accept-Language', 'Host'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + // All specified headers should be redacted + foreach (['Accept-Encoding', 'Accept-Language', 'Host'] as $headerName) { + if (isset($headerMap[$headerName])) { + $this->assertEquals('[REDACTED]', $headerMap[$headerName], "Header $headerName should be redacted"); + } + } + } + + public function testSanitizeFailsWhenFileNotFound(): void + { + $nonExistentFile = $this->tempDir.'/nonexistent.har'; + + $this->commandTester->execute([ + 'har' => $nonExistentFile, + ]); + + $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('File not found', $output); + } + + public function testSanitizeFailsWhenPathIsDirectory(): void + { + $directoryPath = $this->tempDir.'/notafile'; + mkdir($directoryPath); + + $this->commandTester->execute([ + 'har' => $directoryPath, + ]); + + $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Path is a directory', $output); + } + + public function testSanitizeShowsSuccessMessage(): void + { + $harFile = __DIR__.'/../../fixtures/www.softwareishard.com-single-entry.har'; + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--header' => ['Accept-Encoding'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Sanitized HAR written to', $output); + $this->assertStringContainsString($outputFile, $output); + } + + public function testCommandConfiguration(): void + { + $command = new SanitizeCommand(); + + $this->assertEquals('har:sanitize', $command->getName()); + $this->assertStringContainsString('Sanitize sensitive data', $command->getDescription()); + + $definition = $command->getDefinition(); + $this->assertTrue($definition->getArgument('har')->isRequired()); + $this->assertFalse($definition->getArgument('output')->isRequired()); + $this->assertTrue($definition->hasOption('header')); + $this->assertTrue($definition->getOption('header')->isArray()); + $this->assertTrue($definition->hasOption('query-param')); + $this->assertTrue($definition->getOption('query-param')->isArray()); + $this->assertTrue($definition->hasOption('body-field')); + $this->assertTrue($definition->getOption('body-field')->isArray()); + $this->assertTrue($definition->hasOption('case-sensitive')); + $this->assertFalse($definition->getOption('case-sensitive')->isArray()); + } + + public function testSanitizeWithNoOptions(): void + { + $harFile = __DIR__.'/../../fixtures/www.softwareishard.com-single-entry.har'; + $outputFile = $this->tempDir.'/sanitized.har'; + + // Load original for comparison + $serializer = new Serializer(); + $original = $serializer->deserializeHar(file_get_contents($harFile)); + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + // With no options, output should be functionally equivalent to input + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + $this->assertCount( + \count($original->getLog()->getEntries()), + $sanitized->getLog()->getEntries() + ); + } + + public function testSanitizeQueryParams(): void + { + $harFile = $this->createHarFileWithQueryParams([ + 'api_key' => 'secret-key-123', + 'token' => 'auth-token-456', + 'page' => '1', + ]); + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--query-param' => ['api_key', 'token'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $params = $sanitized->getLog()->getEntries()[0]->getRequest()->getQueryString(); + $paramMap = $this->paramsToMap($params); + + $this->assertEquals('[REDACTED]', $paramMap['api_key']); + $this->assertEquals('[REDACTED]', $paramMap['token']); + $this->assertEquals('1', $paramMap['page']); + } + + public function testSanitizeMultipleQueryParams(): void + { + $harFile = $this->createHarFileWithQueryParams([ + 'secret1' => 'value1', + 'secret2' => 'value2', + 'secret3' => 'value3', + 'public' => 'visible', + ]); + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--query-param' => ['secret1', 'secret2', 'secret3'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $params = $sanitized->getLog()->getEntries()[0]->getRequest()->getQueryString(); + $paramMap = $this->paramsToMap($params); + + $this->assertEquals('[REDACTED]', $paramMap['secret1']); + $this->assertEquals('[REDACTED]', $paramMap['secret2']); + $this->assertEquals('[REDACTED]', $paramMap['secret3']); + $this->assertEquals('visible', $paramMap['public']); + } + + public function testSanitizeHeadersAndQueryParamsTogether(): void + { + $harFile = $this->createHarFileWithQueryParams([ + 'api_key' => 'secret-key', + 'page' => '1', + ]); + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--header' => ['Host'], + '--query-param' => ['api_key'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $entry = $sanitized->getLog()->getEntries()[0]; + + // Check query params are redacted + $params = $entry->getRequest()->getQueryString(); + $paramMap = $this->paramsToMap($params); + $this->assertEquals('[REDACTED]', $paramMap['api_key']); + $this->assertEquals('1', $paramMap['page']); + + // Check headers are redacted + $headers = $entry->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + if (isset($headerMap['Host'])) { + $this->assertEquals('[REDACTED]', $headerMap['Host']); + } + } + + public function testSanitizeBodyFields(): void + { + $harFile = $this->createHarFileWithPostData([ + 'username' => 'john', + 'password' => 'secret123', + 'remember_me' => 'true', + ]); + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--body-field' => ['password'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $postData = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData(); + $params = $postData->getParams(); + $paramMap = $this->paramsToMap($params); + + $this->assertEquals('john', $paramMap['username']); + $this->assertEquals('[REDACTED]', $paramMap['password']); + $this->assertEquals('true', $paramMap['remember_me']); + } + + public function testSanitizeMultipleBodyFields(): void + { + $harFile = $this->createHarFileWithPostData([ + 'username' => 'john', + 'password' => 'secret123', + 'api_key' => 'key-456', + 'public_field' => 'visible', + ]); + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--body-field' => ['password', 'api_key'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $postData = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData(); + $params = $postData->getParams(); + $paramMap = $this->paramsToMap($params); + + $this->assertEquals('john', $paramMap['username']); + $this->assertEquals('[REDACTED]', $paramMap['password']); + $this->assertEquals('[REDACTED]', $paramMap['api_key']); + $this->assertEquals('visible', $paramMap['public_field']); + } + + public function testSanitizeAllOptionsTogether(): void + { + $harFile = $this->createHarFileWithAllData( + ['token' => 'secret-token', 'page' => '1'], + ['password' => 'secret123', 'username' => 'john'] + ); + $outputFile = $this->tempDir.'/sanitized.har'; + + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--header' => ['Host'], + '--query-param' => ['token'], + '--body-field' => ['password'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $entry = $sanitized->getLog()->getEntries()[0]; + + // Check query params + $queryParams = $entry->getRequest()->getQueryString(); + $queryMap = $this->paramsToMap($queryParams); + $this->assertEquals('[REDACTED]', $queryMap['token']); + $this->assertEquals('1', $queryMap['page']); + + // Check body fields + $postData = $entry->getRequest()->getPostData(); + $bodyParams = $postData->getParams(); + $bodyMap = $this->paramsToMap($bodyParams); + $this->assertEquals('[REDACTED]', $bodyMap['password']); + $this->assertEquals('john', $bodyMap['username']); + } + + public function testCaseInsensitiveMatchingByDefault(): void + { + $harFile = $this->createHarFileWithQueryParams([ + 'API_KEY' => 'secret-key', + 'Token' => 'auth-token', + ]); + $outputFile = $this->tempDir.'/sanitized.har'; + + // By default, matching is case-insensitive + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--query-param' => ['api_key', 'token'], + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $params = $sanitized->getLog()->getEntries()[0]->getRequest()->getQueryString(); + $paramMap = $this->paramsToMap($params); + + // Both should be redacted despite case mismatch + $this->assertEquals('[REDACTED]', $paramMap['API_KEY']); + $this->assertEquals('[REDACTED]', $paramMap['Token']); + } + + public function testCaseSensitiveMatchingWhenEnabled(): void + { + $harFile = $this->createHarFileWithQueryParams([ + 'API_KEY' => 'secret-key', + 'api_key' => 'another-key', + 'Token' => 'auth-token', + ]); + $outputFile = $this->tempDir.'/sanitized.har'; + + // With case-sensitive enabled, only exact matches should be redacted + $this->commandTester->execute([ + 'har' => $harFile, + 'output' => $outputFile, + '--query-param' => ['api_key'], + '--case-sensitive' => true, + ]); + + $this->assertSame(Command::SUCCESS, $this->commandTester->getStatusCode()); + + $serializer = new Serializer(); + $sanitized = $serializer->deserializeHar(file_get_contents($outputFile)); + + $params = $sanitized->getLog()->getEntries()[0]->getRequest()->getQueryString(); + $paramMap = $this->paramsToMap($params); + + // Only exact case match should be redacted + $this->assertEquals('secret-key', $paramMap['API_KEY']); + $this->assertEquals('[REDACTED]', $paramMap['api_key']); + $this->assertEquals('auth-token', $paramMap['Token']); + } + + /** + * @param \Deviantintegral\Har\Header[] $headers + * + * @return array + */ + private function headersToMap(array $headers): array + { + $map = []; + foreach ($headers as $header) { + $map[$header->getName()] = $header->getValue(); + } + + return $map; + } + + /** + * @param Params[] $params + * + * @return array + */ + private function paramsToMap(array $params): array + { + $map = []; + foreach ($params as $param) { + $map[$param->getName()] = $param->getValue(); + } + + return $map; + } + + /** + * @param array $queryParams + */ + private function createHarFileWithQueryParams(array $queryParams): string + { + $paramObjects = []; + foreach ($queryParams as $name => $value) { + $param = (new Params())->setName($name)->setValue($value); + $paramObjects[] = $param; + } + + $request = (new Request()) + ->setMethod('GET') + ->setUrl(new Uri('https://example.com/api')) + ->setHttpVersion('HTTP/1.1') + ->setHeaders([]) + ->setCookies([]) + ->setQueryString($paramObjects) + ->setHeadersSize(-1) + ->setBodySize(0); + + $content = (new Content()) + ->setSize(0) + ->setMimeType('text/html'); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHttpVersion('HTTP/1.1') + ->setHeaders([]) + ->setCookies([]) + ->setContent($content) + ->setHeadersSize(-1) + ->setBodySize(0); + + $entry = (new Entry()) + ->setStartedDateTime(new \DateTime()) + ->setTime(100) + ->setRequest($request) + ->setResponse($response); + + $creator = (new Creator()) + ->setName('Test') + ->setVersion('1.0'); + + $log = (new Log()) + ->setVersion('1.2') + ->setCreator($creator) + ->setEntries([$entry]); + + $har = (new Har())->setLog($log); + + $serializer = new Serializer(); + $harContent = $serializer->serializeHar($har); + + $filePath = $this->tempDir.'/input-'.uniqid().'.har'; + file_put_contents($filePath, $harContent); + + return $filePath; + } + + /** + * @param array $postParams + */ + private function createHarFileWithPostData(array $postParams): string + { + $paramObjects = []; + foreach ($postParams as $name => $value) { + $param = (new Params())->setName($name)->setValue($value); + $paramObjects[] = $param; + } + + $postData = (new PostData()) + ->setMimeType('application/x-www-form-urlencoded') + ->setParams($paramObjects); + + $request = (new Request()) + ->setMethod('POST') + ->setUrl(new Uri('https://example.com/api')) + ->setHttpVersion('HTTP/1.1') + ->setHeaders([]) + ->setCookies([]) + ->setQueryString([]) + ->setPostData($postData) + ->setHeadersSize(-1) + ->setBodySize(0); + + $content = (new Content()) + ->setSize(0) + ->setMimeType('text/html'); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHttpVersion('HTTP/1.1') + ->setHeaders([]) + ->setCookies([]) + ->setContent($content) + ->setHeadersSize(-1) + ->setBodySize(0); + + $entry = (new Entry()) + ->setStartedDateTime(new \DateTime()) + ->setTime(100) + ->setRequest($request) + ->setResponse($response); + + $creator = (new Creator()) + ->setName('Test') + ->setVersion('1.0'); + + $log = (new Log()) + ->setVersion('1.2') + ->setCreator($creator) + ->setEntries([$entry]); + + $har = (new Har())->setLog($log); + + $serializer = new Serializer(); + $harContent = $serializer->serializeHar($har); + + $filePath = $this->tempDir.'/input-'.uniqid().'.har'; + file_put_contents($filePath, $harContent); + + return $filePath; + } + + /** + * @param array $queryParams + * @param array $postParams + */ + private function createHarFileWithAllData(array $queryParams, array $postParams): string + { + $queryParamObjects = []; + foreach ($queryParams as $name => $value) { + $param = (new Params())->setName($name)->setValue($value); + $queryParamObjects[] = $param; + } + + $postParamObjects = []; + foreach ($postParams as $name => $value) { + $param = (new Params())->setName($name)->setValue($value); + $postParamObjects[] = $param; + } + + $postData = (new PostData()) + ->setMimeType('application/x-www-form-urlencoded') + ->setParams($postParamObjects); + + $request = (new Request()) + ->setMethod('POST') + ->setUrl(new Uri('https://example.com/api')) + ->setHttpVersion('HTTP/1.1') + ->setHeaders([]) + ->setCookies([]) + ->setQueryString($queryParamObjects) + ->setPostData($postData) + ->setHeadersSize(-1) + ->setBodySize(0); + + $content = (new Content()) + ->setSize(0) + ->setMimeType('text/html'); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHttpVersion('HTTP/1.1') + ->setHeaders([]) + ->setCookies([]) + ->setContent($content) + ->setHeadersSize(-1) + ->setBodySize(0); + + $entry = (new Entry()) + ->setStartedDateTime(new \DateTime()) + ->setTime(100) + ->setRequest($request) + ->setResponse($response); + + $creator = (new Creator()) + ->setName('Test') + ->setVersion('1.0'); + + $log = (new Log()) + ->setVersion('1.2') + ->setCreator($creator) + ->setEntries([$entry]); + + $har = (new Har())->setLog($log); + + $serializer = new Serializer(); + $content = $serializer->serializeHar($har); + + $filePath = $this->tempDir.'/input-'.uniqid().'.har'; + file_put_contents($filePath, $content); + + return $filePath; + } + + private function recursiveRemoveDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = array_diff(scandir($directory), ['.', '..']); + foreach ($items as $item) { + $path = $directory.'/'.$item; + if (is_dir($path)) { + $this->recursiveRemoveDirectory($path); + } else { + unlink($path); + } + } + rmdir($directory); + } +} diff --git a/tests/src/Unit/EntryTest.php b/tests/src/Unit/EntryTest.php index a557392f..dabc1ffe 100644 --- a/tests/src/Unit/EntryTest.php +++ b/tests/src/Unit/EntryTest.php @@ -93,4 +93,93 @@ public function testGetSetInitiator(): void $entry = (new Entry())->setInitiator($initiator); $this->assertSame($initiator, $entry->getInitiator()); } + + public function testCloneIsDeep(): void + { + $har = $this->repository->load('www.softwareishard.com-single-entry.har'); + $entry = $har->getLog()->getEntries()[0]; + + // Clone the entry + $cloned = clone $entry; + + // Verify the cloned request is a different instance + $this->assertNotSame($entry->getRequest(), $cloned->getRequest()); + $this->assertNotSame($entry->getResponse(), $cloned->getResponse()); + + // Modify the cloned request + $cloned->getRequest()->setMethod('PATCH'); + + // Verify the original is unchanged + $this->assertNotEquals('PATCH', $entry->getRequest()->getMethod()); + } + + public function testCloneWithInitiator(): void + { + $entry = (new Entry()) + ->setRequest(new Request()) + ->setResponse(new Response()) + ->setCache(new Cache()) + ->setTimings(new Timings()) + ->setInitiator((new Initiator())->setType('parser')); + + $cloned = clone $entry; + + // Verify initiator is cloned + $this->assertNotSame($entry->getInitiator(), $cloned->getInitiator()); + + // Modify cloned initiator + $cloned->getInitiator()->setType('script'); + + // Verify original is unchanged + $this->assertEquals('parser', $entry->getInitiator()->getType()); + } + + public function testCloneCacheIsDeep(): void + { + $cache = (new Cache())->setComment('original comment'); + $entry = (new Entry()) + ->setRequest(new Request()) + ->setResponse(new Response()) + ->setCache($cache) + ->setTimings(new Timings()); + + $cloned = clone $entry; + + // Verify cache is cloned (different instance) + $this->assertNotSame($entry->getCache(), $cloned->getCache()); + + // Modify cloned cache + $cloned->getCache()->setComment('modified comment'); + + // Verify original is unchanged + $this->assertEquals('original comment', $entry->getCache()->getComment()); + } + + public function testCloneTimingsIsDeep(): void + { + $timings = (new Timings()) + ->setBlocked(10.0) + ->setDns(20.0) + ->setSsl(-1) + ->setConnect(-1) + ->setSend(5.0) + ->setWait(100.0) + ->setReceive(15.0); + $entry = (new Entry()) + ->setRequest(new Request()) + ->setResponse(new Response()) + ->setCache(new Cache()) + ->setTimings($timings); + + $cloned = clone $entry; + + // Verify timings is cloned (different instance) + $this->assertNotSame($entry->getTimings(), $cloned->getTimings()); + + // Modify cloned timings + $cloned->getTimings()->setBlocked(99.0); + + // Verify original is unchanged + $this->assertEquals(10.0, $entry->getTimings()->getBlocked()); + } } diff --git a/tests/src/Unit/HarSanitizerTest.php b/tests/src/Unit/HarSanitizerTest.php new file mode 100644 index 00000000..c9f58c44 --- /dev/null +++ b/tests/src/Unit/HarSanitizerTest.php @@ -0,0 +1,1076 @@ +createHarWithHeaders([ + 'Authorization' => 'Bearer secret-token', + 'Content-Type' => 'application/json', + 'Cookie' => 'session=abc123', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Authorization', 'Cookie']); + + $sanitized = $sanitizer->sanitize($har); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + $this->assertEquals('[REDACTED]', $headerMap['Authorization']); + $this->assertEquals('application/json', $headerMap['Content-Type']); + $this->assertEquals('[REDACTED]', $headerMap['Cookie']); + } + + public function testRedactHeadersCaseInsensitive(): void + { + $har = $this->createHarWithHeaders([ + 'authorization' => 'Bearer secret-token', + 'COOKIE' => 'session=abc123', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Authorization', 'Cookie']); + + $sanitized = $sanitizer->sanitize($har); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + $this->assertEquals('[REDACTED]', $headerMap['authorization']); + $this->assertEquals('[REDACTED]', $headerMap['COOKIE']); + } + + public function testRedactHeadersCaseSensitive(): void + { + $har = $this->createHarWithHeaders([ + 'authorization' => 'Bearer secret-token', + 'Authorization' => 'Bearer another-token', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Authorization']); + $sanitizer->setCaseSensitive(true); + + $sanitized = $sanitizer->sanitize($har); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + // Only exact case match should be redacted + $this->assertEquals('Bearer secret-token', $headerMap['authorization']); + $this->assertEquals('[REDACTED]', $headerMap['Authorization']); + } + + public function testRedactResponseHeaders(): void + { + $har = $this->createHarWithResponseHeaders([ + 'Set-Cookie' => 'session=secret; HttpOnly', + 'Content-Type' => 'application/json', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Set-Cookie']); + + $sanitized = $sanitizer->sanitize($har); + + $headers = $sanitized->getLog()->getEntries()[0]->getResponse()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + $this->assertEquals('[REDACTED]', $headerMap['Set-Cookie']); + $this->assertEquals('application/json', $headerMap['Content-Type']); + } + + public function testCustomRedactedValue(): void + { + $har = $this->createHarWithHeaders([ + 'Authorization' => 'Bearer secret-token', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Authorization']); + $sanitizer->setRedactedValue('***'); + + $sanitized = $sanitizer->sanitize($har); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + $this->assertEquals('***', $headerMap['Authorization']); + } + + public function testOriginalHarIsNotModified(): void + { + $har = $this->createHarWithHeaders([ + 'Authorization' => 'Bearer secret-token', + ]); + + $originalValue = $har->getLog()->getEntries()[0]->getRequest()->getHeaders()[0]->getValue(); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Authorization']); + $sanitizer->sanitize($har); + + // Original should be unchanged + $currentValue = $har->getLog()->getEntries()[0]->getRequest()->getHeaders()[0]->getValue(); + $this->assertEquals($originalValue, $currentValue); + $this->assertEquals('Bearer secret-token', $currentValue); + } + + public function testNoRedactionConfigured(): void + { + $har = $this->createHarWithHeaders([ + 'Authorization' => 'Bearer secret-token', + ]); + + $sanitizer = new HarSanitizer(); + $sanitized = $sanitizer->sanitize($har); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + $this->assertEquals('Bearer secret-token', $headerMap['Authorization']); + } + + public function testMultipleEntries(): void + { + $har = $this->createHarWithMultipleEntries(); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Authorization']); + + $sanitized = $sanitizer->sanitize($har); + + $entries = $sanitized->getLog()->getEntries(); + $this->assertCount(2, $entries); + + foreach ($entries as $entry) { + $headers = $this->headersToMap($entry->getRequest()->getHeaders()); + $this->assertEquals('[REDACTED]', $headers['Authorization']); + } + } + + public function testFluentInterface(): void + { + $sanitizer = new HarSanitizer(); + + $result = $sanitizer + ->redactHeaders(['Authorization']) + ->setRedactedValue('***') + ->setCaseSensitive(true); + + $this->assertSame($sanitizer, $result); + } + + public function testHeadersSizeRecalculatedAfterRedaction(): void + { + // Create a HAR with a header that has a known value + $har = $this->createHarWithHeaders([ + 'Authorization' => 'Bearer very-long-secret-token-that-is-quite-lengthy', + ]); + + $originalHeadersSize = $har->getLog()->getEntries()[0]->getRequest()->getHeadersSize(); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Authorization']); + + $sanitized = $sanitizer->sanitize($har); + + $newHeadersSize = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeadersSize(); + + // The redacted value "[REDACTED]" is shorter than the original token + // so the headers size should be smaller + $this->assertLessThan($originalHeadersSize, $newHeadersSize); + } + + public function testResponseHeadersSizeRecalculatedAfterRedaction(): void + { + // Create a HAR with a response header that has a known value + $har = $this->createHarWithResponseHeaders([ + 'Set-Cookie' => 'session=very-long-secret-session-id-that-should-be-redacted', + ]); + + $originalHeadersSize = $har->getLog()->getEntries()[0]->getResponse()->getHeadersSize(); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Set-Cookie']); + + $sanitized = $sanitizer->sanitize($har); + + $newHeadersSize = $sanitized->getLog()->getEntries()[0]->getResponse()->getHeadersSize(); + + // The redacted value "[REDACTED]" is shorter than the original value + $this->assertLessThan($originalHeadersSize, $newHeadersSize); + } + + public function testHeadersSizeUnchangedWhenNoHeadersRedacted(): void + { + // Create a HAR with headers that won't be redacted + $har = $this->createHarWithHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => '*/*', + ]); + + $originalHeadersSize = $har->getLog()->getEntries()[0]->getRequest()->getHeadersSize(); + + $sanitizer = new HarSanitizer(); + // Configure to redact headers that don't exist + $sanitizer->redactHeaders(['Authorization', 'Cookie']); + + $sanitized = $sanitizer->sanitize($har); + + $newHeadersSize = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeadersSize(); + + // Headers size should be exactly the same since nothing was redacted + $this->assertSame($originalHeadersSize, $newHeadersSize); + } + + public function testRedactQueryParams(): void + { + $har = $this->createHarWithQueryParams([ + 'api_key' => 'secret-key', + 'token' => 'auth-token', + 'page' => '1', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactQueryParams(['api_key', 'token']); + + $sanitized = $sanitizer->sanitize($har); + + $params = $sanitized->getLog()->getEntries()[0]->getRequest()->getQueryString(); + $paramMap = $this->paramsToMap($params); + + $this->assertEquals('[REDACTED]', $paramMap['api_key']); + $this->assertEquals('[REDACTED]', $paramMap['token']); + $this->assertEquals('1', $paramMap['page']); + } + + public function testRedactQueryParamsCaseInsensitive(): void + { + $har = $this->createHarWithQueryParams([ + 'API_KEY' => 'secret-key', + 'Token' => 'auth-token', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactQueryParams(['api_key', 'token']); + + $sanitized = $sanitizer->sanitize($har); + + $params = $sanitized->getLog()->getEntries()[0]->getRequest()->getQueryString(); + $paramMap = $this->paramsToMap($params); + + $this->assertEquals('[REDACTED]', $paramMap['API_KEY']); + $this->assertEquals('[REDACTED]', $paramMap['Token']); + } + + public function testRedactQueryParamsFluentInterface(): void + { + $sanitizer = new HarSanitizer(); + + $result = $sanitizer->redactQueryParams(['api_key']); + + $this->assertSame($sanitizer, $result); + } + + public function testRedactBodyFieldsFormEncoded(): void + { + $har = $this->createHarWithPostParams([ + 'username' => 'john', + 'password' => 'secret123', + 'remember' => 'true', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + $params = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData()->getParams(); + $paramMap = $this->paramsToMap($params); + + $this->assertEquals('[REDACTED]', $paramMap['password']); + $this->assertEquals('john', $paramMap['username']); + $this->assertEquals('true', $paramMap['remember']); + } + + public function testRedactBodyFieldsJsonRequest(): void + { + $json = json_encode([ + 'username' => 'john', + 'password' => 'secret123', + 'data' => ['nested' => 'value'], + ]); + + $har = $this->createHarWithJsonPostData($json); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData()->getText(); + $data = json_decode($text, true); + + $this->assertEquals('[REDACTED]', $data['password']); + $this->assertEquals('john', $data['username']); + $this->assertEquals(['nested' => 'value'], $data['data']); + } + + public function testRedactBodyFieldsJsonResponse(): void + { + $json = json_encode([ + 'user' => 'john', + 'token' => 'secret-token', + 'expires' => 3600, + ]); + + $har = $this->createHarWithJsonResponse($json); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['token']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getResponse()->getContent()->getText(); + $data = json_decode($text, true); + + $this->assertEquals('[REDACTED]', $data['token']); + $this->assertEquals('john', $data['user']); + $this->assertEquals(3600, $data['expires']); + } + + public function testRedactBodyFieldsNestedJson(): void + { + $json = json_encode([ + 'user' => [ + 'name' => 'john', + 'credentials' => [ + 'password' => 'secret123', + 'api_key' => 'key123', + ], + ], + 'password' => 'top-level-password', + ]); + + $har = $this->createHarWithJsonPostData($json); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password', 'api_key']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData()->getText(); + $data = json_decode($text, true); + + $this->assertEquals('[REDACTED]', $data['password']); + $this->assertEquals('[REDACTED]', $data['user']['credentials']['password']); + $this->assertEquals('[REDACTED]', $data['user']['credentials']['api_key']); + $this->assertEquals('john', $data['user']['name']); + } + + public function testRedactBodyFieldsCaseInsensitive(): void + { + $json = json_encode([ + 'PASSWORD' => 'secret1', + 'Password' => 'secret2', + ]); + + $har = $this->createHarWithJsonPostData($json); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData()->getText(); + $data = json_decode($text, true); + + $this->assertEquals('[REDACTED]', $data['PASSWORD']); + $this->assertEquals('[REDACTED]', $data['Password']); + } + + public function testRedactBodyFieldsFluentInterface(): void + { + $sanitizer = new HarSanitizer(); + + $result = $sanitizer->redactBodyFields(['password']); + + $this->assertSame($sanitizer, $result); + } + + public function testRedactBodyFieldsNonJsonContentUnchanged(): void + { + $har = $this->createHarWithTextResponse('plain text content'); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getResponse()->getContent()->getText(); + $this->assertEquals('plain text content', $text); + } + + public function testRedactBodyFieldsInvalidJsonUnchanged(): void + { + $postData = (new PostData()) + ->setMimeType('application/json') + ->setText('not valid json {'); + + $request = (new Request()) + ->setMethod('POST') + ->setUrl(new Uri('https://example.com')) + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setPostData($postData); + + $har = $this->createHarWithRequest($request); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData()->getText(); + $this->assertEquals('not valid json {', $text); + } + + public function testRedactBodyFieldsEmptyJsonText(): void + { + // Test with empty string JSON body - covers line 294 + $postData = (new PostData()) + ->setMimeType('application/json') + ->setText(''); + + $request = (new Request()) + ->setMethod('POST') + ->setUrl(new Uri('https://example.com')) + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setPostData($postData); + + $har = $this->createHarWithRequest($request); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + // Empty text should remain empty (sanitizeJsonText returns null, setText not called) + $this->assertEquals('', $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData()->getText()); + } + + public function testRedactBodyFieldsJsonPrimitiveValue(): void + { + // Test with JSON primitive (string) - covers line 317 (non-array data) + $json = json_encode('just a string'); + + $har = $this->createHarWithJsonResponse($json); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getResponse()->getContent()->getText(); + // Primitive should be unchanged + $this->assertEquals('"just a string"', $text); + } + + public function testRedactBodyFieldsJsonArray(): void + { + $json = json_encode([ + ['id' => 1, 'password' => 'secret1'], + ['id' => 2, 'password' => 'secret2'], + ]); + + $har = $this->createHarWithJsonResponse($json); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getResponse()->getContent()->getText(); + $data = json_decode($text, true); + + $this->assertEquals('[REDACTED]', $data[0]['password']); + $this->assertEquals('[REDACTED]', $data[1]['password']); + $this->assertEquals(1, $data[0]['id']); + $this->assertEquals(2, $data[1]['id']); + } + + public function testRedactBodyFieldsJsonMimeTypeWithNoText(): void + { + // Create content with JSON mime type but no text + $content = (new Content()) + ->setSize(0) + ->setCompression(0) + ->setMimeType('application/json'); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setContent($content) + ->setRedirectURL(new Uri('')); + + $har = $this->createHarWithResponse($response); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + // Should not throw - nothing to sanitize + $sanitized = $sanitizer->sanitize($har); + + // Content should not have text + $this->assertFalse($sanitized->getLog()->getEntries()[0]->getResponse()->getContent()->hasText()); + } + + public function testRedactBodyFieldsPostDataJsonMimeTypeWithNoText(): void + { + // Create postData with JSON mime type but using params instead of text + $postData = (new PostData()) + ->setMimeType('application/json') + ->setParams([(new Params())->setName('password')->setValue('secret')]); + + $request = (new Request()) + ->setMethod('POST') + ->setUrl(new Uri('https://example.com')) + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setPostData($postData); + + $har = $this->createHarWithRequest($request); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + // Params should be redacted, but no JSON text processing should occur + $params = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData()->getParams(); + $paramMap = $this->paramsToMap($params); + + $this->assertEquals('[REDACTED]', $paramMap['password']); + } + + public function testRedactBodyFieldsJsonPreservesSlashesAndUnicode(): void + { + // Test that slashes and unicode are preserved (not escaped) + $json = json_encode([ + 'url' => 'https://example.com/path', + 'name' => 'ユーザー', + 'password' => 'secret', + ], \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); + + $har = $this->createHarWithJsonPostData($json); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactBodyFields(['password']); + + $sanitized = $sanitizer->sanitize($har); + + $text = $sanitized->getLog()->getEntries()[0]->getRequest()->getPostData()->getText(); + + // Verify URL slashes are not escaped + $this->assertStringContainsString('https://example.com/path', $text); + // Verify unicode is not escaped + $this->assertStringContainsString('ユーザー', $text); + // Verify password is redacted + $data = json_decode($text, true); + $this->assertEquals('[REDACTED]', $data['password']); + } + + public function testRedactCookies(): void + { + $har = $this->createHarWithRequestCookies([ + 'session_id' => 'abc123', + 'tracking' => 'xyz789', + 'preferences' => 'dark_mode', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactCookies(['session_id', 'tracking']); + + $sanitized = $sanitizer->sanitize($har); + + $cookies = $sanitized->getLog()->getEntries()[0]->getRequest()->getCookies(); + $cookieMap = $this->cookiesToMap($cookies); + + $this->assertEquals('[REDACTED]', $cookieMap['session_id']); + $this->assertEquals('[REDACTED]', $cookieMap['tracking']); + $this->assertEquals('dark_mode', $cookieMap['preferences']); + } + + public function testRedactResponseCookies(): void + { + $har = $this->createHarWithResponseCookies([ + 'session_id' => 'secret-session', + 'auth_token' => 'secret-token', + 'locale' => 'en_US', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactCookies(['session_id', 'auth_token']); + + $sanitized = $sanitizer->sanitize($har); + + $cookies = $sanitized->getLog()->getEntries()[0]->getResponse()->getCookies(); + $cookieMap = $this->cookiesToMap($cookies); + + $this->assertEquals('[REDACTED]', $cookieMap['session_id']); + $this->assertEquals('[REDACTED]', $cookieMap['auth_token']); + $this->assertEquals('en_US', $cookieMap['locale']); + } + + public function testRedactCookiesCaseInsensitive(): void + { + $har = $this->createHarWithRequestCookies([ + 'SESSION_ID' => 'secret1', + 'Session_Id' => 'secret2', + ]); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactCookies(['session_id']); + + $sanitized = $sanitizer->sanitize($har); + + $cookies = $sanitized->getLog()->getEntries()[0]->getRequest()->getCookies(); + $cookieMap = $this->cookiesToMap($cookies); + + $this->assertEquals('[REDACTED]', $cookieMap['SESSION_ID']); + $this->assertEquals('[REDACTED]', $cookieMap['Session_Id']); + } + + public function testRedactCookiesFluentInterface(): void + { + $sanitizer = new HarSanitizer(); + + $result = $sanitizer->redactCookies(['session_id']); + + $this->assertSame($sanitizer, $result); + } + + public function testRedactCookiesOriginalUnmodified(): void + { + $har = $this->createHarWithRequestCookies([ + 'session_id' => 'original-value', + ]); + + $originalValue = $har->getLog()->getEntries()[0]->getRequest()->getCookies()[0]->getValue(); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactCookies(['session_id']); + $sanitizer->sanitize($har); + + // Original should be unchanged + $currentValue = $har->getLog()->getEntries()[0]->getRequest()->getCookies()[0]->getValue(); + $this->assertEquals($originalValue, $currentValue); + $this->assertEquals('original-value', $currentValue); + } + + public function testWithRealFixture(): void + { + $repository = $this->getHarFileRepository(); + $har = $repository->load('www.softwareishard.com-single-entry.har'); + + $sanitizer = new HarSanitizer(); + $sanitizer->redactHeaders(['Accept-Encoding', 'User-Agent']); + + $sanitized = $sanitizer->sanitize($har); + + $headers = $sanitized->getLog()->getEntries()[0]->getRequest()->getHeaders(); + $headerMap = $this->headersToMap($headers); + + // These headers should be redacted if they exist + if (isset($headerMap['Accept-Encoding'])) { + $this->assertEquals('[REDACTED]', $headerMap['Accept-Encoding']); + } + if (isset($headerMap['User-Agent'])) { + $this->assertEquals('[REDACTED]', $headerMap['User-Agent']); + } + } + + /** + * @param array $headers + */ + private function createHarWithHeaders(array $headers): Har + { + $headerObjects = []; + foreach ($headers as $name => $value) { + $header = (new Header())->setName($name)->setValue($value); + $headerObjects[] = $header; + } + + $request = (new Request()) + ->setMethod('GET') + ->setUrl(new Uri('https://example.com')) + ->setHeaders($headerObjects) + ->setHttpVersion('HTTP/1.1'); + + return $this->createHarWithRequest($request); + } + + /** + * @param array $headers + */ + private function createHarWithResponseHeaders(array $headers): Har + { + $headerObjects = []; + foreach ($headers as $name => $value) { + $header = (new Header())->setName($name)->setValue($value); + $headerObjects[] = $header; + } + + $content = (new Content()) + ->setSize(0) + ->setCompression(0); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHeaders($headerObjects) + ->setHttpVersion('HTTP/1.1') + ->setContent($content) + ->setRedirectURL(new Uri('')); + + return $this->createHarWithResponse($response); + } + + /** + * @param array $params + */ + private function createHarWithQueryParams(array $params): Har + { + $paramObjects = []; + foreach ($params as $name => $value) { + $param = (new Params())->setName($name)->setValue($value); + $paramObjects[] = $param; + } + + $request = (new Request()) + ->setMethod('GET') + ->setUrl(new Uri('https://example.com')) + ->setQueryString($paramObjects) + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1'); + + return $this->createHarWithRequest($request); + } + + private function createHarWithMultipleEntries(): Har + { + $entries = []; + + for ($i = 0; $i < 2; ++$i) { + $headers = [ + (new Header())->setName('Authorization')->setValue('Bearer token-'.$i), + ]; + + $request = (new Request()) + ->setMethod('GET') + ->setUrl(new Uri('https://example.com/api/'.$i)) + ->setHeaders($headers) + ->setHttpVersion('HTTP/1.1'); + + $content = (new Content()) + ->setSize(0) + ->setCompression(0); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setContent($content) + ->setRedirectURL(new Uri('')); + + $timings = (new Timings()) + ->setBlocked(-1) + ->setDns(-1) + ->setConnect(-1) + ->setSsl(-1) + ->setSend(0) + ->setWait(100) + ->setReceive(10); + + $entry = (new Entry()) + ->setRequest($request) + ->setResponse($response) + ->setCache(new Cache()) + ->setTimings($timings) + ->setTime(110); + + $entries[] = $entry; + } + + $creator = (new Creator()) + ->setName('Test') + ->setVersion('1.0'); + + $log = (new Log()) + ->setVersion('1.2') + ->setCreator($creator) + ->setEntries($entries); + + return (new Har())->setLog($log); + } + + private function createHarWithRequest(Request $request): Har + { + $content = (new Content()) + ->setSize(0) + ->setCompression(0); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setContent($content) + ->setRedirectURL(new Uri('')); + + return $this->createHarWithRequestAndResponse($request, $response); + } + + private function createHarWithResponse(Response $response): Har + { + $request = (new Request()) + ->setMethod('GET') + ->setUrl(new Uri('https://example.com')) + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1'); + + return $this->createHarWithRequestAndResponse($request, $response); + } + + private function createHarWithRequestAndResponse(Request $request, Response $response): Har + { + $timings = (new Timings()) + ->setBlocked(-1) + ->setDns(-1) + ->setConnect(-1) + ->setSsl(-1) + ->setSend(0) + ->setWait(100) + ->setReceive(10); + + $entry = (new Entry()) + ->setRequest($request) + ->setResponse($response) + ->setCache(new Cache()) + ->setTimings($timings) + ->setTime(110); + + $creator = (new Creator()) + ->setName('Test') + ->setVersion('1.0'); + + $log = (new Log()) + ->setVersion('1.2') + ->setCreator($creator) + ->setEntries([$entry]); + + return (new Har())->setLog($log); + } + + /** + * @param Header[] $headers + * + * @return array + */ + private function headersToMap(array $headers): array + { + $map = []; + foreach ($headers as $header) { + $map[$header->getName()] = $header->getValue(); + } + + return $map; + } + + /** + * @param Params[] $params + * + * @return array + */ + private function paramsToMap(array $params): array + { + $map = []; + foreach ($params as $param) { + $map[$param->getName()] = $param->getValue(); + } + + return $map; + } + + /** + * @param array $params + */ + private function createHarWithPostParams(array $params): Har + { + $paramObjects = []; + foreach ($params as $name => $value) { + $param = (new Params())->setName($name)->setValue($value); + $paramObjects[] = $param; + } + + $postData = (new PostData()) + ->setMimeType('application/x-www-form-urlencoded') + ->setParams($paramObjects); + + $request = (new Request()) + ->setMethod('POST') + ->setUrl(new Uri('https://example.com')) + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setPostData($postData); + + return $this->createHarWithRequest($request); + } + + private function createHarWithJsonPostData(string $json): Har + { + $postData = (new PostData()) + ->setMimeType('application/json') + ->setText($json); + + $request = (new Request()) + ->setMethod('POST') + ->setUrl(new Uri('https://example.com')) + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setPostData($postData); + + return $this->createHarWithRequest($request); + } + + private function createHarWithJsonResponse(string $json): Har + { + $content = (new Content()) + ->setSize(\strlen($json)) + ->setCompression(0) + ->setMimeType('application/json') + ->setText($json); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setContent($content) + ->setRedirectURL(new Uri('')); + + return $this->createHarWithResponse($response); + } + + private function createHarWithTextResponse(string $text): Har + { + $content = (new Content()) + ->setSize(\strlen($text)) + ->setCompression(0) + ->setMimeType('text/plain') + ->setText($text); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHeaders([]) + ->setHttpVersion('HTTP/1.1') + ->setContent($content) + ->setRedirectURL(new Uri('')); + + return $this->createHarWithResponse($response); + } + + /** + * @param array $cookies + */ + private function createHarWithRequestCookies(array $cookies): Har + { + $cookieObjects = []; + foreach ($cookies as $name => $value) { + $cookie = (new Cookie())->setName($name)->setValue($value); + $cookieObjects[] = $cookie; + } + + $request = (new Request()) + ->setMethod('GET') + ->setUrl(new Uri('https://example.com')) + ->setHeaders([]) + ->setCookies($cookieObjects) + ->setHttpVersion('HTTP/1.1'); + + return $this->createHarWithRequest($request); + } + + /** + * @param array $cookies + */ + private function createHarWithResponseCookies(array $cookies): Har + { + $cookieObjects = []; + foreach ($cookies as $name => $value) { + $cookie = (new Cookie())->setName($name)->setValue($value); + $cookieObjects[] = $cookie; + } + + $content = (new Content()) + ->setSize(0) + ->setCompression(0); + + $response = (new Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHeaders([]) + ->setCookies($cookieObjects) + ->setHttpVersion('HTTP/1.1') + ->setContent($content) + ->setRedirectURL(new Uri('')); + + return $this->createHarWithResponse($response); + } + + /** + * @param Cookie[] $cookies + * + * @return array + */ + private function cookiesToMap(array $cookies): array + { + $map = []; + foreach ($cookies as $cookie) { + $map[$cookie->getName()] = $cookie->getValue(); + } + + return $map; + } +} diff --git a/tests/src/Unit/PostDataTest.php b/tests/src/Unit/PostDataTest.php index 9544bfba..295b46bb 100644 --- a/tests/src/Unit/PostDataTest.php +++ b/tests/src/Unit/PostDataTest.php @@ -324,4 +324,28 @@ public function testGetParamsCallsTraitSetText(): void // This test kills the MethodCallRemoval mutant at PostData.php:37 // If traitSetText() is not called, text wouldn't be properly cleared } + + public function testCloneIsDeep(): void + { + $postData = (new PostData()) + ->setMimeType('application/x-www-form-urlencoded') + ->setParams([ + (new Params())->setName('username')->setValue('john'), + (new Params())->setName('password')->setValue('secret'), + ]); + + $cloned = clone $postData; + + // Verify params are cloned + $this->assertNotSame($postData->getParams()[0], $cloned->getParams()[0]); + $this->assertNotSame($postData->getParams()[1], $cloned->getParams()[1]); + + // Modify cloned params + $cloned->getParams()[0]->setValue('jane'); + $cloned->getParams()[1]->setValue('newpassword'); + + // Verify original is unchanged + $this->assertEquals('john', $postData->getParams()[0]->getValue()); + $this->assertEquals('secret', $postData->getParams()[1]->getValue()); + } } diff --git a/tests/src/Unit/RequestTest.php b/tests/src/Unit/RequestTest.php index de2eb091..a38915c3 100644 --- a/tests/src/Unit/RequestTest.php +++ b/tests/src/Unit/RequestTest.php @@ -266,4 +266,44 @@ public function testFromPsr7ServerRequestWithQueryParams(): void $this->assertEquals('baz', $queryParams[1]->getName()); $this->assertEquals('qux', $queryParams[1]->getValue()); } + + public function testCloneIsDeep(): void + { + $request = (new Request()) + ->setMethod('POST') + ->setUrl(new Uri('https://example.com')) + ->setHeaders([ + (new Header())->setName('Authorization')->setValue('Bearer token'), + ]) + ->setCookies([ + (new Cookie())->setName('session')->setValue('abc123'), + ]) + ->setQueryString([ + (new \Deviantintegral\Har\Params())->setName('foo')->setValue('bar'), + ]) + ->setPostData((new PostData())->setText('test body')) + ->setHttpVersion('HTTP/1.1'); + + $cloned = clone $request; + + // Verify headers are cloned + $this->assertNotSame($request->getHeaders()[0], $cloned->getHeaders()[0]); + $cloned->getHeaders()[0]->setValue('Bearer new-token'); + $this->assertEquals('Bearer token', $request->getHeaders()[0]->getValue()); + + // Verify cookies are cloned + $this->assertNotSame($request->getCookies()[0], $cloned->getCookies()[0]); + $cloned->getCookies()[0]->setValue('xyz789'); + $this->assertEquals('abc123', $request->getCookies()[0]->getValue()); + + // Verify query params are cloned + $this->assertNotSame($request->getQueryString()[0], $cloned->getQueryString()[0]); + $cloned->getQueryString()[0]->setValue('baz'); + $this->assertEquals('bar', $request->getQueryString()[0]->getValue()); + + // Verify postData is cloned + $this->assertNotSame($request->getPostData(), $cloned->getPostData()); + $cloned->getPostData()->setText('modified body'); + $this->assertEquals('test body', $request->getPostData()->getText()); + } } diff --git a/tests/src/Unit/ResponseTest.php b/tests/src/Unit/ResponseTest.php index 13af1320..0ed04c37 100644 --- a/tests/src/Unit/ResponseTest.php +++ b/tests/src/Unit/ResponseTest.php @@ -86,4 +86,38 @@ public function testSetHeadersWithEmptyArray(): void $response->setHeaders([]); $this->assertSame(2, $response->getHeadersSize()); } + + public function testCloneIsDeep(): void + { + $content = (new Content())->setText('test content'); + $response = (new \Deviantintegral\Har\Response()) + ->setStatus(200) + ->setStatusText('OK') + ->setHeaders([ + (new Header())->setName('Set-Cookie')->setValue('session=abc123'), + ]) + ->setCookies([ + (new \Deviantintegral\Har\Cookie())->setName('session')->setValue('abc123'), + ]) + ->setContent($content) + ->setRedirectURL(new Uri('')) + ->setHttpVersion('HTTP/1.1'); + + $cloned = clone $response; + + // Verify headers are cloned + $this->assertNotSame($response->getHeaders()[0], $cloned->getHeaders()[0]); + $cloned->getHeaders()[0]->setValue('session=xyz789'); + $this->assertEquals('session=abc123', $response->getHeaders()[0]->getValue()); + + // Verify cookies are cloned + $this->assertNotSame($response->getCookies()[0], $cloned->getCookies()[0]); + $cloned->getCookies()[0]->setValue('xyz789'); + $this->assertEquals('abc123', $response->getCookies()[0]->getValue()); + + // Verify content is cloned + $this->assertNotSame($response->getContent(), $cloned->getContent()); + $cloned->getContent()->setText('modified content'); + $this->assertEquals('test content', $response->getContent()->getText()); + } }