Skip to content

Mask sensitive values on their way out of a service - #635

Merged
hanna-paasivirta merged 12 commits into
cancel-abandoned-runsfrom
echo-mask-payload
Aug 17, 2026
Merged

Mask sensitive values on their way out of a service#635
hanna-paasivirta merged 12 commits into
cancel-abandoned-runsfrom
echo-mask-payload

Conversation

@elias-ba

@elias-ba elias-ba commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Short Description

A payload reaching a service carries values the server put there rather than the caller, and services could hand them back out. This masks them at the boundary instead of trusting each service to remember.

Stacked on #632#631#630#629. Review those first; this branch contains them.

Implementation Details

Three routes led back to the caller:

  • echo returned its input verbatim, which is what started this
  • any service logging its payload reaches the caller too, because logger output is forwarded as SSE log events — and vocab_mapper logs its whole payload on the first line of main
  • the error envelope returns the exception text, and nearly every service catches broadly and rewraps as ApolloError(500, str(e)) with the request in scope

Rather than patch three call sites, masking moved to places a service cannot bypass:

  • create_logger installs a filter on the log handler, so what a service uses to log makes no difference
  • every result leaves through one masked exit in entry.call
  • a before_send hook masks what Sentry receives — it scrubs frame locals by name, but not exception text, contexts or breadcrumbs

mask_secrets already existed for Langfuse traces, so it is reused rather than duplicated. Doing so showed it was narrower than what the server can fill in: it now covers the other providers' field names, normalised so api-key, apiKey and X-Api-Key all match, and has a depth bound so a deeply nested payload cannot turn into a RecursionError.

The value pattern needed care in both directions, because the same function passes over workflow YAML, job code and service results. A loose version ate task-, risk- and disk- prefixed names; a different loose version ate sk-antelope-migration-plan. It anchors on the prefixes providers actually use, or wants a run too long and unbroken to be a name, and test_mask_secrets.py pins both directions.

Additional notes for the reviewer

Five instance-auth tests changed, and the coverage they had is restored elsewhere. They read the substituted value off echo's response, which masking makes impossible — with everything coming back [REDACTED], a swap that wrote the wrong value, or forwarded the caller's own, would look identical to a correct one. So applyResolvedKey is now its own exported function and asserted directly: the substitution is checked without a service having to hand a value back to prove it. Paired with the InstanceAuth rows that pin which resolution a given credential produces, that covers the same ground the round trip did. Verified by breaking it both ways — forwarding the caller's own credential fails two of the five new assertions, blanking the field rather than dropping it fails one.

A websocket fix rides along. Live testing found close was aborting nothing: Elysia builds a fresh wrapper object per event, so the object the close handler receives is never the one the message handler stored against. Keyed on the underlying socket now, with a test that fails without the fix.

Closes #634

AI Usage

  • I have used Claude Code

@elias-ba elias-ba changed the title echo: stop reflecting the full payload Mask sensitive values on their way out of a service Aug 16, 2026
@elias-ba
elias-ba changed the base branch from main to cancel-abandoned-runs August 16, 2026 19:41
@elias-ba
elias-ba force-pushed the echo-mask-payload branch 4 times, most recently from 0cf5e8f to d4ba7eb Compare August 16, 2026 20:02
@elias-ba
elias-ba force-pushed the cancel-abandoned-runs branch from a670b4d to 8e25fe6 Compare August 16, 2026 20:15
echo returned its input verbatim and logged it. A payload reaching a
service can carry values the server set rather than the caller, and
logger output is forwarded to the client as SSE log events, so anything
in there leaves twice.

It now runs the payload through mask_secrets before returning or logging
it. That helper already existed for Langfuse traces and does the same job
here, so there is no second one to keep in step.

The mask itself was narrower than what the server can fill in: it listed
three field names and recognised one provider's key format. It now covers
the fields for the other providers too, and matches both key shapes, so a
value under a name nobody listed is still caught.

Follow-ups in #634.
A payload reaching a service carries values the server put there rather
than the caller. Three routes led back out: echo returned its input
verbatim, any service logging its payload reached the caller too (logger
output is forwarded as SSE log events), and the error envelope returns the
exception text.

The logger is the one that matters, because it needs nothing of the
service: vocab_mapper logs its whole payload on the first line of main.
Masking in create_logger covers that and anything written later without
each service having to remember.

Also masks the request context search_adaptor_docs sends to Sentry, which
the three sibling services already stripped.

The mask itself was narrower than what the server can fill in, so it now
lists the other providers' fields. Widening its value pattern to a general
sk- shape first made it match ordinary words - task-, risk-, disk- and
friends - which would have quietly corrupted every Langfuse trace, since
this is the same function used as the export mask. It is anchored now, with
tests pinning both directions.

The instance-auth tests read a masked field off echo's response, which they
can no longer do. They assert that the request is accepted and that what the
caller sent does not come back; what the server substitutes is covered
directly against InstanceAuth.authenticate.
Review of the masking itself found four ways round it.

The error envelope masked two branches and not the one services take.
Nearly all of them catch broadly and rewrap as ApolloError(500, str(e)),
which entry.py returned untouched - the same disclosure, by the sibling
branch of the same case statement. Masking now happens once at the exit,
so it covers the message, the details, and whatever gets added later.

The log filter was attached to each logger built by create_logger, and a
filter on a logger only runs for records emitted through it. A plain
getLogger, or a third-party logger like httpx, writes to the same stdout
handler and sailed past - and vocab_mapper already silences httpx
precisely because it reaches that stream. It sits on the handler now, so
what a service uses to log makes no difference.

Rendering the message inside the filter moved %-formatting out of the
handler, where a bad format string is caught and reported, and into the
caller's own logging call, where it is not. A cosmetic typo in a log line
would have failed the request. And a message that was neither string nor
container passed through unmasked, because the formatter calls str() on it
after the filter has run.

Sentry saw everything unmasked: it scrubs frame locals by name, but not
exception text and not a set_context payload. A before_send hook covers
both, and replaces the per-service discipline three services each
hand-rolled differently.

Also: the lookbehind excluded a leading hyphen or underscore, which are
legitimately in front of a key; api-key and apiKey were not recognised
alongside api_key, and x-api-key stopped being recognised when the names
were normalised; the recursion had no depth bound, which a deep payload
could turn into a failed request; and two except clauses captured an
exception variable they never bound, raising NameError instead of the 500
they meant to return.
The follow-up from #634, done at the boundary rather than per service.
Driven through an unmounted probe that reflects its payload and masks
nothing itself, plus one that raises with the payload in scope, since
pointing these at echo would only prove echo masks. All three fail if the
mask at entry.call's exit is removed.
The exit mask is now a function every path calls, including the two that
returned early. That also settles a conflict with the cancellation stack,
which restructured the same branches the other way: both now write the
output file on every path and mask on every path, so the two agree instead
of one silently winning.

Sentry got an allowlist of sections and breadcrumbs were not on it. Every
INFO record becomes a breadcrumb, and they ride along on the next error
event - so a key logged through a handler the filter had not reached left
that way. Masking the whole event covers the sections nobody listed, and
transactions get the same hook, since before_send is for errors only.

The log filter went on at the first create_logger call, over the handlers
that existed by then. Langfuse attaches one to the httpx logger during
import, before any service module runs, and it writes to stderr, which the
bridge forwards to the caller line for line. The sweep now runs at import
and again once the service module is loaded, and covers handlers on other
loggers rather than only root.

The value pattern matched sk- followed by any hyphenated words, so
sk-antelope-migration-plan came back redacted. Now that the same function
masks what a service returns, that silently corrupts a caller's own data.
It anchors on the prefixes providers use, or wants an unbroken run long
enough not to be a name.

Also drops the server's absolute path from the input-not-found message.
Events reach the caller by a third route: not the result, not the log
stream, so neither of those masks sees them. Nothing puts a key in one
today, which is the moment to close it rather than after something does.
Three services each stripped api_key from their Sentry context by name, in
three slightly different ways, and one of them missed nested values and
key-shaped strings entirely. They call the shared mask now. The stream
manager is still dropped by name, because that is an object rather than
data and not a secret at all.

Also removes set_log_output and the filename it sets: nothing has read
either since logging moved to stdout.
The regex had three comments for one pattern, and the longest recounted
the two shapes I tried before this one rather than explaining the one that
is there. The depth guard said the same thing twice, once beside the
constant and once in its test.

Also drops a phrase that named what the temp payload holds - the same
thing the commit messages were careful about, missed one layer down in a
public repo.
Live testing found the close handler was aborting nothing. Elysia builds a
fresh wrapper object for each websocket event, so the one the close handler
receives is never the one the message handler stored against - the lookup
missed every time, and the child kept generating. Keyed on the underlying
socket now, which is what the two events share.

Nothing caught this: the run settles on its own eventually, so the only
signal was a python process still alive after the socket went away. The
test looks for exactly that, and closes early enough that echo has not
finished by itself - a longer wait passes whether or not the fix is there,
which is how the first version of it fooled me.
Masking echo took away the only test of this. The five instance-auth rows
used to read the substituted value back off the response; with everything
coming back "[REDACTED]" a swap that wrote the wrong value, or forwarded
the caller's own, would look exactly like a correct one.

applyResolvedKey is now its own exported function and asserted directly,
so the substitution is checked without a service having to hand a value
back to prove it. Paired with the InstanceAuth rows that pin which
resolution a credential produces, that covers the ground the round trip
did.

Checked by breaking it both ways: forwarding the caller's credential fails
two of the five, and blanking the field rather than dropping it fails one.
@hanna-paasivirta

hanna-paasivirta commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Looks great, thank you Elias! I added a tiny change to copy the logger registry before walking it list(root.manager.loggerDict.values()) so that if a logger is created by another thread it won't crash in a really confusing way. Let me know if this is ok and I'll merge this one @elias-ba

@elias-ba

Copy link
Copy Markdown
Collaborator Author

Thanks @hanna-paasivirta that sounds great, nice catch. Please feel free to merge PRs when you happy with them

@hanna-paasivirta hanna-paasivirta mentioned this pull request Aug 17, 2026
2 tasks
@hanna-paasivirta
hanna-paasivirta merged commit 5cc4cc5 into cancel-abandoned-runs Aug 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants