Runtime instrumentation — privacy filter, aggregation, score
Sprint reference:
SPRINT_20260429_012_FindingsLedger_runtime_privacy_filterLibrary:src/Findings/__Libraries/StellaOps.Findings.Ledger.Runtime/ADR:ADR-016 Findings Runtime Score FormulaOperations:runtime-instrumentation-operations.md— runbook, metrics, alerts, retention (sprint 014).
This page documents the three pure algorithms that sit between runtime trace ingestion and score persistence:
- Privacy filter —
RuntimeSymbolRedactor - Trace aggregator —
RuntimeTraceAggregator - Score calculator —
RuntimeScoreCalculator
All three are pure libraries: no Postgres, no I/O, no DI registration. They are unit-tested in isolation in src/Findings/__Tests/StellaOps.Findings.Ledger.Runtime.Tests/.
1. Privacy filter contract
What gets redacted
RuntimeSymbolRedactor strips a single trailing address token of the form @0x[hex]+ (optionally chained with +0x[hex]+ continuations) from runtime symbols and file paths. The pattern is anchored to the end of the string:
| Field | Method | Pattern (anchored) |
|---|---|---|
| Symbol | RuntimeSymbolRedactor.RedactSymbol | @0x[0-9A-Fa-f]+(\+0x[0-9A-Fa-f]+)*$ |
| File | RuntimeSymbolRedactor.RedactFilePath | (same as symbol) |
| Frame | RuntimeSymbolRedactor.Redact(RuntimeFrame) | applies both |
Examples (input → output)
| Input | Output | Notes |
|---|---|---|
SSL_write@0x7FFEDEADBEEF | SSL_write | basic case |
SSL_write@0x7FFFAAAABBBB | SSL_write | different address, same redaction |
foo@0xAA+0xBB+0xCC | foo | chained addresses stripped together |
/usr/lib/libssl.so@0x7FFEDEAD | /usr/lib/libssl.so | file path |
Foo@SuppressWarnings | Foo@SuppressWarnings | annotation (no hex tail) → preserved |
user@host | user@host | no hex tail → preserved |
foo_0xdeadbeef | foo_0xdeadbeef | no leading @ → preserved |
foo@0xDEAD-0xBEEF | foo@0xDEAD-0xBEEF | - is not the documented separator |
std::vector::push_back@0x12345678 | std::vector::push_back | C++/Rust qualified names handled |
What is NOT redacted
- Symbols with no
@(returned unchanged). - Symbols with
@but no0x[hex]tail (e.g.Foo@Annotation,user@host). - Hex-like tokens that appear mid-string (
foo_0xdeadbeef). - Frame metadata other than
SymbolandFile:Line,IsEntryPoint,IsVulnerableFunction,Confidenceare preserved exactly as ingested. Redacting more would lose evidence value (sprint Decisions & Risks).
Properties
- Idempotent.
Redact(Redact(x)) == Redact(x)for all inputs. The end-anchored regex makes re-application a no-op. - Deterministic. Pure function of input; same string in → same string out.
- Performant. ~10 ms for 10,000 symbols on a developer laptop. Pre-compiled regex with end-of-string anchor and a fast-path early-exit when no
@is present in the input. - Conservative. No address-like token without
@0xprefix is ever stripped, so legitimate hex literals in symbol names are preserved.
2. Trace aggregation algorithm
Grouping
RuntimeTraceAggregator.Aggregate(traces, tenantId, findingId) groups input traces by
key = (ArtifactDigest, RedactSymbol(firstVulnerableFrame.Symbol))
and emits one RuntimeTraceAggregate per group. Grouping by the redacted vulnerable function symbol is the operative correctness requirement for the integration test RuntimeTraces_PrivacyFilter_RedactsAddresses_AndAggregatesHotSymbols: two traces of SSL_write from different @0x... addresses must collapse into a single aggregate with HitCount >= 2.
Per-group emitted fields
| Field | Computation |
|---|---|
HitCount | count(traces in group) |
FirstSeen | min(trace.CapturedAt) |
LastSeen | max(trace.CapturedAt) |
ContainerCount | count(distinct trace.ContainerId) (null/empty ids collapse to one) |
HasEntryPoint | any(any frame in trace where IsEntryPoint == true) |
VulnerableFunction | Redacted symbol (the bucket key) |
Skip rules
- A trace with no frame where
IsVulnerableFunction == trueis skipped. - A trace whose first vulnerable frame redacts to an empty string is skipped (cannot bucket without a symbol key).
Determinism guarantee
The result list is sorted by (ArtifactDigest, VulnerableFunction) using string.CompareOrdinal before being returned. This means the aggregator produces byte-for-byte identical output regardless of the order in which traces arrive in the input enumerable.
This is asserted by RuntimeTraceAggregatorTests.Aggregate_DeterministicAcross100Shuffles, which serializes aggregates into a canonical string and compares against 100 shuffled-input runs.
3. Score formula reference
Full ADR: ADR-016 Findings Runtime Score Formula.
score = clamp01(
0.40 * normalizedHits(aggregates) // log10-scaled at saturation 100
+ 0.30 * entryPointPresence(aggregates) // binary 0/1
+ 0.20 * vulnerableFunctionCoverage(traces) // ratio of distinct vuln to all functions
+ 0.10 * recencyWeight(aggregates, window) // linear decay over window
) * 100
Key properties (asserted by unit tests):
- Empty input →
score = 0. - All four components maxed →
score = 100. - Any input →
0 <= score <= 100. - Per-component pre-weight contributions exposed in
RuntimeScore.Componentsfor explainability (UI / CLI). - Weights are tunable via
RuntimeScoreCalculatorOptions; constructor validates they sum to 1.0. - Same input + same
now→ same score (no internal clock).
See the ADR for the full rationale on weight choices, alternatives considered, and boundary semantics.
4. Privacy / compliance posture
The privacy filter exists so that per-process address tokens never reach persistence. Specifically:
@0x[hex]+suffixes are stripped before aggregation, so they cannot enter the bucket key.- Aggregates only contain redacted symbol names, hit counts, distinct container counts, and timestamps. No raw stack frames are persisted by the aggregator.
- Hit counts and container counts are counts, not identifiers — they do not enable de-anonymization of which specific process / pod / request observed a given trace.
- Frame metadata that is preserved (line, file path without
@0x..., entry-point/vuln flags, confidence) is treated as evidence value and is not by itself an identifier.
The 1-byte loss of fidelity (we cannot tell SSL_write@0xAA from SSL_write@0xBB apart in persistence) is an explicit design trade-off: the address tokens are per-process-image artifacts and have no value for correlating findings across containers, deployments, or restarts. Stripping them is what makes “same vulnerable function from different addresses” → one bucket possible.
5. Algorithm composition
The three algorithms compose into the runtime instrumentation pipeline as follows:
ingest (raw traces)
│
▼
RuntimeSymbolRedactor.Redact(frame) ◀── per frame, before persistence
│
▼
RuntimeTraceAggregator.Aggregate(traces, …) ◀── periodic / on-demand
│
▼
RuntimeScoreCalculator.Calculate(aggregates, recentTraces, window, now)
│
▼
persist `RuntimeScore` for `(tenant, finding)`
Each stage is a pure function with no I/O, so the entire pipeline is trivially testable end-to-end with in-memory fixtures. The persistence
- DI wiring belongs to
SPRINT_20260429_011_FindingsLedger_runtime_persistence; the HTTP endpoint wiring belongs to sprint 013.
6. Test references
src/Findings/__Tests/StellaOps.Findings.Ledger.Runtime.Tests/Privacy/RuntimeSymbolRedactorTests.cs— 31 tests (28 unit + 3 property-based at 1000 / 500 / 500 iterations).src/Findings/__Tests/StellaOps.Findings.Ledger.Runtime.Tests/Aggregation/RuntimeTraceAggregatorTests.cs— 15 tests including the canonical contract test and a 100-shuffle determinism test.src/Findings/__Tests/StellaOps.Findings.Ledger.Runtime.Tests/Scoring/RuntimeScoreCalculatorTests.cs— 22 tests covering empty / max / saturation / boundary / validation / determinism.
Total: 68 / 68 tests green as of sprint completion.
