ROB-997 Redact Secret data from outbound notifications - #116
Merged
naomi-robusta merged 2 commits intoAug 20, 2026
Conversation
CloudEvent notifications carried whole runtime.Objects. With Secret
watching enabled, every Secret create, update and delete JSON-serialized
the Secret's data and stringData to the configured receiver — and updates
sent the previous values too, via oldObj.
Add pkg/redact and apply it in two layers:
- Typed, in the controller as events are built, so every handler is
covered rather than just the ones that serialize objects today. A
Secret's data/stringData values become "[redacted by kubewatch]".
Objects are deep-copied first: they come from the shared informer
cache and redacting one in place would corrupt it for every other
reader.
- Defensive, on the marshalled bytes in the CloudEvent handler, right
before the POST. This walks the document and redacts the data fields
of any object whose kind is Secret, at any depth. It catches what the
typed layer cannot see — notably the unstructured Secrets reachable
through `customresources`, which is not gated by `resource.secret`.
A payload that fails the round-trip is an error, not a fall-back to
the unredacted bytes.
Key names, labels, annotations, type and the rest of the metadata are
kept, so a notification still says which Secret changed and which keys it
has. Non-Secret resources are untouched and keep their full object body,
which is what downstream consumers match on. Redacted `data` values stay
valid base64 in both layers, so receivers that decode them still can.
Tests drive the real path — Kubernetes API to informer to controller to
handler to HTTP — with sentinel bytes, and assert neither the raw nor the
base64 form reaches the receiver on create, update or delete. Verified by
mutation: disabling either layer, or redacting in place, each fails a
distinct test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzZp8kPXKYjXuxY1oN1aU8
naomi-robusta
requested review from
Avi-Robusta,
RoiGlinik,
Sheeproid and
moshemorad
August 18, 2026 13:57
The check interpolated ${{ github.event.pull_request.body }} directly into
the run: block, so the script text was built out of untrusted input. A
description containing backticks had them run as commands on the runner,
and one containing a double quote ended the string bash was parsing —
failing the step with "conditional binary operator expected" on a valid
description.
Pass the body through the environment instead and quote it. The check keeps
its teeth: a description with no "## Tests performed" section, or a section
with no bullet list, still fails. Verified against the real body that broke
it, four valid/invalid descriptions, and an injection attempt that now goes
through as data with nothing executed.
Also anchor the section-extracting sed to a line-initial h2 (/^## /), so a
subheading inside the section no longer truncates it early.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzZp8kPXKYjXuxY1oN1aU8
Avi-Robusta
approved these changes
Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes ROB-997 (High, CWE-201).
The problem
pkg/handlers/cloudeventput wholeruntime.Objectvalues intoObj/OldObjandjson.Marshal'd them. With Secret watching enabled (resource.secret: true), every Secret create, update and delete JSON-serialized the Secret'sdata(base64) andstringData(plaintext) to the configured receiver. Updates sent the previous values too, viaoldObj.Reproduced before fixing — one message per operation, sentinel bytes present in all three:
A second path the ticket doesn't mention
customresources(controller.go:562) goes through the dynamic client and yields*unstructured.Unstructured. Configuringgroup: "", version: v1, resource: secretsproduces the same disclosure whileresource.secretstaysfalse— the flag is not a control on that path at all. Both paths are covered here.Approach: redact, not truncate
The ticket recommends replacing
Obj/OldObjwith a metadata-only DTO. That would break the platform: the Robusta runner is the CloudEvent consumer and matches playbooks on the full object body (base_triggers.pyloads hikaru objects fromobj/oldObj, and scope matchers walk arbitrary nested paths). So this redacts the secret material and leaves every other resource's payload intact.New
pkg/redact, applied in two layers:data/stringDatavalues become[redacted by kubewatch]. Objects are deep-copied first: they are the shared informer cache's own objects, and redacting one in place would corrupt the cache for every other reader in the process.kindisSecret, at any depth. This is the backstop for Secrets the typed layer cannot see, notably the unstructured path above. A payload that fails the round-trip returns an error rather than falling back to the unredacted bytes.What survives: key names, labels, annotations,
type, and the rest of the metadata — so a notification still says which Secret changed and which keys it has, just not their values. Redacteddatavalues stay valid base64 in both layers (W3JlZGFjdGVkIGJ5IGt1YmV3YXRjaF0=), so receivers that decode them still can. Non-Secret objects pass straight through — same pointer, no copy, no allocation.Actual outbound body after the fix:
Tests performed
go build ./...,go vet ./...andgo test -race ./...(whatmake testruns) all pass. One pre-existing failure is unchanged — see below.pkg/controller/controller_test.go,pkg/handlers/cloudevent/cloudevent_test.go).spec,status, labels, image, node name, andoldObj.pkg/redact, controller, handler).objName()names the resource type"Secret", sodata.kindis literally the string the defensive layer keys off. A test pins down that the envelope and event metadata come out whole regardless.datafield, large-integer fidelity across the JSON round-trip, and invalid JSON being rejected rather than passed through.conditional binary operator expected/ exit 2 that CI reported.Mutation-tested rather than only run green. Four mutations, each caught by a distinct test:
TestSecretEventsReachHandlersRedacted— cloudevent still held, via layer 2TestJSONRedactsNestedAndListedSecrets,TestJSONRedactsNonMapDataFieldDeepCopy)A CI fix rides along
The first run of
check-pr-descriptionfailed on this PR, and the cause was the check, not the change. It interpolated${{ github.event.pull_request.body }}straight into itsrun:block, so the script text was assembled from untrusted input:go build ./...,go testandmake test;conditional binary operator expected/ exit 2 the run reported.Second commit passes the body through the environment and quotes it. The check keeps its teeth — no
## Tests performedsection, or a section with no bullet list, still fails. It affects every PR in the repo, not just this one, so say the word if you would rather it were split into its own PR.For reviewers
Two judgment calls worth a look:
DataandStringData". Key names are not secret material and carry real signal (which keys exist, which were added or removed), while name/namespace/labels go out regardless. Trivial to switch to clearing the maps outright if you'd rather.Not addressed here, and worth separate tickets:
pkg/handlers/slackwebhook.TestWebhookInitfails onmaster— the test reuses oneSlackWebhookacross cases whileInitmutates it. Confirmed pre-existing by stashing this branch; left alone as out of scope.processItemre-reads the object from the informer cache instead of using the event's own, so a rapid create→delete burst can silently drop the create notification. Hit while writing the controller test, which sequences around it with a comment.