Skip to content

fix(datadog): keep visitor e-mail addresses out of RUM view URLs - #1632

Merged
dawsontoth merged 17 commits into
stagefrom
fix/rum-redact-auth-email-in-view-urls
Aug 19, 2026
Merged

fix(datadog): keep visitor e-mail addresses out of RUM view URLs#1632
dawsontoth merged 17 commits into
stagefrom
fix/rum-redact-auth-email-in-view-urls

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Studio was sending visitors' e-mail addresses — and live password-reset tokens — to Datadog RUM inside the URL fields of every auth screen, where they were retained and searchable. This redacts both in beforeSend, for every event type.

The addresses ride in the app-generated ?me=/?email= form-persistence params and in the /config/users/<address> path; the token rides in ?token= on /reset-password and /verify-email, and useResetPassword exchanges it for a password change. Nothing redacted any of it: beforeSend only ever touched error.* fields, and shouldKeepEvent returns early for non-error events, so the view and resource events carrying the bulk of it were never visited.

For the human reviewer

  1. Redact at the sink, not at the source. Every writer could encode instead, but that is an invariant each future caller has to re-earn and its failure mode is silent PII. Fixing the sink is one choke point. Fully reversible. What a "no" costs: the source fix is strictly larger and touches six call sites; it is also the only fix for the third-party leak below, so this is "as well as", not "instead of".
  2. A partial redaction is treated as worse than none. zodRequireEmail accepts '()!~* and encodeURIComponent leaves them bare, so a value that ended at those emitted ?email=<redacted>'reilly%40example.com — still identifying, but looking handled. The value therefore ends only at delimiters no accepted address can contain. Cost: an apostrophe or paren wrapping a URL in prose is consumed into the redaction. Ruling requested by @kriszyp; I agree with it, but it is the one trade a reviewer might reverse.
  3. Credential param names go beyond the observed exposure. Only token appears in RUM today; access_token, id_token, refresh_token, code, secret, password, api_key do not. For a credential redactor I treated the standard names as the point rather than speculative generality. A "no" means trimming the list to token and accepting that the next OAuth param to reach a URL ships once before anyone notices.
  4. URL redaction runs before shouldKeepEvent, so dropped events pay for it. The filter can throw on a malformed error field, and the SDK's catchUserErrors swallows that into sending the event anyway — a view event cannot be dismissed from beforeSend at all. The cheaper order is the one that leaks. Both outside legs raised the allocation cost; I kept the order and made the common path a single test instead.
  5. Scope: this fixes Datadog only. The same URLs are captured by the HubSpot, Meta, LinkedIn and Google Analytics beacons, which beforeSend cannot touch — measured, and filed separately. If the view is that the source fix should land first, this PR is the wrong shape and I would rather hear that now.
  6. Known cosmetic residue: an encoded JSON param value redacts to ?filters=<redacted>%22%7D, because % is legal in a local part so the match cannot tell where the encoding stops. No address survives; pinned by a test that says so.

Verification

Fails on base. A detached worktree at the merge base, calling base's beforeSend directly (not the new module, so the failure is behavioural rather than a missing import):

× keeps the visitor address out of the view URL
  AssertionError: expected '…/#/sign-in?me=someone%40example.com' not to contain 'someone%40example.com'
× keeps the reset token out of the view URL
  AssertionError: expected '…/#/reset-password?token=abc.def' not to contain 'abc.def'

Both pass on the branch.

Gate: vitest run 294 files / 2,316 tests, tsc -b, oxlint, dprint check — all clean under Node 24.19.0 (.nvmrc).

Backtracking bound, measured. An earlier revision of the address match had two repetitions that both accepted -: 18ms at 22 characters, 553ms at 26, 8.7s at 30, on the main thread, per event. Now 0.2ms at 40 characters, with a test asserting under 100ms.

Exposure quantified in RUM (app f590deee-…, 30 days): 991 view events across 332 sessions carrying an address, plus 8,988 resource / 448 long-task / 289 action events; 140 view events across 77 sessions carrying a reset token, plus 3,732 resource and 1,400 long-task. 190 resource events carry an address in resource.url itself, 62 of them Studio's own auth URLs.

End-to-end route: not observable in the browser previewbeforeSend produces no UI, and the only true end-to-end exercise is the real SDK in production. Unit coverage plus the RUM measurements above are the evidence; what the tests do not prove is behaviour against real @datadog/browser-rum payloads (read-only or proxied properties), which both legs correctly flagged as a gap.

Review coverage

Authored by Opus 5 (claude-opus-5[1m]). Six pre-push rounds, because rounds 1–5 each found a real defect and three of them were regressions introduced by the previous round's fix.

lens outcome
codex gpt-5.6-sol ✓ all rounds (graded leg)
gemini via agy (default model, not pinned by the run) ✓ rounds 1, 4, 5 · ✗ no-output rounds 2, 3, 6
cursor-composer cursor-agent not installed (rounds 1–4); refuses a diff touching AGENTS.md (round 5); pruned as a low-risk delta (round 6)
cursor-grok ✗ pruned by policy every round
Harper domain adjudication exit-1 rounds 1–5, zero-byte log; pruned as a low-risk delta (round 6)

The adjudicator never ran, so outside findings were not machine-adjudicated — I triaged them by hand and rejected two on evidence: Codex's claim that error.handling_stack is outside the SDK's editable list (it is present in the installed 7.8.0 bundle's ERROR entry), and Gemini's request for a null-event guard (the SDK's types make it non-nullable). Everything else was reproduced before being fixed.

Human-Review-Need: 3 @ 1a54ef3

@dawsontoth
dawsontoth requested a review from a team as a code owner August 18, 2026 14:21
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 57.15% 7102 / 12426
🔵 Statements 57.7% 7634 / 13229
🔵 Functions 49.62% 1782 / 3591
🔵 Branches 51.71% 5094 / 9851
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/integrations/datadog/beforeSend.ts 100% 100% 100% 100%
src/integrations/datadog/redactSensitiveParams.ts 100% 100% 100% 100%
src/integrations/datadog/shouldKeepEvent.ts 100% 100% 100% 100%
Generated in workflow #1761 for commit 3613b26 by the Vitest Coverage Report Action

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces email parameter redaction (me and email) from view URLs and referrers across all Datadog event types to prevent sensitive data exposure, especially on hash-routed authentication screens. The feedback suggests extending this redaction to error messages, stack traces, and resource URLs, as these fields could still leak email parameters on Harper-owned hosts due to exemptions in the general error text redaction. Additionally, adding test cases to verify this extended redaction is recommended.

Comment thread src/integrations/datadog/beforeSend.ts Outdated
Comment thread src/integrations/datadog/beforeSend.test.ts
dawsontoth added a commit that referenced this pull request Aug 18, 2026
…ew URLs

Review feedback on #1632: `redactErrorText` keeps the path for Harper-owned
hosts on purpose, so an error raised on an auth screen whose message, stack
or resource URL quotes the page URL would carry `?me=<address>` straight
through that exemption. No error in the last 30 days (1,110 sampled events)
actually carried one, so this is defence in depth rather than an observed
leak — but it costs one regex pass and the failure mode is a customer's
address in Error Tracking. `handling_stack` gets the same treatment, being
the same class of field and likewise editable in `beforeSend`.

Writing the test for it turned up a real bug in `redactEmailParams`: the
value class `[^&#]+` also admitted whitespace, so an address quoted inside a
multi-line stack matched past the end of its URL and swallowed every
following frame into the redaction. The class now mirrors the terminator set
`redactErrorText`'s own `URL_TOKEN` uses, and the multi-line stack, trailing
fragment and mid-sentence cases are pinned by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated gate — not yet queued for human review.

This PR's AI review found issues, and the PR description reports no cross-model reviews.
Per team policy, a substantive PR with AI-review findings is queued for human review only after at least 2 cross-model reviews have been run, their findings addressed, and the coverage reported in the PR description (## Review coverage naming each model — see harper-engineering-guidelines).
The findings below count as one of the two: address them, run a second outside-model review, update the description, and the gate lifts automatically on the next pass.


TL;DR

Commit 318ac76e correctly extends auth-parameter redaction to supported Datadog error fields and fixes the multiline overmatch.
However, the new terminator class stops at apostrophes, so a valid address such as o'reilly@example.com is only partially redacted.
Because this defeats the PR’s privacy guarantee for a real accepted input, changes are required.
The prior ruling that URL-based form persistence is broader than this Datadog-specific scope remains unchanged.

verdict: CHANGES
merge: rebase
Human-Review-Need: 4 @ 318ac76
Human-Review-Need: 6 @ 318ac76

Diff tour

  • src/integrations/datadog/beforeSend.ts:39-69 composes repository/URL redaction with auth-parameter redaction across message, stack, handling stack, resource URL, view URL, and referrer while preserving filter-before-redact ordering.
  • src/integrations/datadog/shouldKeepEvent.ts:9-22 adds error.handling_stack to the internal event shape. This is supported by the locked SDK: DataDog/browser-sdk#4357, “Allow updates of error.handling_stack, added writability in 6.32.
  • src/integrations/datadog/redactEmailParams.ts:26-40 adds prose/stack terminators to prevent redaction from consuming subsequent frames, but the apostrophe terminator introduces the finding below.
  • src/integrations/datadog/beforeSend.test.ts:113-151 covers composed error-field redaction; src/integrations/datadog/redactEmailParams.test.ts:57-77 covers multiline, fragment, and prose boundaries but misses an accepted apostrophe-containing address.

Findings

major — src/integrations/datadog/redactEmailParams.ts:36 — treating apostrophe as a URL terminator leaves most of a valid email address unredacted

Validation

The one-commit delta and git diff --check were inspected. A standalone reproduction confirms that encodeURIComponent("o'reilly@example.com") leaves the apostrophe unescaped and the new regex produces ?email=<redacted>'reilly%40example.com.

Vitest was not run because this read-only checkout has no node_modules.

Review coverage

lens outcome
gemini pruned — pruned (policy minimal)
cursor-grok pruned — pruned (policy minimal)
cursor-composer pruned — pruned (policy minimal)
codex changes — valid-email suffix can escape redaction
domain pruned — pruned (policy minimal)

Pre-push review of fix/rum-redact-auth-email-in-view-urls (318ac76) vs origin/stage by codex.
Review emphasis: Dispatch-configured.

Review coverage

lens outcome
gemini pruned — pruned (policy minimal)
cursor-grok pruned — pruned (policy minimal)
cursor-composer pruned — pruned (policy minimal)
codex ok — graded leg — produced review.md + comments.json
domain pruned — pruned (policy minimal)

Pre-push review of fix/rum-redact-auth-email-in-view-urls (318ac76) vs origin/stage by codex.
Review emphasis: Dispatch-configured.

— codex review, submitted by the dispatch review gate

Comment thread src/integrations/datadog/redactEmailParams.ts Outdated
Comment thread src/integrations/datadog/redactEmailParams.ts Outdated
Comment thread src/integrations/datadog/redactEmailParams.ts Outdated
Comment thread src/integrations/datadog/beforeSend.ts Outdated
Comment thread src/integrations/datadog/beforeSend.ts Outdated
Comment thread src/integrations/datadog/redactEmailParams.ts Outdated
@cb1kenobi

Copy link
Copy Markdown
Member

Heads-up — the fixes described in the replies above don't appear to have landed on this branch yet.

The PR is still at 318ac76e (2 commits: 3f1a6dd2, 318ac76e), and redactEmailParams.ts at that head still reads:

const EMAIL_PARAM = /([?&](?:me|email)=)[^&#\s'"<>)\]]+/gi;

No token alternative, and no second value-shape pass. Same for the other four — I re-read the files at the current head rather than going by the thread state.

Most likely they're committed locally and just not pushed. Flagging it because all five threads are resolved, so the PR currently reads as fully addressed — easy for the next reviewer to take at face value.

For what it's worth, the replies themselves are excellent: pulling the actual RUM counts (140 view events across 77 sessions carrying a reset token; 190 resource events with an encoded @, 62 of them our own auth screens) turned two of these from "reachable in principle" into measured exposure. And the reasoning for keeping the named pass alongside the value-shape pass — that token has no @ and would fall straight through a value-only match — is exactly right; that's the trap I'd have walked into if I'd proposed replacing rather than adding.

Happy to re-review as soon as the commits are up.


🤖 Barber AI · claude-opus-5

dawsontoth added a commit that referenced this pull request Aug 18, 2026
…an hold

Review feedback on #1632: the terminator class borrowed `URL_TOKEN`'s stop
set, which includes the apostrophe — but `o'reilly@example.com` passes
`zodRequireEmail`, and `encodeURIComponent` escapes neither `'` nor
`(`/`)`/`!`/`~`/`*`, so the address reaches the URL verbatim. The value
therefore ended at the quote and `?email=o'reilly%40example.com` was rewritten
to `?email=<redacted>'reilly%40example.com`, shipping most of the address.
A partial redaction is worse than none: it still identifies the person while
looking handled.

The value now ends only at `&`, `#` or whitespace. Whitespace has to stay a
terminator to keep multi-line stacks intact, but quotes and brackets belong
inside the value, so a delimiter wrapping the URL in surrounding prose is
consumed into the redaction instead. That is the right direction to be wrong
in: over-redacting costs a little context in Error Tracking, under-redacting
costs a customer their address.

Fixed at the redactor rather than at each writer. Encoding correctly at every
call site is an invariant every future caller has to re-earn, and the failure
mode is silent; the sink is the one choke point that cannot be forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Aug 18, 2026
…st param name

Barber AI review on #1632. `redactEmailParams` becomes
`redactSensitiveParams`: it no longer only covers addresses.

`?token=` was shipping in `view.url`. `ResetPassword` reads it from the same
hash-routed query and `useResetPassword` exchanges it for a password change, so
a retained reset URL is an account takeover for as long as the token is valid —
and RUM had 140 view events across 77 sessions carrying one in the last 30 days,
plus 3,732 resource and 1,400 long-task events attributed to those views. Of the
other credential-shaped names checked against RUM (`code`, `state`,
`access_token`, `id_token`, `key`, `secret`, `password`, `apikey`, `session`),
none appear in any view URL, so `token` is the whole exposure.

A param-name list only covers the params someone remembered, so a second pass
matches any param whose value holds an address: `?filters={"email":"…"}`
deep-links from `RelationshipCell` carried one through a param no auth screen
owns. It still matches values rather than paths, so `/HDBInstance/<id>/operation`
stays readable.

Trailing punctuation is preserved instead of being eaten into the redaction,
mirroring `redactErrorText`'s `TRAILING_PUNCTUATION`.

Every field is now type-checked rather than truth-checked. The SDK wraps this
hook in `catchUserErrors`, which swallows a throw and returns `undefined`, and a
view event cannot be dismissed from `beforeSend` at all — so a throw part-way
through would ship the event with only some fields redacted. Failing open is the
one failure mode a redactor must not have.

Comments cut again to the constraints the code can't state; both prior rounds
flagged the narration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Aug 18, 2026
…RL-PII rule

Both cost a review round on #1632. `beforeSend` failing open is why every field
it touches is type-checked and why the URL redaction has to precede
`shouldKeepEvent` — an ordering that looks arbitrary until you know the filter
can throw. And hash routing is why a `URLSearchParams` fix for a query param
silently does nothing here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dawsontoth
dawsontoth requested a review from cb1kenobi August 18, 2026 16:33
@dawsontoth

Copy link
Copy Markdown
Contributor Author

You are right, and thank you for checking the files rather than the thread state — that was my error, not a sync lag. I resolved all five threads while the commits were still only local, which left the PR reading as fully addressed with none of the code on it. Resolving should have come after pushing; I inverted it.

Now pushed — head is 1a54ef38, 10 commits. At that head redactEmailParams.ts is gone (renamed redactSensitiveParams.ts) and the two passes are:

const CREDENTIAL_PARAM = 'me|email|token|access_token|id_token|refresh_token|code|secret|password|api_?key';
const ADDRESS = String.raw`[\w.%+!~*'()-]+(?:@|%40)[\w-]+(?:\.[\w-]+)*\.\w{2,}`;

Two things changed after those replies, both from later review rounds, so the threads understate the diff rather than overstate it:

The value-shape pass became an address-token match. Terminating a value at delimiters was unwinnable — one round flagged it for over-consuming the JSON around an embedded URL, a later one for under-matching an unencoded JSON value, which are contradictory demands on one character class. Matching the address token itself needs no delimiter choice, and it also covers the /config/users/<address> path segment I had told you was out of reach. It applies to URL fields only: user@host.tld is equally the shape of an scp git remote, and your redactErrorText deliberately keeps the host of git@github.com:<redacted> — one of its existing tests caught the address pass stripping it, which was a good save.

That first token version had a ReDoS. [\w-]+(?:[.-][\w-]+)* let two repetitions both accept -, so an @ before a long hyphenated token with no dot-TLD backtracked exponentially: 18ms at 22 characters, 553ms at 26, 8.7s at 30, on the main thread, per event. Separators are \. alone now — 0.2ms at 40 — with a test bounding it. Worth knowing if that pattern gets reused anywhere.

On your point about the named pass: it also turned out to be load-bearing in the other direction. Extending it past token to the standard credential names is the only thing covering ?code=/?secret=-style params, since none of those has an @ for the address pass to find.

🤖 Addressed by Claude Code

@kriszyp
kriszyp dismissed their stale review August 18, 2026 16:36

cross-model coverage reported — released to human review

@dawsontoth
dawsontoth requested a review from kriszyp August 18, 2026 16:45
Comment thread src/integrations/datadog/redactSensitiveParams.test.ts Outdated
Comment thread src/integrations/datadog/beforeSend.test.ts Outdated
dawsontoth added a commit that referenced this pull request Aug 18, 2026
Barber AI review on #1632, both findings from mutation testing and both
reproduced here before fixing. Three separate mutations survived the whole
94-test suite, so three invariants were pinned by nothing.

Reverting `ADDRESS`'s local part to `[\w.%+-]+` stayed green while
`/config/users/o'brien(work)%40example.com` shipped **completely unredacted**
and `o'reilly@example.com` redacted to `o'<redacted>`. The apostrophe cases
already in the suite all sit behind `?email=`, so they exercise the credential
branch and never reach `ADDRESS` at all.

`url: 12345` did not discriminate the type guard: `WORTH_SCANNING.test(12345)`
coerces, finds no separator and returns early, so the truthiness version never
reaches the call that would throw. An array whose string form *does* match and
which has no `.replace` is what forces it.

Reverting `shouldKeepEvent`'s message check to `?? ''` also stayed green — the
leading `.test()` calls coerce, and no existing case carried a non-string
message as far as the `.includes` that throws.

Each mutation now fails: 2, 1 and 1 test respectively.

Production code is byte-identical to 1a54ef3, which the cross-model review
already covered, so this adds no unreviewed behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Aug 18, 2026
…ew URLs

Review feedback on #1632: `redactErrorText` keeps the path for Harper-owned
hosts on purpose, so an error raised on an auth screen whose message, stack
or resource URL quotes the page URL would carry `?me=<address>` straight
through that exemption. No error in the last 30 days (1,110 sampled events)
actually carried one, so this is defence in depth rather than an observed
leak — but it costs one regex pass and the failure mode is a customer's
address in Error Tracking. `handling_stack` gets the same treatment, being
the same class of field and likewise editable in `beforeSend`.

Writing the test for it turned up a real bug in `redactEmailParams`: the
value class `[^&#]+` also admitted whitespace, so an address quoted inside a
multi-line stack matched past the end of its URL and swallowed every
following frame into the redaction. The class now mirrors the terminator set
`redactErrorText`'s own `URL_TOKEN` uses, and the multi-line stack, trailing
fragment and mid-sentence cases are pinned by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Aug 18, 2026
…an hold

Review feedback on #1632: the terminator class borrowed `URL_TOKEN`'s stop
set, which includes the apostrophe — but `o'reilly@example.com` passes
`zodRequireEmail`, and `encodeURIComponent` escapes neither `'` nor
`(`/`)`/`!`/`~`/`*`, so the address reaches the URL verbatim. The value
therefore ended at the quote and `?email=o'reilly%40example.com` was rewritten
to `?email=<redacted>'reilly%40example.com`, shipping most of the address.
A partial redaction is worse than none: it still identifies the person while
looking handled.

The value now ends only at `&`, `#` or whitespace. Whitespace has to stay a
terminator to keep multi-line stacks intact, but quotes and brackets belong
inside the value, so a delimiter wrapping the URL in surrounding prose is
consumed into the redaction instead. That is the right direction to be wrong
in: over-redacting costs a little context in Error Tracking, under-redacting
costs a customer their address.

Fixed at the redactor rather than at each writer. Encoding correctly at every
call site is an invariant every future caller has to re-earn, and the failure
mode is silent; the sink is the one choke point that cannot be forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Aug 18, 2026
…st param name

Barber AI review on #1632. `redactEmailParams` becomes
`redactSensitiveParams`: it no longer only covers addresses.

`?token=` was shipping in `view.url`. `ResetPassword` reads it from the same
hash-routed query and `useResetPassword` exchanges it for a password change, so
a retained reset URL is an account takeover for as long as the token is valid —
and RUM had 140 view events across 77 sessions carrying one in the last 30 days,
plus 3,732 resource and 1,400 long-task events attributed to those views. Of the
other credential-shaped names checked against RUM (`code`, `state`,
`access_token`, `id_token`, `key`, `secret`, `password`, `apikey`, `session`),
none appear in any view URL, so `token` is the whole exposure.

A param-name list only covers the params someone remembered, so a second pass
matches any param whose value holds an address: `?filters={"email":"…"}`
deep-links from `RelationshipCell` carried one through a param no auth screen
owns. It still matches values rather than paths, so `/HDBInstance/<id>/operation`
stays readable.

Trailing punctuation is preserved instead of being eaten into the redaction,
mirroring `redactErrorText`'s `TRAILING_PUNCTUATION`.

Every field is now type-checked rather than truth-checked. The SDK wraps this
hook in `catchUserErrors`, which swallows a throw and returns `undefined`, and a
view event cannot be dismissed from `beforeSend` at all — so a throw part-way
through would ship the event with only some fields redacted. Failing open is the
one failure mode a redactor must not have.

Comments cut again to the constraints the code can't state; both prior rounds
flagged the narration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Aug 18, 2026
…RL-PII rule

Both cost a review round on #1632. `beforeSend` failing open is why every field
it touches is type-checked and why the URL redaction has to precede
`shouldKeepEvent` — an ordering that looks arbitrary until you know the filter
can throw. And hash routing is why a `URLSearchParams` fix for a query param
silently does nothing here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth added a commit that referenced this pull request Aug 18, 2026
Barber AI review on #1632, both findings from mutation testing and both
reproduced here before fixing. Three separate mutations survived the whole
94-test suite, so three invariants were pinned by nothing.

Reverting `ADDRESS`'s local part to `[\w.%+-]+` stayed green while
`/config/users/o'brien(work)%40example.com` shipped **completely unredacted**
and `o'reilly@example.com` redacted to `o'<redacted>`. The apostrophe cases
already in the suite all sit behind `?email=`, so they exercise the credential
branch and never reach `ADDRESS` at all.

`url: 12345` did not discriminate the type guard: `WORTH_SCANNING.test(12345)`
coerces, finds no separator and returns early, so the truthiness version never
reaches the call that would throw. An array whose string form *does* match and
which has no `.replace` is what forces it.

Reverting `shouldKeepEvent`'s message check to `?? ''` also stayed green — the
leading `.test()` calls coerce, and no existing case carried a non-string
message as far as the `.includes` that throws.

Each mutation now fails: 2, 1 and 1 test respectively.

Production code is byte-identical to 1a54ef3, which the cross-model review
already covered, so this adds no unreviewed behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the fix/rum-redact-auth-email-in-view-urls branch from 6929fa0 to cc77e96 Compare August 18, 2026 17:23

@DavidCockerill DavidCockerill left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at cc77e96. @cb1kenobi had already found the substantive issues here, so rather than re-raise anything I verified closure at this head — all four are closed, and the fail-open one is closed properly rather than patched.

Finding Status
High?token= on /reset-password and /verify-email still ships in view.url Closed. CREDENTIAL_PARAM now covers token|access_token|id_token|refresh_token|code|secret|password|api_?key alongside me|email.
Medium — a param-name list leaves ?filters= shipping addresses; match the value shape Closed at the level asked for. The ADDRESS alternative matches a bare address anywhere in the string, so an address under any param name is caught — the rename to redactSensitiveParams reflects that it is no longer param-keyed only.
Mediumresource.url on non-error resource events is never redacted Closed. event.resource?.url is redacted unconditionally, ahead of the shouldKeepEvent gate.
Low — the view redaction runs last and beforeSend fails open Closed at the root. catch { return false } drops the event on any throw, and view URLs are redacted first, ahead of shouldKeepEvent — which the docblock correctly identifies as the one field that cannot be dismissed later, so it has to be clean before anything that can throw.

@kriszyp's apostrophe case (o'reilly@example.com) is closed at 31d7bbee with the reasoning preserved in the CREDENTIAL_VALUE comment, and @gemini-code-assist's error.* consistency point at 318ac76e.

Two things worth crediting. The ReDoS work is measured rather than guessed — the ADDRESS comment records that [.-] as a separator class backtracked 8.7s at 30 characters with [\w-]+ also matching -, and pins separators to \. alone. That is a number most reviews never produce. And WORTH_SCANNING short-circuits before either regex runs, so the common clean-URL case costs one cheap test instead of two alternated scans.

One non-blocking observation, not a reason to hold this: a bare address in error.message / error.stack is deliberately not redacted — those go through redactCredentialParams (params only) rather than the ADDRESS pattern, because the address token would eat the host out of the git@github.com:<redacted> that redactErrorText keeps for triage. That trade is documented and the PR's scope is URLs, so I am not asking for it here. Worth knowing it stays a live gap if an error string ever interpolates a user address (User foo@bar.com not found). Anchoring ADDRESS so it cannot match an scp-style remote — requiring the match not be followed by : — would let both be redacted, but that is a design call for another change.

This is the complete set of my concerns at this head.

— DAIvid (Claude Opus 5)

Comment thread src/integrations/datadog/beforeSend.test.ts Outdated
dawsontoth added a commit that referenced this pull request Aug 18, 2026
Barber AI review on #1632, reproduced first: reverting either `view.referrer`
or `event.resource.url` to a truthiness check kept all 94 tests green, while
`beforeSend` throws, the `catch` returns `false`, and a view event — which the
SDK cannot dismiss — ships that field unredacted.

The previous commit pinned `view.url` and stopped there. All three fields take
the same value down the same path, so one case was never the right shape; the
`it.each` covers the set. Each of the three reverts now fails one test.

The symmetry grep I ran covered the guards themselves — 7 fields, 7 `typeof`
checks — and not the tests standing behind them, which is how a symmetric
implementation ended up with asymmetric coverage.

Production code is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth and others added 5 commits August 18, 2026 14:24
The auth screens carry a typed address between each other in `?me=`/`?email=`
so a visitor doesn't retype it. The RUM SDK reads `view.url` and
`view.referrer` from `window.location`, so every view, resource, action and
long task recorded on those screens shipped the address to Error Tracking,
where it is retained and searchable — 991 view events across 332 distinct
sessions in the last 30 days, most of them unauthenticated people on the
sign-up funnel who never became users.

Two things had to be true for it to leak, so both are fixed here:

`beforeSend` only ever redacted `error.message`, `error.stack` and
`error.resource.url`, and `shouldKeepEvent` returns early for every
non-error event, so the redaction block never ran for the view and resource
events carrying the bulk of it. It now redacts the view URL for all kept
event types; `view.url` and `view.referrer` are on the browser SDK's shared
modifiable-field allowlist, so they are editable for each of them.

`redactErrorText` could not do the job either. It reduces a URL to
scheme + host + <redacted> only for hosts Harper doesn't own, deliberately
keeping the path for our own domains because that path is how instance
errors get triaged — and the auth routes are on fabric.harper.fast, so they
took that exemption. `redactEmailParams` matches by param name instead, so
the endpoint stays readable while the address does not survive.

Studio uses hash routing, so the param sits in the fragment rather than the
query: for `/#/sign-in?me=…`, `new URL(url).search` is empty. The match is
therefore against the raw string — a `URLSearchParams` implementation would
silently do nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ew URLs

Review feedback on #1632: `redactErrorText` keeps the path for Harper-owned
hosts on purpose, so an error raised on an auth screen whose message, stack
or resource URL quotes the page URL would carry `?me=<address>` straight
through that exemption. No error in the last 30 days (1,110 sampled events)
actually carried one, so this is defence in depth rather than an observed
leak — but it costs one regex pass and the failure mode is a customer's
address in Error Tracking. `handling_stack` gets the same treatment, being
the same class of field and likewise editable in `beforeSend`.

Writing the test for it turned up a real bug in `redactEmailParams`: the
value class `[^&#]+` also admitted whitespace, so an address quoted inside a
multi-line stack matched past the end of its URL and swallowed every
following frame into the redaction. The class now mirrors the terminator set
`redactErrorText`'s own `URL_TOKEN` uses, and the multi-line stack, trailing
fragment and mid-sentence cases are pinned by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…an hold

Review feedback on #1632: the terminator class borrowed `URL_TOKEN`'s stop
set, which includes the apostrophe — but `o'reilly@example.com` passes
`zodRequireEmail`, and `encodeURIComponent` escapes neither `'` nor
`(`/`)`/`!`/`~`/`*`, so the address reaches the URL verbatim. The value
therefore ended at the quote and `?email=o'reilly%40example.com` was rewritten
to `?email=<redacted>'reilly%40example.com`, shipping most of the address.
A partial redaction is worse than none: it still identifies the person while
looking handled.

The value now ends only at `&`, `#` or whitespace. Whitespace has to stay a
terminator to keep multi-line stacks intact, but quotes and brackets belong
inside the value, so a delimiter wrapping the URL in surrounding prose is
consumed into the redaction instead. That is the right direction to be wrong
in: over-redacting costs a little context in Error Tracking, under-redacting
costs a customer their address.

Fixed at the redactor rather than at each writer. Encoding correctly at every
call site is an invariant every future caller has to re-earn, and the failure
mode is silent; the sink is the one choke point that cannot be forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t JSON delimiters

Cross-model review (codex + gemini) on 31d7bbe, adjudicated by hand because
the domain leg failed.

A resource event's own `resource.url` was never redacted — only the
`error.resource.url` of a *failed* request was. Confirmed live rather than
taken on the reviewer's word: 190 resource events in the last 30 days carry an
encoded `@`, 62 of them Studio's own auth-screen URLs. `resource.url` is on the
SDK's editable-property list for resource events, so it is redacted here too.

The value class no longer runs through JSON delimiters. `zodRequireEmail`
rejects `"`, `,`, `<`, `>`, `[`, `]`, `{`, `}` and `|`, so ending the value on
them cannot truncate any address we accept — which means an auth URL embedded in
a JSON error message keeps the fields after it, instead of being eaten to the
next space. `'`, `(`, `)`, `!`, `~` and `*` stay inside the value: those are
valid in an address and survive `encodeURIComponent`, so ending there would
leak most of it (the defect fixed in 31d7bbe). Both trades are now pinned by
tests that state which way each one goes.

Also `import { type DatadogErrorEvent, ... }`, matching the repo's dominant
convention — the value import predates this branch, so this is not a regression
being fixed, just the hazard the reviewer named removed while nearby.

Comments cut back to the invariants the code can't state, per the
zero-new-comments default; both reviewers flagged the narration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st param name

Barber AI review on #1632. `redactEmailParams` becomes
`redactSensitiveParams`: it no longer only covers addresses.

`?token=` was shipping in `view.url`. `ResetPassword` reads it from the same
hash-routed query and `useResetPassword` exchanges it for a password change, so
a retained reset URL is an account takeover for as long as the token is valid —
and RUM had 140 view events across 77 sessions carrying one in the last 30 days,
plus 3,732 resource and 1,400 long-task events attributed to those views. Of the
other credential-shaped names checked against RUM (`code`, `state`,
`access_token`, `id_token`, `key`, `secret`, `password`, `apikey`, `session`),
none appear in any view URL, so `token` is the whole exposure.

A param-name list only covers the params someone remembered, so a second pass
matches any param whose value holds an address: `?filters={"email":"…"}`
deep-links from `RelationshipCell` carried one through a param no auth screen
owns. It still matches values rather than paths, so `/HDBInstance/<id>/operation`
stays readable.

Trailing punctuation is preserved instead of being eaten into the redaction,
mirroring `redactErrorText`'s `TRAILING_PUNCTUATION`.

Every field is now type-checked rather than truth-checked. The SDK wraps this
hook in `catchUserErrors`, which swallows a throw and returns `undefined`, and a
view event cannot be dismissed from `beforeSend` at all — so a throw part-way
through would ship the event with only some fields redacted. Failing open is the
one failure mode a redactor must not have.

Comments cut again to the constraints the code can't state; both prior rounds
flagged the narration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dawsontoth and others added 9 commits August 18, 2026 14:24
Cross-model review (codex) on 39e2ee3.

`shouldKeepEvent` runs first and calls string methods on `error.message` and
`error.stack`, so a malformed field throws out of it — before any of the type
guards added in the previous commit. The SDK's `catchUserErrors` swallows that
and ships the event anyway, which put a reset token back on the wire despite
those guards.

The filter reads no URL field, so the URL redaction now runs ahead of it and the
error-text redaction stays behind it, where the raw stack and raw
`error.resource.url` are still what it attributes on. `shouldKeepEvent` also
type-checks the three fields it reads rather than `?? ''`-ing them, so the throw
path is gone rather than merely stepped around.

The value class now ends at a backslash. It was consuming the one that escapes a
quote in a JSON payload quoted inside an error message, leaving the payload's
escaping malformed; no address `zodRequireEmail` accepts contains a backslash, so
stopping there cannot truncate one.

Both passes need a `?` or `&` to match, and most Studio URLs are a bare hash
route, so an early return skips two scans on that path — raised by both outside
legs across rounds, and cheap enough now that it is two `includes` calls rather
than a second regex.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…RL-PII rule

Both cost a review round on #1632. `beforeSend` failing open is why every field
it touches is type-checked and why the URL redaction has to precede
`shouldKeepEvent` — an ordering that looks arbitrary until you know the filter
can throw. And hash routing is why a `URLSearchParams` fix for a query param
silently does nothing here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xt redaction

Cross-model review (codex + gemini) on e97441c — the first round both outside
legs completed.

Matching "a param value up to the next delimiter" could not win. Ending the
value at `"`/`{`/`}` protects the JSON around an embedded URL but misses an
address in an unencoded JSON value; admitting them does the reverse. Both were
raised as findings, in opposite directions, two rounds apart. An address is now
matched as a *token* wherever it appears, which needs no delimiter choice: it
covers the unencoded JSON value, the `/config/users/<address>` path segment that
was previously out of scope, and any param no auth screen owns. Requiring a
dot-TLD also ends the over-redaction of `?pkg=@harperdb/client` and
`?ref=main@HEAD`, which were being destroyed as if they were addresses.

That token is also the shape of an scp-style git remote, so it applies to URL
fields only. `redactErrorText` owns URL and remote semantics in free text and
deliberately keeps the host of `git@github.com:<redacted>` for triage — an
existing test caught the address pass taking it. Error text therefore gets
`redactCredentialParams`, the named-param pass alone.

Credential params extended past `token` to the standard names (`access_token`,
`id_token`, `refresh_token`, `code`, `secret`, `password`, `api_key`). None
appears in any RUM view URL today; for a credential redactor the names are the
point, not speculative generality.

One pass over the string instead of two, behind a cheap pre-test, which answers
the allocation-on-the-hot-path finding both legs raised.

The filter-exception test asserted an ordering it had stopped exercising: the
same commit that added it made `shouldKeepEvent` type-safe, so a non-string
message no longer threw and the test passed for the wrong reason. It now forces
the throw with a getter, and the redaction block returns `false` rather than
letting a throw ship half-redacted text.

Not taken: a null/undefined `event` guard. The SDK's own types make the argument
non-nullable, and guarding each of its contracts in turn has no end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…endors aren't

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cross-model review (codex + gemini) on 70853fb; both legs flagged the
backtracking independently.

`[\w-]+(?:[.-][\w-]+)*` let two repetitions both match `-`, so an `@` followed by
a long hyphenated token with no dot-TLD — `git@long-service-name-stage-cluster`,
which is an ordinary thing to find in a URL — explored the partitions
exponentially. Measured on the branch: 18ms at 22 characters, 553ms at 26, 8.7s
at 30, all on the main thread, for every RUM event. Separators are `\.` alone
now, which is unambiguous because `[\w-]+` already covers hyphens inside a label:
0.2ms at 40 characters. A test bounds it.

The address local part now carries the punctuation `zodRequireEmail` accepts
(`'()!~*`). Without it, `/config/users/o'reilly@example.com` matched only from
`reilly`, leaving the leading characters of the address behind — the same partial
redaction the delimiter set was fixed for two rounds ago, arriving by the other
route.

`error.resource.url` gets the URL pass, not the credential-params pass.
`redactErrorText` keeps Harper paths for triage, so an address in the path of a
failed request to `api.harper.fast` was surviving it.

All mutation is inside the try. A throwing getter on a URL field escaped it,
and on a throw the SDK sends the event.

Comments halved again to the invariants that would otherwise be edited back into
defects — the `\.`-only separator above all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Barber AI review on #1632, both findings from mutation testing and both
reproduced here before fixing. Three separate mutations survived the whole
94-test suite, so three invariants were pinned by nothing.

Reverting `ADDRESS`'s local part to `[\w.%+-]+` stayed green while
`/config/users/o'brien(work)%40example.com` shipped **completely unredacted**
and `o'reilly@example.com` redacted to `o'<redacted>`. The apostrophe cases
already in the suite all sit behind `?email=`, so they exercise the credential
branch and never reach `ADDRESS` at all.

`url: 12345` did not discriminate the type guard: `WORTH_SCANNING.test(12345)`
coerces, finds no separator and returns early, so the truthiness version never
reaches the call that would throw. An array whose string form *does* match and
which has no `.replace` is what forces it.

Reverting `shouldKeepEvent`'s message check to `?? ''` also stayed green — the
leading `.test()` calls coerce, and no existing case carried a non-string
message as far as the `.includes` that throws.

Each mutation now fails: 2, 1 and 1 test respectively.

Production code is byte-identical to 1a54ef3, which the cross-model review
already covered, so this adds no unreviewed behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Barber AI review on #1632, reproduced first: reverting either `view.referrer`
or `event.resource.url` to a truthiness check kept all 94 tests green, while
`beforeSend` throws, the `catch` returns `false`, and a view event — which the
SDK cannot dismiss — ships that field unredacted.

The previous commit pinned `view.url` and stopped there. All three fields take
the same value down the same path, so one case was never the right shape; the
`it.each` covers the set. Each of the three reverts now fails one test.

The symmetry grep I ran covered the guards themselves — 7 fields, 7 `typeof`
checks — and not the tests standing behind them, which is how a symmetric
implementation ended up with asymmetric coverage.

Production code is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cross-model review (codex) on 76ac11a.

`view.name` was the one editable URL-ish field left out. In practice it was
already clean — `translateUrlForDatadog` drops the query and parameterises route
values, and across 300 sampled view events that *do* carry an address in
`view.url_hash`, zero carried one in `view.name` (they read `/sign-in/`,
`/verifying/`, `/$organizationId/$clusterId/config/users/$username/`). So this is
belt to the other fields' braces rather than a leak being closed, but it is
equally editable and costs one line.

The substantive half is the tests. Rather than pin the guards the review named, I
mutation-tested all eleven: five were unpinned — `error.stack`,
`error.handling_stack`, `error.resource.url`, and `shouldKeepEvent`'s `stack` and
`resource.url`. Each is read twice, once by the filter and once by the redaction,
so one case per field pins both. A 5xx message is used throughout because that is
what makes the filter coerce `resource.url`: under `?? ''` the array stringifies
into an instance endpoint and the event is silently *dropped* rather than kept,
which is a different failure from the throw the other guards have.

All eleven guards now fail a test when reverted. This is the third round on the
same defect class — the symmetry grep I kept running counted guards and
redaction calls, both of which were already symmetric, and never the tests
behind them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cross-model review (codex + gemini) on e454d57; both legs completed and the
only substantive finding was Gemini's.

`api_?key` matched `apikey` and `api_key` but not `api-key`. Nothing in Studio
writes any of the three — zero occurrences across 30 days of RUM for every
spelling, and no `apiKey` in `src` at all — so this is the same defence-in-depth
call already made for the other credential names rather than an observed leak.
One character, and all four spellings are pinned.

Comments trimmed again, which was the remaining nit. The ones left state why a
fixture discriminates rather than what the code does: an array value instead of
a number, the 5xx message that forces the filter to coerce. Those are the ones
someone would otherwise simplify back into a test that passes either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the fix/rum-redact-auth-email-in-view-urls branch from 7d75d5b to 149d5c8 Compare August 18, 2026 18:25
Comment thread src/integrations/datadog/beforeSend.ts Outdated
Comment thread src/integrations/datadog/redactSensitiveParams.test.ts
…me comment

Barber AI review on 149d5c8, and a correction to something I asserted.

The `view.name` comment claimed the field was "already clean in practice"
because `translateUrlForDatadog` parameterises the path. That premise is false.
It parameterises by string match against TanStack's `m.params`, which are
*decoded*, while the href holds the encoded form — so `/someone%40acme-corp.com/`
never matches `/someone@acme-corp.com/` and the address survives into the name.
The guard is the only thing between that path and Datadog, and the old comment
is the sentence someone would quote while deleting it. I could not observe the
shape in 30 days of RUM (every `users/<address>` view carried a raw `@`, which
round-trips and parameterises correctly), but a jsdom run against the real
`users/$username` route reproduces it, and a mechanism that can leak beats a
sample that has not yet.

`ADDRESS`'s domain label needed `[\w-]`, not `[\w]`: with the hyphen dropped,
`someone%40acme-corp.com` and `first.last%40my-company.io` matched nothing and
shipped whole. A hyphenated corporate domain is the ordinary shape, and it is
exactly what that view name produces. Both label positions are covered — the
first and the ones after it are separate repetitions and each was unpinned.

Three more mutations that survived are now killed: dropping
`access_token|id_token|refresh_token`, dropping `code|secret|password`, and
narrowing the `[?&]` anchor to `[?]`. The anchor case needs a value that is *not*
address-shaped, or the address pass redacts it regardless and the anchor is never
what is under test.

The previous commit's "pin every guard by mutation" was an overclaim: it covered
the eleven type guards, not the regex components beside them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/integrations/datadog/redactSensitiveParams.test.ts
Barber AI review on c69d360. Both are coverage, not live leaks — the regexes
are unchanged.

`ADDRESS`'s local part carries a hyphen too, at the end of its class, and I had
swept only the two on the domain side. Dropping it left 119 green while
`mary-jane%40acme.com` redacted to `mary-<redacted>` — a partial redaction, which
is the failure mode this PR's whole terminator argument exists to prevent, and
hyphens in a local part are ordinary.

The `&` anchor exists in both regexes and only the URL one was pinned. `CREDENTIAL`
is the pass `redactErrorAndParams` runs over `error.message`, `error.stack` and
`error.handling_stack`, so a credential after `&` quoted in a stack shipped
verbatim under the mutant.

Correcting the previous commit's subject rather than rewriting it: "redact
hyphenated mail domains" claimed a behaviour change that did not happen —
`redactSensitiveParams.ts` was byte-identical, and that commit pinned the
hyphenated-domain behaviour by test instead of fixing it. The regex had always
handled it; only the mutation was uncaught. The squash takes the PR title, so the
misleading subject does not reach `stage`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/integrations/datadog/redactSensitiveParams.test.ts
… prove something

Barber AI review on ea344a2. Coverage only — production files are unchanged.

The git-remote case was vacuous. Its text carried no `?` and no `&`, so
`redactCredentialParams` returned at the pre-test before either regex ran, and it
passed whichever regex the body used — including the address pass, which is the
one mistake the comment above that function exists to warn about. Under that
swap the fixture now loses its host (`<redacted>:acme-corp/svc.git`), which is
exactly the triage loss `redactErrorText` is careful to avoid.

The pre-test's own `&` arm was unpinned for the same reason: every free-text
fixture that carried an `&` also carried a `?`, so the first arm always decided.

Then the same shape everywhere else it applies, rather than only where it was
reported: the two "endpoint stays readable" assertions used separator-free URLs,
so the invariant they name — a Harper API endpoint survives redaction, which is
how these errors get triaged — was being proved by an early return. Both now
carry a query and exercise the regexes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dawsontoth
dawsontoth added this pull request to the merge queue Aug 19, 2026
Merged via the queue into stage with commit 1b50ed1 Aug 19, 2026
2 checks passed
@dawsontoth
dawsontoth deleted the fix/rum-redact-auth-email-in-view-urls branch August 19, 2026 14:09
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.

4 participants