Illya Moskvin

Redacting secrets and PII from VCR.py cassettes

Illustration of the VCR.py logo in the style of a photocopy with the vcr.py text redacted and a “Top Secret” stamp on top

This is a story about overengineering. Like most of my posts, if you found this, you are probably looking for a clever solution to some very specific problem. I’m going to give you that solution—and then, I’m going to tell you why you may want to avoid it.

Quick context for everyone else:

This creates a problem:

Now, it may take a while before you run into this issue at full force. You’ll likely be running your integration tests (1) against a dev environment, and (2) using fake data. Your first focus will likely be on redacting secrets that you send in your request, and VCR.py has filter_headers and filter_query_parameters for that:

# By the way, we are working with pytest and pytest-vcr for these examples
# Put this into e.g. tests/conftest.py
@pytest.fixture(scope="module")
def vcr_config():
    return {
        "filter_headers": ["authorization"],
        "filter_query_parameters": ['api_key'],
    }

Eventually, you may encounter a case where there are secrets in the response body:

So you read about before_record_response and find yourself at a cross-roads:

Let’s walk through these options in reverse order.

Committing encrypted cassettes #

I imagine that at most enterprises, this would not pass muster. There’s too much risk of privilege escalation and credential spoofing from anyone who has access to the encryption keys, including via CI. But there are tools for it, e.g. vcrpy-encrypt, git-crypt and Ansible Vault. Depending on your situation and precedents, this could be an option.

Writing a generic scrubber #

This is where I got trapped. It’s really tempting to try to cook up a one-size-fits-all secret-scrubbing solution that doesn’t care about the structure of the response. It’s an interesting puzzle to solve, and it seems doable—but with some major caveats.

We can use Presidio and detect-secrets to process the reponse. Presidio is Microsoft’s open-source Python package for removing PII from free-text data, and detect-secrets does what it says on the box.

For context, I was specifically interested in building a flexible solution for sanitizing JSON API responses, not free-text content (e.g. web pages). Recording API interactions is the typical use-case for VCR.py.

I’ll share my rough working solution. You’ll need these packages:

# Install these via your package manager of choice
pytest>=9.0.1
pytest-recording>=0.13.4
vcrpy>=7.0.0
presidio-analyzer>=2.2.360
presidio-anonymizer>=2.2.360
presidio-structured>=0.0.6
detect-secrets>=1.5.0

Here’s the code:

import json
from contextlib import nullcontext

import pytest
import spacy
import tldextract
from detect_secrets.core.scan import scan_line
from detect_secrets.settings import default_settings, get_settings, transient_settings
from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig
from presidio_structured import (
    JsonAnalysisBuilder,
    JsonDataProcessor,
    StructuredAnalysis,
    StructuredEngine,
)


@pytest.fixture(scope="session", autouse=True)
def prewarm_presidio():
    """
    Download Presidio resources to avoid network requests during tests.
    """
    tldextract.extract("test@example.com")  # download TLD cache
    spacy.load("en_core_web_lg")  # initialize spaCy model


# Initialize Presidio with spaCy support for name recognition
nlp_configuration = {
    "nlp_engine_name": "spacy",
    "models": [{"lang_code": "en", "model_name": "en_core_web_lg"}],
}
nlp_engine = NlpEngineProvider(nlp_configuration=nlp_configuration).create_engine()
json_engine = StructuredEngine(data_processor=JsonDataProcessor())
_analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=["en"])
_json_analysis_builder = JsonAnalysisBuilder(analyzer=_analyzer)
_anonymizer = AnonymizerEngine()
_operators = {
    "PERSON": OperatorConfig("replace", {"new_value": "<PERSON>"}),
    "EMAIL_ADDRESS": OperatorConfig("replace", {"new_value": "<EMAIL_ADDRESS>"}),
}

# Configure detect-secrets to use default settings
with default_settings() as settings:
    pass
get_settings().set(settings)


def conditional_settings(secret_settings: dict | None):
    """
    Context manager to apply detect-secrets settings if provided.
    """
    if secret_settings:
        return transient_settings(secret_settings)
    else:
        return nullcontext()


