Redacting secrets and PII from VCR.py cassettes

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:
- VCR.py is a Python library which records HTTP requests (and their responses) into “cassette” files. You can use these cassettes to speed up tests—or run integration tests in CI, which you might have trouble running otherwise.
- Personally Identifiable Information (PII) is any data that can be used to identify a specific individual, e.g. names, email addresses, phone numbers, and SSNs.
- Secrets are digital authentication credentials, e.g. API keys, encryption keys, passwords, or certificates.
This creates a problem:
- By design, cassette files should be committed into version control.
- However, secrets and PII should not be committed to version control.
- VCR.py does not scrub secrets and PII from cassettes: you must do so yourself!
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:
- The API responds with some derived credentials.
- You need to use some real data for stress-testing.
- There are test accounts with actual names and emails.
So you read about before_record_response and find yourself at a cross-roads:
- Option A: Whack-a-mole fields that need redacting.
- Option B: Write a generic function to redact any response.
- Option C: Commit unsanitized cassettes, but encrypt them.
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:
- Added 670 MB to my
.venv. (Including 382 MB from model.) - Added 10 sec to
uv syncfrom scratch. (Better than expected.) - Sample test runs 6 sec slower (~5 sec to load model).
- I have to specify configuration for good results.
- 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!
- We may want to include the model in our CI base image.
- We need to make sure we only load the model once.
- 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: