DEV Community

jaryn
jaryn

Posted on

Reproduce a Bearer Leak From an OTel JSON Export You Fed to a Model

The export looked harmless. A 48 KB trace.json from a local Jaeger query, one HTTP client span, p99 stuck at 1.8s. I was one paste away from asking a coding assistant why checkout stalled on POST /pay.

Then I grepped the file. http.request.header.authorization sat in span.attributes as Bearer eyJhbGciOi.... That is the whole incident. No production dump. No vendor bug report. A throwaway fixture I generated so the paste would fail before a model context window became a log sink.

Would you ship that JSON to a remote tokenizer? I would not. The failure is not "AI is risky." The failure is a missing trust boundary between an observability export and a prompt.

The trust boundary, drawn once

OpenTelemetry is a debug surface. A model is a third party, even when it runs on a box you own. Those are not the same room.

flowchart LR
  App[App SDK] -->|OTLP| Collector
  Collector --> Backend[Jaeger / Tempo]
  Backend -->|export JSON| Laptop
  Laptop -->|redact gate| Prompt[Prompt builder]
  Prompt -->|allowlisted attrs only| Model
  Laptop -.->|raw paste| Model

The dotted line is the bug. Everything left of redact gate may legally contain a Bearer token, a Cookie, a db.connection_string, or a session.id that maps to a person. Everything right of it should not.

Ask the ugly question early: which process is allowed to see Authorization? If the answer is "the collector, maybe the backend, never the model," you already have an invariant. The rest of this article is that invariant as a fixture.

Minimal failing export

I did not scrape a live cluster. I wrote a span that matches what HTTP instrumentations emit when header capture is on. Treat this as a lab fixture, not evidence of a CVE.

Pinned for the lab: Python 3.12, pytest==8.3.3. No OTel SDK required to fail the test.

{
  "resourceSpans": [{
    "scopeSpans": [{
      "spans": [{
        "name": "POST /pay",
        "attributes": [
          {"key": "http.method", "value": {"stringValue": "POST"}},
          {"key": "http.route", "value": {"stringValue": "/pay"}},
          {"key": "http.status_code", "value": {"intValue": 504}},
          {"key": "http.request.header.authorization",
           "value": {"stringValue": "Bearer eyJhbGciOiJIUzI1NiJ9.lab-fixture"}},
          {"key": "http.request.header.cookie",
           "value": {"stringValue": "sid=s%3Alab.fixture"}},
          {"key": "db.connection_string",
           "value": {"stringValue": "postgres://app:lab-pass@db.internal:5432/pay"}}
        ]
      }]
    }]
  }]
}
Enter fullscreen mode Exit fullscreen mode

Save it as fixtures/trace.negative.json. That file must never become prompt context. If your assistant workflow cannot reject it, you do not have a gate. You have a habit.

Positive twin: fixtures/trace.positive.json. Same shape, no secrets. Keep http.method, http.route, http.status_code, net.peer.name, duration. That is the debug signal. The Bearer is not.

What actually leaks, and what does not

I keep a short allow/deny list next to the fixture. It is boring on purpose.

Attribute / field Send to a model? Why
http.method, http.route, http.status_code Yes Needed to reason about the stall
http.request.header.authorization Never Credential
http.request.header.cookie / set-cookie Never Session
db.connection_string, db.user, db.statement with literals Never Credential + possible PII
enduser.id, user.email Never by default Identifier
span events with exception stacktrace Maybe Can embed DSNs from config loaders
resource service.name, service.version Yes Identity of the binary, not the caller
trace_id / span_id Team policy Correlates to production traffic

Is trace_id a secret? Usually no. Is it a join key into a system the model should not see? Sometimes yes. I treat join keys as confidential when the backend is production.

Reproduce the leak with a 40-line gate

The gate is a pure function. No network. No model. If this function is green, you still have not proved the assistant is safe. You have proved the export is.

# redact_otel.py — lab fixture, not a production redactor
from __future__ import annotations

import json
import re
from typing import Any, Iterable

DENY_KEY_FRAGMENTS = (
    "authorization",
    "cookie",
    "set-cookie",
    "x-api-key",
    "api_key",
    "api-key",
    "secret",
    "password",
    "connection_string",
    "enduser.id",
    "user.email",
)

DENY_VALUE = re.compile(
    r"(?i)(bearer\s+[a-z0-9._\-]+|postgres://\S+|mysql://\S+|sid=\S+)"
)

REDACTED = "[REDACTED]"


def _key_denied(key: str) -> bool:
    k = key.lower()
    return any(frag in k for frag in DENY_KEY_FRAGMENTS)


def walk(node: Any) -> Any:
    if isinstance(node, dict):
        key = node.get("key")
        if isinstance(key, str) and "value" in node:
            if _key_denied(key):
                return {"key": key, "value": {"stringValue": REDACTED}}
            value = node["value"]
            if isinstance(value, dict) and "stringValue" in value:
                if DENY_VALUE.search(value["stringValue"] or ""):
                    return {"key": key, "value": {"stringValue": REDACTED}}
        return {k: walk(v) for k, v in node.items()}
    if isinstance(node, list):
        return [walk(x) for x in node]
    return node


def forbidden_hits(node: Any, acc: list[str] | None = None) -> list[str]:
    acc = acc if acc is not None else []
    if isinstance(node, dict):
        key = node.get("key")
        if isinstance(key, str) and _key_denied(key):
            val = node.get("value", {})
            text = val.get("stringValue", "") if isinstance(val, dict) else ""
            if text and text != REDACTED:
                acc.append(key)
        for v in node.values():
            forbidden_hits(v, acc)
    elif isinstance(node, list):
        for x in node:
            forbidden_hits(x, acc)
    return acc


def load(path: str) -> Any:
    with open(path, encoding="utf-8") as f:
        return json.load(f)
Enter fullscreen mode Exit fullscreen mode

Pytest, two directions. Negative fixture must be caught. Positive fixture must survive.

# test_redact_otel.py
import json
from redact_otel import forbidden_hits, load, walk, REDACTED

def test_negative_export_is_rejected():
    raw = load("fixtures/trace.negative.json")
    hits = forbidden_hits(raw)
    assert "http.request.header.authorization" in hits
    assert "db.connection_string" in hits

def test_negative_export_redacts_bearer_and_dsn():
    raw = load("fixtures/trace.negative.json")
    cleaned = walk(raw)
    blob = json.dumps(cleaned)
    assert "eyJhbGciOi" not in blob
    assert "lab-pass" not in blob
    assert REDACTED in blob
    assert forbidden_hits(cleaned) == []

def test_positive_export_keeps_route_and_status():
    raw = load("fixtures/trace.positive.json")
    assert forbidden_hits(raw) == []
    cleaned = walk(raw)
    blob = json.dumps(cleaned)
    assert "/pay" in blob
    assert "504" in blob
Enter fullscreen mode Exit fullscreen mode

Run it:

python -m pytest -q test_redact_otel.py
Enter fullscreen mode Exit fullscreen mode

Expected on the negative file before walk: assertion finds http.request.header.authorization. Expected after walk: the JWT substring is gone. If your first run is all green without a negative fixture, the test is lying. Add the failing export first. Then make it fail closed.

Prevent / detect / recover

A redactor in a gist is not a control. Name the layer.

Layer Prevent Detect Recover
SDK Do not capture Authorization / Cookie. Turn header capture off unless a named allowlist exists. CI grep for OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS and for http.request.header.authorization in golden traces Rotate the token the span held; treat the export as a credential dump
Collector Attribute processor drops deny-listed keys before storage Collector debug log sampled without values; metric on dropped keys Flush or re-process the bad batch; mark the backend index tainted
Export CLI jq / this fixture as a required pipe before clipboard Pre-commit or Make target: pytest test_redact_otel.py Delete trace.json from disk and chat history
Prompt builder Refuse to attach any .json unless forbidden_hits is empty Unit test on the builder, not on the model Invalidate the thread; do not "ask the model to forget"
Model host Network egress allowlist; no default path to a public tokenizer Prompt-size + attachment hash logged locally, not the body Rotate anyway; the host is not your source of truth

Notice recover always includes rotate. Redaction after a paste is not recovery. The bytes already left.

Where a self-hosted assistant still sits behind this gate

I evaluate coding assistants the same way I evaluate log shippers: what crosses the boundary, and who can read the buffer.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source AI development platform with free model access and a free server option, which is enough to stand up a local prompt path and point this fixture at it. That is the only product fact this workflow needs. I am not pinning model names, quotas, or hardware here; those change, and a stale number is worse than silence.

Self-hosting moves the tokenizer. It does not delete Bearer eyJ... from trace.json. A teammate with disk access, a crash dump, a session recorder, or a "helpful" debug log of prompts will still see it. If you use a self-hosted path, run forbidden_hits in the prompt builder before the bytes hit localhost. Localhost is not a policy.

If you want one check against that path: export the negative fixture, attach it the way you would attach a Jaeger dump, and confirm the builder rejects it. If the builder cannot reject, do not debug production traces there. Debug the builder.

Commands I actually keep next to the fixture

Unexecuted until you wire them to your backend. Labels matter.

# 1) Confirm the raw export still contains a deny-listed key (lab file).
grep -n "authorization" fixtures/trace.negative.json

# 2) Fail closed in CI. No model invocation.
python -m pytest -q test_redact_otel.py

# 3) Optional clipboard pipe for humans who insist on interactive debug.
# Template — unexecuted. Replace jaeger-export.json with a file you own.
python - <<'PY'
from pathlib import Path
from redact_otel import walk, forbidden_hits, load
import json, sys
p = Path("jaeger-export.json")
data = load(p)
hits = forbidden_hits(data)
if hits:
    print("refusing export; keys:", hits, file=sys.stderr)
    sys.exit(2)
Path("jaeger-export.redacted.json").write_text(json.dumps(walk(data), indent=2))
print("wrote jaeger-export.redacted.json")
PY
Enter fullscreen mode Exit fullscreen mode

Do not curl a production Jaeger into this. Copy a synthetic span first. If you cannot explain every attribute in the file, you are not ready to paste it.

Limitations — read these before you copy the regex

This approach is a regression fixture for a known shape: OTLP JSON with key / stringValue pairs. It is not a DLP product.

  • Nested protobuf, zipkin v1, Tempo parquet, or a screenshot of Grafana will bypass it. Different codec, different test.
  • Base64 blobs, JWTs split across events, and db.statement with inline emails need parsers you have not seen above. I did not pretend to write those.
  • Allowlisting http.route can still leak identifiers if your routes are /users/{email}.
  • DENY_VALUE is a coarse regex. It will false-positive on the word "bearer" in a comment span. Good. Fail closed, then add an allow.
  • A green pytest does not prove your assistant UI skipped the file input. Test the builder, or you tested a script nobody runs.

Who should not use this as their only control: teams under a legal hold that must retain raw traces; teams whose model already runs inside the same confidentiality domain and whose prompt store is production-grade; anyone hoping a redactor will make it acceptable to paste live customer traffic. It will not. Use a scrubbed staging trace.

Who should use it: platform and security engineers who already dump spans when latency spikes, and who have started feeding those dumps to a coding assistant. If that is you, the invariant belongs in CI, not in a wiki.

The boundary question

I want one line in the pipeline, not a culture speech. Does forbidden_hits run in CI on every golden trace and on the prompt builder, or only in a gist you remember after the leak?

The SDK should stop recording Authorization. The collector should drop it anyway. The prompt builder should refuse the file if either layer missed. Three layers, one invariant. Which of those three can you prove with a fixture tonight?

Top comments (0)