def redact_secrets_dict(data):
    """
    Find and redact passwords/secrets in a nested dictionary.
    """
    if isinstance(data, dict):
        result = {}
        for key, value in data.items():
            if isinstance(value, (dict, list)):
                result[key] = redact_secrets_dict(value)
            elif isinstance(value, str):
                # Format as key-value for better detection (detector needs context)
                line = f'"{key}": "{value}"'
                secret_generator = scan_line(line)
                secret = next(secret_generator, None)

                if secret:
                    result[key] = "<SECRET>"
                else:
                    result[key] = value
            else:
                result[key] = value
        return result
    elif isinstance(data, list):
        return [redact_secrets_dict(item) for item in data]
    else:
        return data


def redact_secrets_multiline(data: str) -> str:
    """
    Find and redact passwords/secrets in a multi-line string.
    """
    cleaned_lines = []
    for line in data.splitlines():
        secret_generator = scan_line(line)
        for secret in secret_generator:
            if secret.secret_value:
                line = line.replace(secret.secret_value, "<SECRET>")
        cleaned_lines.append(line)
    return "\n".join(cleaned_lines)


def redact_response_factory(
    entity_mapping: dict | None = None,
    secret_settings: dict | None = None,
):
    """
    Create a redact_response function with optional configuration.
    """
    def redact_response(response):
        """
        Redact secrets from the response body using Presidio and detect-secrets.
        """
        body = response["body"]["string"]
        is_body_bytes = isinstance(body, bytes)

        if is_body_bytes:
            body = body.decode("utf-8")

        try:
            # Try to parse body as JSON
            data = json.loads(body)

            # First, use presidio-structured to redact PII
            if entity_mapping:
                # Custom entity mapping provided
                json_analysis = StructuredAnalysis(entity_mapping=entity_mapping)
            else:
                # Auto-detect entities
                json_analysis = _json_analysis_builder.generate_analysis(
                    data=data,
                    language="en"
                )

            redacted_data = json_engine.anonymize(
                data=data,
                structured_analysis=json_analysis,
                operators=_operators
            )

            # Then, use detect-secrets to find and redact passwords/secrets
            with conditional_settings(secret_settings):
                redacted_data = redact_secrets_dict(redacted_data)

            redacted_body = json.dumps(redacted_data)
        except json.JSONDecodeError:
            # If not JSON, use Presidio with plain text
            results = _analyzer.analyze(text=body, language='en')
            anonymized = _anonymizer.anonymize(text=body, analyzer_results=results)
            redacted_body = anonymized.text

            # Run detect-secrets on each line
            with conditional_settings(secret_settings):
                redacted_data = redact_secrets_multiline(redacted_data)

        if is_body_bytes:
            redacted_body = redacted_body.encode("utf-8")

        response["body"]["string"] = redacted_body

        return response
    return redact_response


@pytest.fixture(scope="module")
def vcr_config():
    """
    Configure vcrpy for recording/replaying HTTP interactions.
    """
    return {
        "filter_headers": ['authorization'],
        "before_record_response": redact_response_factory(),
        "record_mode": "once",  # pytest-recording default is "none"
    }

You can use it like this:

@pytest.mark.vcr()
def test_without_config():
    ...

@pytest.mark.vcr(
    before_record_response=redact_response_factory(
        entity_mapping={
            # https://microsoft.github.io/presidio/structured/
            "data.name": "PERSON",
            "data.email": "EMAIL_ADDRESS",
        },
        secret_settings={
            # https://github.com/Yelp/detect-secrets/#usage-in-other-python-scripts
            "plugins_used": [{"name": "KeywordDetector"}]
        }
    )
)
def test_with_config():
    ...

Results are promising! My test example produced a clean cassette:

interactions:
- request:
    body: null
    headers:
      Accept:
      - '*/*'
      Accept-Encoding:
      - gzip, deflate
      Connection:
      - keep-alive
      User-Agent:
      - python-requests/2.32.5
    method: GET
    uri: http://localhost:8000/hello
  response:
    body:
      string: '{"message": "Hello, World!", "status": "success", "data": {"id": 12345,
        "name": "<PERSON>", "email": "<EMAIL_ADDRESS>", "password": "<SECRET>"}}'
    headers:
      content-length:
      - '140'
      content-type:
      - application/json
      date:
      - Sun, 30 Nov 2025 08:08:57 GMT
      server:
      - uvicorn
    status:
      code: 200
      message: OK
version: 1

What are the downsides? Let’s take a look:

  1. Added 670 MB to my .venv. (Including 382 MB from model.)
  2. Added 10 sec to uv sync from scratch. (Better than expected.)
  3. Sample test runs 6 sec slower (~5 sec to load model).
  4. I have to specify configuration for good results.
  5. Secret/PII redaction is not guaranteed.

Performance is better than expected. There are no deal-breakers here, but more work would be needed to mitigate the downsides. The reward for good work is more work—and the reward for over-engineering is more engineering!

  1. We may want to include the model in our CI base image.
  2. We need to make sure we only load the model once.
  3. Add a context manager to set config per request.

The idea of adding a context manager is interesting: pytest-recording gives us the option to modify cassette configuration either via the vcr_config fixture, or via keyword arguments to individual pytest.mark.vcr marks. That’s nice, but it means that the same config will be used for all requests. What if we could change the scrubber configuration for each request?

Here is a context manager I wrote to do that:

from contextlib import contextmanager

@contextmanager
def vcr_record_patch(
    cassette,
    before_record_request=None,
    before_record_response=None,
):
    """
    Temporarily modify cassette redaction hooks within a context.

    Args:
        cassette: The VCR cassette instance to modify
        before_record_request: Function to process requests before recording
        before_record_response: Function to process responses before recording

    Example:
        ```
        with vcr_record_patch(vcr, before_record_response=my_custom_redactor):
            response = requests.get("http://example.com")
        ```
    """
    # Save original configuration
    original_before_record_request = cassette._before_record_request
    original_before_record_response = cassette._before_record_response

    if before_record_request is not None:
        cassette._before_record_request = before_record_request

    if before_record_response is not None:
        cassette._before_record_response = before_record_response

    try:
        yield cassette
    finally:
        # Restore original configuration
        cassette._before_record_request = original_before_record_request
        cassette._before_record_response = original_before_record_response

We can now configure redaction for specific parts of our tests:

import pytest
import requests

from .utils import redact_response_factory, vcr_record_patch

@pytest.mark.vcr()
def test_record_patch(vcr):
    with vcr_record_patch(vcr, before_record_response=redact_response_factory(
        # entity_mapping omitted for auto-detection of fields
        secret_settings={
            "plugins_used": [
                {"name": "AWSKeyDetector"},
            ]
        }
    )):
        response = requests.get(
            # Example endpoint that returns an AWS key for the current user
            "http://example.com/api/user-access",
            headers={"Authorization": "Bearer my-api-key"}
        )

It’s neat to see this working. But do we really need presidio and detect-secrets at this point? If we want good results from both, we must specify what fields we want to redact with presidio and what plugins we want to use from detect-secrets. Otherwise, we get over-aggressive redaction:

interactions:
- response:
    body:
      string: '{"message": "Hello, World!", "status": "<SECRET>", "data": {"id": "<None>",
        "name": "<PERSON>", "email": "<SECRET>", "password": "<SECRET>"}}'

On the flip side, both plugins might not catch PII and secrets: presidio uses machine learning models, so it’s probabalistic, and detect-secrets relies primarily on pattern matching and entropy calculations. You need to configure them to get decent results.

This defeats the point of writing a generic scrubber. 🚮

Redacting specific fields #

This is where I landed. If you are working with well-structured data, and if you know what fields you want to redact, why complicate things? Just redact what you want to redact.

Let’s use JSON Patch (RFC 6902) from python-jsonpath to specify what parts of the JSON we want to redact, and what values we want to use as replacements. We can adapt the context manager and factory we wrote to make this more elegant.

# Requirements
python-jsonpath>=2.0.1

Implementation code:

import json
from contextlib import contextmanager
from typing import Any, Callable

import pytest
from jsonpath.patch import JSONPatch, JSONPatchError


def create_redactor(path_mapping: dict[str, Any] | None) -> Callable | None:
    """
    Create a VCR.py before_record hook that redacts JSON response bodies.

    Args:
        path_mapping: Dict of JSON Pointer paths (RFC 6901) to replacement values

    Returns:
        A function for `before_record_request` or `before_record_response` hook

    Example:
        ```
        redactor = create_redactor({"/api/key": "***", "/user/token": "<REDACTED>"})
        cassette._before_record_response = redactor
        ```
    """
    if not path_mapping:
        return lambda data_dict: data_dict  # No-op redactor

    def redact(data_dict: dict):
        """Redact values at specified JSON Pointer paths."""
        body = data_dict["body"]["string"]
        is_body_bytes = isinstance(body, bytes)

        if is_body_bytes:
            body = body.decode("utf-8")

        try:
            data = json.loads(body)

            # Apply each path individually, skipping invalid paths
            for path, value in path_mapping.items():
                try:
                    patch = JSONPatch([{"op": "replace", "path": path, "value": value}])
                    data = patch.apply(data)
                except JSONPatchError:
                    # Path does not exist; skip this redaction
                    continue

            redacted_body = json.dumps(data)

            if is_body_bytes:
                redacted_body = redacted_body.encode("utf-8")  # type: ignore

            data_dict["body"]["string"] = redacted_body
        except json.JSONDecodeError:
            # If not JSON, leave unchanged
            pass

        return data_dict

    return redact


@contextmanager
def vcr_redact(cassette, request_paths=None, response_paths=None):
    """
    Temporarily modify cassette to redact specific JSON Pointer locations.

    Args:
        cassette: The VCR cassette instance to modify
        request_paths: Dict of JSON Pointer paths to replacement values
        response_paths: Dict of JSON Pointer paths to replacement values

    Example:
        ```
        with vcr_redact(vcr, response_paths={
            "/data/name": "<REDACTED>",
            "/data/email": "<EMAIL>",
        }):
            response = requests.get("http://example.com")
        ```
    """

    # Save original configuration
    original_before_record_request = cassette._before_record_request
    original_before_record_response = cassette._before_record_response

    # Set new redactors
    if request_paths:
        cassette._before_record_request = create_redactor(request_paths)

    if response_paths:
        cassette._before_record_response = create_redactor(response_paths)

    try:
        yield cassette
    finally:
        # Restore original configuration
        cassette._before_record_request = original_before_record_request
        cassette._before_record_response = original_before_record_response


@pytest.fixture(scope="module")
def vcr_config():
    """
    Configure vcrpy for recording/replaying HTTP interactions.
    """
    return {
        "filter_headers": ["authorization"],
        "record_mode": "once",
    }

Example usage:

@pytest.mark.vcr(
    before_record_response=create_redactor({
        "/data/name": "<PERSON>",
        "/data/email": "<EMAIL_ADDRESS>",
        "/data/password": "<SECRET>",
    })
)
def test_redact(vcr):
    ...

@pytest.mark.vcr()
def test_redact_context_manager(vcr):
    with vcr_redact(vcr, response_paths={
        "/data/name": "<PERSON>",
        "/data/email": "<EMAIL_ADDRESS>",
        "/data/password": "<SECRET>",
    }):
        ...

That’s it! Compared to the “smart” solution, our tests execute in half the time, and we only added 86 KB to our requirements. We also have complete control over what fields get redacted. This is a solution I’d actually use in my projects.

The one caveat I can see right now is that the redactions will be applied to all requests made in the context. This could be an issue if you want to redact a field with the same path for some requests, but not others.

I’m done tinkering with this—now, it’s your turn to over-engineer this to fit your needs. Everything you need is in this article, but if you want a sandbox, you can find my test code on GitHub:

https://github.com/IllyaMoskvin/vcrpy-secrets