Skip to content

Commit f29efc4

Browse files
committed
Scrub API key from Sentry crash reports
The 'headers' local in codeplain_REST_api.post_request holds the raw API key (X-API-Key) and was serialized into stack trace frame vars. Add 'headers' and the hyphenated 'x-api-key' to the scrub denylist (the SDK default only covers the underscore form) and enable recursive scrubbing so sensitive keys nested inside dict locals are filtered too. Users remain identifiable via the existing render_id tag, which the backend can map to an account since every render API call sends the render_id on an authenticated request.
1 parent dcd86b5 commit f29efc4

2 files changed

Lines changed: 57 additions & 7 deletions

File tree

plain2code_telemetry.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@
2929
# Local variable names whose values may contain proprietary spec or generated
3030
# code content and must be scrubbed from stack traces (extends Sentry's
3131
# default denylist, which already covers api_key, auth, secrets etc.).
32+
# "headers" and "x-api-key" cover the request headers local in
33+
# codeplain_REST_api.post_request; the default denylist only has the
34+
# underscore form "x_api_key".
3235
SCRUB_DENYLIST = DEFAULT_DENYLIST + [
3336
"authorization",
37+
"headers",
38+
"x-api-key",
3439
"plain_source",
3540
"plain_source_tree",
3641
"full_plain_source",
@@ -71,7 +76,7 @@ def initialize_telemetry(**init_overrides: Any) -> bool:
7176
ModulesIntegration(),
7277
],
7378
include_local_variables=True,
74-
event_scrubber=EventScrubber(denylist=SCRUB_DENYLIST),
79+
event_scrubber=EventScrubber(denylist=SCRUB_DENYLIST, recursive=True),
7580
shutdown_timeout=FLUSH_TIMEOUT_SECONDS,
7681
)
7782
init_kwargs.update(init_overrides)

tests/test_telemetry.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import sys
23
from argparse import Namespace
34

@@ -8,12 +9,7 @@
89

910
import plain2code_telemetry
1011
from plain2code_state import RunState
11-
from plain2code_telemetry import (
12-
NO_TELEMETRY_ENV_VAR,
13-
capture_crash,
14-
initialize_telemetry,
15-
telemetry_enabled,
16-
)
12+
from plain2code_telemetry import NO_TELEMETRY_ENV_VAR, capture_crash, initialize_telemetry, telemetry_enabled
1713

1814

1915
class CaptureTransport(Transport):
@@ -130,6 +126,55 @@ def crash_with_sensitive_locals():
130126
assert crash_frame_vars["plain_source"] == "[Filtered]"
131127

132128

129+
def test_request_headers_local_is_scrubbed(transport):
130+
"""The `headers` local in codeplain_REST_api.post_request holds the API key
131+
(X-API-Key); the whole variable must be filtered from stack traces."""
132+
init_with_transport(transport)
133+
134+
# Built at runtime so the secret never appears in the source-context lines
135+
# that Sentry attaches to stack frames.
136+
secret = "".join(["super", "-secret-", "key"])
137+
138+
def crash_with_headers_local():
139+
headers = {"X-API-Key": secret, "Content-Type": "application/json"} # noqa: F841
140+
raise KeyError("boom")
141+
142+
try:
143+
crash_with_headers_local()
144+
except KeyError:
145+
exc_info = sys.exc_info()
146+
147+
assert capture_crash(exc_info, None, make_args())
148+
sentry_sdk.flush(timeout=2)
149+
150+
frames = transport.events[0]["exception"]["values"][0]["stacktrace"]["frames"]
151+
crash_frame_vars = frames[-1]["vars"]
152+
assert crash_frame_vars["headers"] == "[Filtered]"
153+
assert secret not in json.dumps(transport.events[0], default=str)
154+
155+
156+
def test_nested_sensitive_keys_are_scrubbed(transport):
157+
"""Scrubbing is recursive: sensitive keys nested inside dict locals are
158+
filtered even when the variable name itself is innocuous."""
159+
init_with_transport(transport)
160+
161+
secret = "".join(["nested", "-secret-", "key"])
162+
163+
def crash_with_nested_secret():
164+
request_info = {"url": "https://api.codeplain.ai", "x-api-key": secret} # noqa: F841
165+
raise KeyError("boom")
166+
167+
try:
168+
crash_with_nested_secret()
169+
except KeyError:
170+
exc_info = sys.exc_info()
171+
172+
assert capture_crash(exc_info, None, make_args())
173+
sentry_sdk.flush(timeout=2)
174+
175+
assert secret not in json.dumps(transport.events[0], default=str)
176+
177+
133178
def test_environment_defaults_to_production(transport):
134179
init_with_transport(transport)
135180
assert sentry_sdk.get_client().options["environment"] == "production"

0 commit comments

Comments
 (0)