diff --git a/src/harness_sdk/instrumentation/__init__.py b/src/harness_sdk/instrumentation/__init__.py index 93af348..9f37ef6 100644 --- a/src/harness_sdk/instrumentation/__init__.py +++ b/src/harness_sdk/instrumentation/__init__.py @@ -21,6 +21,24 @@ class BaseInstrumentorWrapper: RPC_REQUEST_BODY_PREFIX = 'rpc.request.body' RPC_RESPONSE_BODY_PREFIX = 'rpc.response.body' + # Headers/metadata whose values are credentials or session secrets and must + # never be exported verbatim in telemetry. Compared case-insensitively. + SENSITIVE_HEADERS = frozenset({ + 'authorization', + 'proxy-authorization', + 'x-api-key', + 'api-key', + 'apikey', + 'x-auth-token', + 'x-amz-security-token', + 'x-goog-api-key', + 'cookie', + 'set-cookie', + 'x-harness-service-token', + 'x-harness-token', + }) + REDACTED_VALUE = '[REDACTED]' + # Constructor def __init__(self): '''constructor''' @@ -41,10 +59,19 @@ def lowercase_headers(self, headers): '''convert all headers to lowercase''' return {k.lower(): v for k, v in headers.items()} + def _redact_if_sensitive(self, header_key: str, header_value): + '''Return the header value, redacted if the header is credential-bearing.''' + if header_key.lower() in self.SENSITIVE_HEADERS: + return self.REDACTED_VALUE + return header_value + def add_headers_to_span(self, prefix: str, span: Span, headers: dict): - '''set header attributes on the span''' + '''set header attributes on the span, redacting sensitive header values''' for header_key, header_value in headers.items(): - span.set_attribute(f"{prefix}{header_key}", header_value) + span.set_attribute( + f"{prefix}{header_key}", + self._redact_if_sensitive(header_key, header_value), + ) def eligible_based_on_content_type(self, headers: dict): '''find content-type in headers''' diff --git a/test/instrumentation/base_wrapper_test.py b/test/instrumentation/base_wrapper_test.py new file mode 100644 index 0000000..4a9431e --- /dev/null +++ b/test/instrumentation/base_wrapper_test.py @@ -0,0 +1,68 @@ +"""Unit tests for BaseInstrumentorWrapper sensitive-header redaction. + +Covers the choke point (`add_headers_to_span`) shared by the HTTP +(requests/httpx/aiohttp) and RPC (gRPC) handlers, so credential-bearing +headers never reach span attributes verbatim. +""" + +from harness_sdk.instrumentation import BaseInstrumentorWrapper + + +class _FakeSpan: + """Minimal Span stand-in that records set_attribute calls.""" + + def __init__(self): + self.attributes = {} + + def is_recording(self): + return True + + def set_attribute(self, key, value): + self.attributes[key] = value + + +def test_sensitive_headers_are_redacted(agent): # noqa: ARG001 (agent inits Config) + wrapper = BaseInstrumentorWrapper() + span = _FakeSpan() + + headers = { + "authorization": "Bearer sk-secret-openai-key", + "x-api-key": "sk-ant-secret-anthropic-key", + "cookie": "session=abc123", + "x-harness-service-token": "pat.acct.secret", + "content-type": "application/json", + "accept": "*/*", + } + + wrapper.add_headers_to_span("http.request.header.", span, headers) + + # Sensitive headers must be masked. + assert span.attributes["http.request.header.authorization"] == BaseInstrumentorWrapper.REDACTED_VALUE + assert span.attributes["http.request.header.x-api-key"] == BaseInstrumentorWrapper.REDACTED_VALUE + assert span.attributes["http.request.header.cookie"] == BaseInstrumentorWrapper.REDACTED_VALUE + assert span.attributes["http.request.header.x-harness-service-token"] == BaseInstrumentorWrapper.REDACTED_VALUE + + # Benign headers must pass through untouched. + assert span.attributes["http.request.header.content-type"] == "application/json" + assert span.attributes["http.request.header.accept"] == "*/*" + + # No raw secret value should survive anywhere on the span. + assert "sk-secret-openai-key" not in span.attributes.values() + assert "sk-ant-secret-anthropic-key" not in span.attributes.values() + + +def test_redaction_is_case_insensitive(agent): # noqa: ARG001 + wrapper = BaseInstrumentorWrapper() + span = _FakeSpan() + + wrapper.add_headers_to_span( + "http.request.header.", span, {"Authorization": "Bearer sk-secret"} + ) + + assert span.attributes["http.request.header.Authorization"] == BaseInstrumentorWrapper.REDACTED_VALUE + + +def test_redact_helper_returns_value_for_benign_header(agent): # noqa: ARG001 + wrapper = BaseInstrumentorWrapper() + assert wrapper._redact_if_sensitive("accept", "*/*") == "*/*" + assert wrapper._redact_if_sensitive("authorization", "Bearer x") == BaseInstrumentorWrapper.REDACTED_VALUE