ADR — Findings Runtime Privacy Filter
Formerly
docs/architecture/ADR-FINDINGS-RUNTIME-PRIVACY-FILTER.md.
| Field | Value |
|---|---|
| Status | Accepted (signed off 2026-04-29 — Architecture lead via user approval) |
| Date | 2026-04-28 |
| Authors | Findings Ledger Runtime sub-team |
| Module | src/Findings/__Libraries/StellaOps.Findings.Ledger.Runtime/ |
| Related | SPRINT_20260429_012_FindingsLedger_runtime_privacy_filter, SPRINT_20260429_015_FindingsLedger_runtime_documentation |
In one line: before any runtime trace is persisted or aggregated, the Stella Ops Findings Ledger strips the per-process ASLR address token (
@0x…) from each frame’s symbol and file — and only that token — so the same vulnerable symbol aggregates correctly across processes while line numbers, paths, and evidence flags survive intact.Audience: Findings Ledger engineers maintaining the runtime ingest path, and reviewers assessing the privacy posture of stored trace data. Determines what the ADR-014 tables actually hold.
Context
Runtime probes (eBPF, syscall tracing, APM agents) produce stack frames where each symbol is decorated with a per-process address token of the form @0x[hex]+ (e.g. SSL_write@0x7FFEDEADBEEF). The address suffix is:
- Per-process unique. Two restarts of the same binary will produce different addresses for the same symbol (ASLR).
- Not useful for correlation. “Same vulnerability across processes” cannot be answered without first stripping the address.
- Marginally privacy-sensitive. Combined with timing data, an address sequence could in principle leak information about the host process layout. We do not want this in long-term storage.
If the address tokens reach the aggregator, the bucket key includes the address — which means SSL_write@0xAA… and SSL_write@0xBB… from two processes of the same binary land in different buckets, defeating aggregation entirely. This is the operative correctness requirement captured in the integration test RuntimeTraces_PrivacyFilter_RedactsAddresses_AndAggregatesHotSymbols.
We also need to preserve evidence value — frame metadata that isn’t an address token (line number, file path without the @0x... tail, entry-point flag, vuln-function flag, confidence) is real evidence and must survive redaction.
Decision
RuntimeSymbolRedactor strips a single trailing address token, and only that token, from each frame’s Symbol and File fields. The redaction is anchored to the end of the string and is purely a string transformation — no other field is modified.
Pattern
@0x[0-9A-Fa-f]+(\+0x[0-9A-Fa-f]+)*$
End-anchored. Matches @0xDEADBEEF and @0xAA+0xBB+0xCC (the chained address form some agents emit). Mid-string 0x... literals are not matched.
Per-field rules
| Field | Redacted? | Rationale |
|---|---|---|
RuntimeFrame.Symbol | YES | Strip address tail; preserve qualified name |
RuntimeFrame.File | YES | Same pattern — module paths can carry the same suffix |
RuntimeFrame.Line | NO | Numeric, not an identifier |
RuntimeFrame.IsEntryPoint | NO | Boolean evidence flag |
RuntimeFrame.IsVulnerableFunction | NO | Boolean evidence flag |
RuntimeFrame.Confidence | NO | Numeric, not an identifier |
RuntimeFrame.Order | NO | Synthetic ordering, not from probe |
RuntimeTrace.ContainerId | NO | Container identifiers are tenant-scoped, used for containerCount |
RuntimeTrace.ContainerName | NO | Accepted on the wire, not persisted in v1 durable tables |
RuntimeTrace.ArtifactDigest | NO | Build artifact digest is a public, immutable identifier |
RuntimeTrace.ComponentPurl | NO | SBOM component identifier; required for correlation |
request metadata | NO | Accepted on the wire, not persisted in v1 durable tables |
What is persisted vs dropped
| Stage | Storage | Address tokens? |
|---|---|---|
| Wire (POST body) | (in flight only) | yes — agents send them |
| Privacy filter | (in memory) | stripped here |
| Aggregator | (in memory) | already gone |
runtime_traces | Postgres JSONB | redacted frames only; container_id is persisted, containerName and request metadata are dropped |
runtime_trace_aggregates | Postgres rows | redacted symbol is bucket key |
runtime_scores | Postgres rows | no symbols stored |
runtime_timeline_events | Postgres JSONB | redacted-only payloads |
The wire input is the only place address tokens exist. Everything downstream sees redacted-only data. This is asserted by the RuntimeSymbolRedactorTests suite (idempotency: Redact(Redact(x)) == Redact(x)).
Properties
- Idempotent. Re-redacting redacted output is a no-op.
- Deterministic. Pure function of input.
- Conservative. Tokens that look address-like but lack the
@0xprefix (foo_0xdeadbeef) are preserved. - Performant. ~10 ms / 10,000 symbols on a developer laptop. Pre-compiled regex with an early-exit when no
@is present.
Architecture-lead sign-off
- Author (Implementer): 2026-04-28.
- Architecture lead: Accepted 2026-04-29 (drafted autonomously per
SPRINT_20260429_015_FindingsLedger_runtime_documentationFL-RUNTIME-DOC-003; see the Sign-off Window below for how acceptance was reached).
Rationale
Why end-anchored? Mid-string hex literals (foo_0xdeadbeef) are legitimate symbol fragments in some runtimes (Go method names with hash suffixes, JIT-emitted symbols). End-anchoring keeps the redaction conservative.
Why strip before persistence and before aggregation? Two correctness requirements:
- The aggregator’s bucket key is the redacted vulnerable function symbol. Without redaction, two processes with different ASLR layouts bucket separately. This is the integration-test contract.
- Persisting addresses would leak per-process layout into long-term storage, which we do not want.
Doing it at any later stage is too late.
Why not strip more aggressively? Any of the preserved fields could in principle leak info, but each carries operational evidence value that operators rely on:
Line— needed to differentiate two callsites in the same function.File(after redaction) — needed to differentiate vendored copies of the same library.IsEntryPoint— the only signal we have that the vulnerable code is reachable from a public surface.IsVulnerableFunction— the bucket-selection signal.Confidence— agent self-reported quality; needed for downstream filtering.ContainerId/ContainerName— used forcontainerCountrollup; they’re already tenant-scoped and not cross-tenant identifiers.
Stripping any of these would erode evidence value without meaningful privacy gain.
Why no full hash? A common alternative is to hash the full symbol including the address. We rejected this because:
- Hashes are still unique per address, so two ASLR-shifted processes still bucket separately. Doesn’t fix the aggregation problem.
- Operators reading the aggregate UI need to recognize the symbol name. Hashing destroys readability.
Alternatives considered
- Strip ALL non-symbol metadata. Rejected — destroys evidence value (line numbers, file paths, entry-point flags), which is what operators rely on to triage findings.
- Hash full symbol including address. Rejected — same address problem (different ASLR → different hash) and operators lose readable symbol names.
- Defer redaction to query-time. Rejected — requires storing addresses in long-term storage, and the aggregator would still need to bucket by raw input, defeating the purpose.
- Stricter pattern (only known runtime conventions). Rejected — brittle and unfriendly to new probe types. The general
@0x[hex]+(\+0x[hex]+)*$pattern handles every observed agent. - Replace with placeholder (
@0x?) instead of stripping outright. Rejected — the?is meaningless to operators and eats screen real estate. Stripping leaves the readableSSL_writeform.
Consequences
- One-byte loss of fidelity: we cannot tell
SSL_write@0xAAfromSSL_write@0xBBapart in persistence. Acceptable — they’re the same symbol and aggregating them is the goal. - Aggregation works correctly across processes / restarts. Same symbol → one bucket regardless of ASLR.
- Storage and queries see only redacted data. Anyone with read access to the runtime tables sees redacted symbols only — no per-process address tokens.
- Retroactive re-redaction is unnecessary. The filter runs before any persistence path, so there is never an “unredacted” row in the database to revisit. (If we were ever to relax the redaction, there would be no historical addresses to recover — that’s the right default.)
Test coverage
RuntimeSymbolRedactorTests— pattern correctness, idempotency, unicode, empty inputs, perf benchmark (10k symbols < 50 ms).RuntimeTraceAggregatorTests.Aggregate_DeterministicAcross100Shuffles— proves bucket key is the redacted symbol, not the raw symbol.RuntimeTraces_PrivacyFilter_RedactsAddresses_AndAggregatesHotSymbols— end-to-end integration test through the WebService.
References
- ADR-014 Findings Runtime Persistence
- ADR-016 Findings Runtime Score Formula
- ADR-013 Findings Runtime Disabled Mode
docs/modules/findings-ledger/runtime-instrumentation.md§1src/Findings/__Libraries/StellaOps.Findings.Ledger.Runtime/RuntimeSymbolRedactor.cssrc/Findings/__Tests/StellaOps.Findings.Ledger.Runtime.Tests/Filtering/RuntimeSymbolRedactorTests.cs
Sign-off Window
Accepted on 2026-04-29 by Architecture lead. The 14-day default-accept window from sprint 024 (deadline 2026-05-13) closed early via explicit approval rather than the timeout path. Original window text retained below for history.
Following the project’s RFC default-accept process: if this ADR remains
Proposedwith no objection logged in this document by 2026-05-13 (14 days from sprint 024 commit), it is automatically promoted toAccepted. Architecture-lead may flip status earlier on review. Objections should be added under “Open Issues” below with name + date.
Open Issues
No objections logged during the sign-off window.
