Skip to content

[Security] Traceparent values written to plain-text debug logs #132

Description

@pathosDev

Severity / Size

  • Severity: MEDIUM (defensive — see "Caveat" below)
  • Size: S
  • Threat model: shared log-aggregation tenancy (multiple teams or customers' logs ingested into one ELK / Loki / Splunk index). A reader of logs can correlate user actions across actors and nodes via the propagated trace-id.

Affected files

  • src/tracing/Tracer.ts:16-21 — JSDoc explicitly documents: "each span's traceId / spanId are merged into the {@link LogContext} scope so log lines stamped during span execution include them automatically".
  • src/cluster/Protocol.ts:113-119EnvelopeMsg.trace carries the raw traceparent string across the wire.
  • src/internal/ActorCell.ts:608-617 — receives env.trace, opens a span with it as parent; downstream log lines pick up the trace-id via MDC.
  • src/tracing/RecordingTracer.ts:174-179injectContext / extractContext round-trip the raw string.

Caveat — audit framing vs reality

The audit lists "traceparent values written to plain-text debug logs" as a finding. Inspection of the current code shows no log statement that prints env.trace.traceparent verbatim. What does happen is that RecordingTracer-style integration merges traceId/spanId into MDC by design (per the Tracer.ts JSDoc), and every log line emitted during span execution then carries those identifiers — which is the whole point of distributed tracing.

So the framing isn't "we accidentally print a secret in a debug log" — it's "the propagated trace-id is, by design, a stable correlation key, and that's a privacy concern in shared-tenant log indices". The fix is therefore not "stop logging it" but "make it controllable".

This issue tracks the controls: redaction modes, opt-out, and a documented threat-model note. The shape is closer to a feature with safety toggles than a bug-fix.

Background

The W3C traceparent format is:

00-<trace-id>-<span-id>-<flags>
00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

trace-id is a 128-bit random hex string. Within a single trace, every span has the same trace-id — so anyone with read-access to logs across multiple actors / services can:

  1. Pick a trace-id from a log line: request handled, traceId=4bf92f3577b34da6a3ce929d0e0e4736, …
  2. Search every other service's logs for the same trace-id.
  3. Reconstruct the full request chain: which DB queries ran, which downstream APIs were called, what timings, what errors.

In a single-tenant deployment that's intended behaviour. In a multi-tenant log index (e.g. SaaS provider with multiple customers' logs co-mingled, or a shared corporate ELK with role-based access where the role boundary isn't tight) it becomes a privacy leak:

  • Customer-A's support engineer with read-access to all logs can see Customer-B's request flow.
  • Internal abuse: support engineer correlates a senior exec's actions across services.

The framework's responsibility is not to refuse tracing — it's to:

  1. Provide a redaction mode (traceIdInLogs: 'full' | 'hashed' | 'none').
  2. Document the trade-off in the threat-model.
  3. Provide a per-actor opt-out for actors that handle especially sensitive data.

Exploit walkthrough

Step 1 — Multi-tenant log aggregation: company runs a managed-service platform; per-customer actor systems all ship logs to a single Loki cluster. Engineers have cluster-wide read role for triage.

Step 2 — Customer-A's CEO does a sensitive action (acquisition lookup, M&A modelling). The action flows through:

  • WebFrontendActor.receive (traceId=X)
  • AuthActor.receive (traceId=X)
  • BillingActor.receive (traceId=X)
  • M&AModelActor.receive (traceId=X)
  • Cross-node call to ExternalAPIActor.receive on node-2 (traceId=X)

Each log line carries { traceId: 'X', actor: '<path>', msg: 'processing' }.

Step 3 — Support engineer (legitimate cluster-wide-read role) is investigating a totally unrelated performance issue. They grep logs for 'M&AModelActor' and find traceId=X.

Step 4 — They pivot: grep traceId=X across the cluster. They see the full request chain — including the action timing, the data classes touched, the downstream APIs. They now know the CEO is running M&A models against company Z.

Step 5 — Information leak. No code was bypassed; no auth check failed. The trace-id was the unintended correlation handle.

Realistic worst case: insider abuse → market manipulation. Less dramatic but more common: shared SaaS log indices where Customer-A's "developer with debug logs read" sees enough of Customer-B's flow to fingerprint their architecture.

How the 8 already-landed security fixes inform this

  • MDC cross-tenant leak ([Security] MDC context leak across async-storage tenant boundaries #129 / A7.1) is the same family of concern — correlation IDs that propagate further than the trust boundary. The fix shape: provide explicit controls + document the boundary.
  • Idempotency body-fingerprint stored a hash, not the raw body — same shape applies here: traceIdInLogs: 'hashed' mode emits a SHA-256 of traceId + <per-process secret> so trace correlation works within a process restart window but not across cleanly-rotated deployments.
  • Hello-handshake hijack defence added a privacy-preserving sentinel; the fix wasn't "stop emitting any identity" but "emit a verifiable but not reversible identity". Same principle: emit a bucketed correlation key, not the raw cross-trace-tracker.

The shape: the framework's job isn't to refuse the feature; it's to make the privacy-vs-debuggability trade-off explicit at config time.

Fix design

Track 1 — traceIdInLogs policy on the tracer adapter (primary). Add an option:

export type TraceIdLoggingMode =
  | 'full'        // include full hex traceId/spanId in MDC (current default)
  | 'hashed'      // SHA-256(traceId + processSecret).slice(0, 16) — still correlates within a process, not across restarts
  | 'short'       // first 8 hex chars (collision-prone, lower entropy, harder to fingerprint)
  | 'none';       // omit traceId/spanId from MDC entirely (tracing still flows on the wire; just not in log MDC)

export interface RecordingTracerOptions {
  readonly traceIdInLogs?: TraceIdLoggingMode;
}

hashed mode generates a per-process secret on tracer construction (random 32 bytes); the hashed trace-id is stable within the process lifetime but unrecoverable from logs alone. Cross-process correlation is therefore intentionally broken — that's the privacy property.

private formatTraceIdForMdc(ctx: SpanContext): string {
  switch (this.traceIdInLogs) {
    case 'full': return ctx.traceId;
    case 'short': return ctx.traceId.slice(0, 8);
    case 'hashed': return this.hashWithSecret(ctx.traceId);
    case 'none': return '';
  }
}

Track 2 — Per-actor opt-out via props.config.traceIdInLogs. Actors handling especially sensitive data (PII, financial, M&A) can override:

const sensitiveActor = system.actorOf(
  Props.fromBehavior(/* ... */).withTraceIdInLogs('none'),
  'finance-modeler',
);

Inside that actor's dispatch, the MDC injection skips the trace-id field. Cross-wire propagation continues (so traces still link); only the local log lines omit it.

Track 3 — Wire-format unaffected. The EnvelopeMsg.trace.traceparent field continues to carry the full traceparent — that's required for downstream OTel-aware services to receive a coherent trace. The redaction only affects what lands in log lines via MDC.

Track 4 — Documentation. Add to README "Known security caveats":

- Trace propagation: when tracing is enabled, traceId/spanId are merged
  into log MDC by default (per W3C convention).  In shared log-indices,
  these become correlation handles across tenants.  Use
  `traceIdInLogs: 'hashed' | 'short' | 'none'` to control or disable.
  Wire-level propagation (cross-node tracing) is unaffected.

API surface

import { RecordingTracer } from 'actor-ts/tracing';

const tracer = new RecordingTracer({ traceIdInLogs: 'hashed' });
system.tracer = tracer;

// per-actor:
Props.fromBehavior(behavior).withTraceIdInLogs('none');

// runtime introspect:
tracer.traceIdLoggingMode;  // 'full' | 'hashed' | 'short' | 'none'

OtelAdapter gets the same option. The user's OTel SDK is unaffected — it sees full traceparents on the wire.

Backward compatibility

Default unchanged: traceIdInLogs: 'full' is the default (matches W3C convention, what users currently see). Opting in to redaction is a deliberate, documented choice.

No wire-format break: the EnvelopeMsg.trace.traceparent field is unchanged. Cross-node tracing continues to work; downstream OTel-aware services still see full traceparents.

Test plan

  1. Default mode (full) — emit a log line during span execution, verify MDC includes the full 32-char traceId.
  2. hashed mode — same scenario, MDC includes a 16-char hex hash; verify it's stable within process (same traceId → same hash) and different across process restarts (process-secret rotates).
  3. short mode — MDC contains 8 first chars; verify the cross-actor correlation still works for traces within a single process (collision rate is acceptable at 8 hex chars).
  4. none mode — MDC has no traceId field; verify the wire envelope still contains the full traceparent.
  5. Per-actor override — system-default is 'full', but actor X has 'none'; X's log lines have no traceId, sibling actors keep full.
  6. Wire-format testEnvelopeMsg.trace.traceparent is full-length regardless of traceIdInLogs setting.
  7. Cross-node test — node-1 has 'hashed', node-2 has 'full'; trace flows across the wire; each node's logs honour its own setting.

Acceptance criteria

  • RecordingTracer accepts traceIdInLogs: 'full' | 'hashed' | 'short' | 'none' (default 'full').
  • OtelAdapter accepts the same option with the same semantics.
  • Per-actor override via Props.withTraceIdInLogs(...).
  • Hashed mode uses a per-process random secret; documented as not survivable across restarts.
  • Wire-format propagation (EnvelopeMsg.trace.traceparent) is unaffected.
  • README "Known security caveats" updated with the trade-off explanation.
  • CHANGELOG entry under "Tracing: opt-in MDC redaction modes".
  • Test suite covers all four modes + per-actor override + cross-node mixed-mode.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: mediumUseful, not urgentsecuritySecurity-relevant — see severity label for impact tierseverity: mediumModerate impact or requires specific conditions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions