Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add redactions to VCR #344

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Disclaimer: Doing this in PHP is not as easy as in programming languages which s
implement a custom request matcher to handle any need.
* The recorded requests and responses are stored on disk in a serialization format of your choice
(currently YAML and JSON are built in, and you can easily implement your own custom serializer)
* Private data can be redacted from storage using `addRedaction()`
* Supports PHPUnit annotations.

## Usage example
Expand Down
15 changes: 9 additions & 6 deletions src/VCR/Cassette.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace VCR;

use VCR\Storage\Storage;
use VCR\Util\Scrubber;

/**
* A Cassette records and plays back pairs of Requests and Responses in a Storage.
Expand Down Expand Up @@ -67,10 +68,14 @@ public function hasResponse(Request $request): bool
*/
public function playback(Request $request): ?Response
{
$scrubber = new Scrubber($this->config);

foreach ($this->storage as $recording) {
$storedRequest = Request::fromArray($recording['request']);
$unscrubbedRecording = $scrubber->unscrub($recording);

$storedRequest = Request::fromArray($unscrubbedRecording['request']);
if ($storedRequest->matches($request, $this->getRequestMatchers())) {
return Response::fromArray($recording['response']);
return Response::fromArray($unscrubbedRecording['response']);
}
}

Expand All @@ -89,10 +94,8 @@ public function record(Request $request, Response $response): void
return;
}

$recording = [
'request' => $request->toArray(),
'response' => $response->toArray(),
];
$scrubber = new Scrubber($this->config);
$recording = $scrubber->scrub($request, $response);

$this->storage->storeRecording($recording);
}
Expand Down
45 changes: 45 additions & 0 deletions src/VCR/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ class Configuration
*/
private $enabledRequestMatchers;

/**
* A hash of redactions that have been configured.
*
* Format:
* array(
* '<REPLACEMENT>' => callable(VCR\Request $request, VCR\Response $response): ?string
* )
*
* @var array<string, callable>
*/
private $redactions;

/**
* Format:
* array(
Expand Down Expand Up @@ -369,6 +381,39 @@ public function setMode(string $mode): self
return $this;
}

/**
* Gets the defined redactions.
*
* @return array<string, callable>
*/
public function getRedactions(): array
{
return $this->redactions ?? [];
}

/**
* Adds a redaction.
*
* @param string $replacement The string that replaces the private value in storage
* @param string|callable $secret Either the secret to replace, or a callback which returns the secret
*/
public function addRedaction(string $replacement, $secret): self
{
if (\is_string($secret) && '' != $replacement) {
$func = function ($request, $response) use ($secret) {
return $secret;
};
} elseif (\is_callable($secret)) {
$func = $secret;
} else {
throw new \InvalidArgumentException('Redaction replacement string must be a non-empty string or callable.');
}

$this->redactions[$replacement] = $func;

return $this;
}

/**
* Validates a specified cassette path.
*
Expand Down
2 changes: 1 addition & 1 deletion src/VCR/Storage/Storage.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface Storage extends \Iterator
/**
* Stores an array of data.
*
* @param array<string,string|null|array<string,mixed>> $recording array to store in storage
* @param array<string,string|array<string,mixed>|null> $recording array to store in storage
*/
public function storeRecording(array $recording): void;

Expand Down
2 changes: 1 addition & 1 deletion src/VCR/Util/CurlHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ public static function getCurlOptionFromResponse(Response $response, int $option
case \CURLINFO_HEADER_SIZE:
$info = mb_strlen(HttpUtil::formatAsStatusWithHeadersString($response), 'ISO-8859-1');
break;
case CURLPROXY_HTTPS:
case \CURLPROXY_HTTPS:
$info = '';
break;
default:
Expand Down
125 changes: 125 additions & 0 deletions src/VCR/Util/Scrubber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

namespace VCR\Util;

use VCR\Configuration;
use VCR\Request;
use VCR\Response;

class Scrubber
{
/**
* VCR configuration.
*
* @var Configuration
*/
protected $config;

/**
* Create a new Scrubber.
*
* @param Configuration $config configuration to use for this scrubber
*/
public function __construct(Configuration $config)
{
$this->config = $config;
}

/**
* Scrub the given request/response of all secrets, using the configured redactions.
*
* @param Request $request request to scrub
* @param Response $response response to scrub
*
* @return array<string,mixed> The scrubbed recording
*/
public function scrub(Request $request, Response $response)
{
$redactions = $this->evaluateRedactions($request, $response);
$recording = $this->buildRecording($request, $response);

return $this->scrubArray($recording, $redactions);
}

/**
* Unmask the redacted parts of a recording, using the configured redactions.
*
* @param array<string,mixed> $recording The recording, ie. an array with 'request' and 'response' keys
*
* @return array<string,mixed> The unscrubbed recording
*/
public function unscrub($recording)
{
$request = Request::fromArray($recording['request']);
$response = Response::fromArray($recording['response']);

$unmaskings = array_flip($this->evaluateRedactions($request, $response));

return $this->scrubArray($recording, $unmaskings);
}

/**
* Evaluate the configured redactions in the context of the request/response pair.
*
* @param Request $request The request
* @param Response $response The response
*
* @return array<string, string> An array of token => replacement pairs
*/
private function evaluateRedactions($request, $response)
{
$replacements = [];

foreach ($this->config->getRedactions() as $replacement => $callback) {
$privateData = $callback($request, $response);
if ($privateData) {
if (!\is_string($privateData)) {
throw new \InvalidArgumentException("Redaction callback for $replacement did not return a string");
}
$replacements[$replacement] = $privateData;
}
}

return $replacements;
}

/**
* Builds a recording in standard VCR format.
*
* @param Request $request The request
* @param Response $response The response
*
* @return array<string,mixed> The recording, ie. an array with request and response keys
*/
private function buildRecording(Request $request, Response $response)
{
return [
'request' => $request->toArray(),
'response' => $response->toArray(),
];
}

/**
* Walk an array recursively, replacing substrings on each key.
*
* @param array<string,mixed> $arr The array to traverse
* @param array<string,string> $replacements Replacements in search=>replacement pairs
*
* @return array<string,mixed> The resulting array with all replacements performed
*/
private function scrubArray(array &$arr, $replacements)
{
$search = array_values($replacements);
$replace = array_keys($replacements);

foreach ($arr as $key => $value) {
if (\is_string($value)) {
$arr[$key] = str_replace($search, $replace, $value);
} elseif (\is_array($value)) {
$arr[$key] = $this->scrubArray($value, $replacements);
}
}

return $arr;
}
}
33 changes: 33 additions & 0 deletions tests/VCR/ConfigurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,37 @@ public function testSetModeInvalidName(): void
$this->expectExceptionMessage("Mode 'invalid' does not exist.");
$this->config->setMode('invalid');
}

public function testAddRedactionFailsWithNoToken(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Redaction replacement string must be a non-empty string or callable.');
$this->config->addRedaction('', 'secret123');
}

public function testAddRedactionWithString(): void
{
$this->config->addRedaction('<PASSWORD>', 'secret123');
$filters = $this->config->getRedactions();
$this->assertCount(1, $filters);
$this->assertArrayHasKey('<PASSWORD>', $filters);
$this->assertIsCallable($filters['<PASSWORD>']);

$request = new \VCR\Request('GET', 'http://example.com');
$response = new \VCR\Response('200', [], 'body');

/* @phpstan-ignore-next-line */
$this->assertEquals('secret123', $filters['<PASSWORD>']($request, $response));
}

public function testAddRedactionWithCallable(): void
{
$expected = function ($request, $response) {
return 'secret123';
};

$this->config->addRedaction('<PASSWORD>', $expected);
$filters = $this->config->getRedactions();
$this->assertEquals($expected, $filters['<PASSWORD>']);
}
}