Skip to content

Commit 3f2aa36

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 8be684f commit 3f2aa36

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):
@@ -132,6 +128,55 @@ def crash_with_sensitive_locals():
132128
assert crash_frame_vars["plain_source"] == "[Filtered]"
133129

134130

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

0 commit comments

Comments
 (0)