Skip to content

feat(store): mask credentials before an HTTP record is buffered - #39

Merged
anilcancakir merged 3 commits into
masterfrom
feat/redact-credentials
Sep 29, 2026
Merged

anilcancakir merged 3 commits into
masterfrom
feat/redact-credentials

Conversation

@anilcancakir

Copy link
Copy Markdown
Member

Summary

The HTTP ring buffer is served to AI agents over ext.telescope.* and MCP, and it stored credentials verbatim: the bearer header magic's AuthInterceptor writes, the login password in the request body and the token in the login response. TelescopeStore.recordHttp now masks them before a record is buffered or streamed, following Laravel Telescope's model.

  • TelescopeRedaction: default hidden request headers (authorization, proxy-authorization, cookie, set-cookie, x-api-key), request parameters (passwords, tokens, 2FA and OAuth codes) and response parameters (tokens, secrets, TOTP qr_url/qr_svg, recovery codes), matched case-insensitively and masked as ********.
  • hideRequestHeaders, hideRequestParameters, hideResponseParameters extend the defaults, as Laravel's do.
  • redactParameters / redactBody for adapters that hold structured data: a body stringified with toString() is not JSON and cannot be redacted after the fact, so an adapter should redact before it stringifies (magic_devtools does, feat(perf): wire MagicPerfHooks, interaction links and insight rules magic_devtools#22).
  • JSON string bodies are parsed, redacted and re-encoded; a non-JSON body is left as is; recordHttp never throws on odd input; recursion is bounded.

Verification

flutter test --exclude-tags=integration: 378 passed, coverage 97.8%; dart analyze and dart format clean.

Not covered

Credentials in the URL query (?token=), keyless lists (a bare list of recovery codes) and onError bodies of Dio bad-response errors (pre-existing) are left as follow-ups.

The HTTP ring buffer is served to AI agents over ext.telescope.requests
and MCP, and it stored bearer headers, login passwords and Sanctum tokens
as sent. recordHttp now masks hidden request headers and the hidden keys
of a JSON request or response body with '********', following Laravel
Telescope's hiddenRequestHeaders / hiddenRequestParameters /
hiddenResponseParameters model, and emits the masked copy on
onHttpRecord. The defaults cover the Fortify, two-factor and OAuth names
the magic starter stack sends, including qr_url and qr_svg, which carry
the TOTP secret. The walk is depth-capped so recordHttp cannot overflow
the stack.

TelescopeRedaction exposes the hide* methods (additions merge with the
defaults), the lowercased lists, and redactParameters / redactBody for
adapters that must mask a body before stringifying or truncating it.
HttpRequestRecord gains copyWith.
@codecov

codecov Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.18033% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/src/telescope_redaction.dart 98.85% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@kodizm

kodizm Bot commented Sep 29, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

Looks correct and safe to merge. I found no blocking issues, and there is one gap worth considering.

I read lib/src/telescope_redaction.dart, lib/src/internal/redaction_lists.dart, the telescope_store.dart, http_request_record.dart and barrel diffs, the CHANGELOG and the one in-repo caller (DioHttpAdapter.recordRequest). I did not open the doc, skill or test diffs line by line.

What I checked:

  • recordHttp keeps its frozen signature.
  • Both the buffer and onHttpRecord get the redacted copy, and the paused early return still comes first.
  • copyWith's ?? fallbacks are safe, because redaction only ever replaces a value with a non-null one.
  • redactBody catches the only exception jsonDecode throws on bad input, and the depth cap keeps recursion bounded.
  • resetForTesting now resets the redaction lists, so tests stay isolated.
  • The CHANGELOG has entries under [Unreleased] (Added and Security), as CLAUDE.md asks.
  • I found no em or en dashes in the added lines.

Minor

lib/src/telescope_redaction.dart:183 (security): application/x-www-form-urlencoded bodies are kept word for word, so a form-encoded login or OAuth token call (grant_type=password&password=...&client_secret=...) still puts the credential in the buffer. The CHANGELOG says non-JSON bodies are kept as given, but the PR's "Not covered" list only names URL query strings. Either add form bodies to that list or parse k=v&... bodies with Uri.splitQueryString in a follow-up.

Tests

New test/src/telescope_redaction_test.dart (332 lines) plus additions to the store and record tests cover the change. codecov/patch reports 99.04% of the diff hit.

CI

  • Lint & Test: success
  • codecov/patch: success (99.04% of diff hit, target 97.66%)
  • Auto-merge low-risk Dependabot PRs: skipped

An OAuth password grant or a form login sends grant_type=password&
password=...&client_secret=..., which redactBody kept verbatim because it
only parsed JSON. A body that reads as a whole form body is now masked
pair by pair, with every other pair kept byte for byte.

Co-Authored-By: Anılcan Çakır <anilcan.cakir@gmail.com>
@anilcancakir

Copy link
Copy Markdown
Member Author

Round 1 finding, addressed in 1d762e8.

lib/src/telescope_redaction.dart:183, form-encoded bodies kept verbatim (Minor): fixed rather than listed. redactBody now masks a body that reads as a whole application/x-www-form-urlencoded body. The check is _formBody: pairs joined by &, no whitespace, and every % escape well formed. It masks pair by pair. Each key is decoded with Uri.decodeQueryComponent(..., encoding: latin1), which cannot throw once the regex has passed. A matching non-empty value becomes ********, and every other pair keeps its exact bytes. The body is returned unchanged (same instance) when nothing matched. I did not use Uri.splitQueryString plus a re-encode, because that would rewrite the encoding of the pairs that are not secret. Plain text and truncated JSON are still kept as given.

Tests.

  • telescope_redaction_test.dart: masks a form-encoded body pair by pair, keeping every other pair as given. It covers an OAuth password grant with PASSWORD and client_secret masked, username=a%40b.test and scope= untouched, a percent-encoded key (pass%77ord), an empty value that stays visible, and a form body with nothing to hide (same(...)). It failed before the change.
  • telescope_store_test.dart: the case that pinned the gap (leaves a body that is not JSON untouched, which asserted password=hunter2 survived) is now masks a form body and leaves one it cannot read untouched.

Docs. The CHANGELOG (Added and Security), doc/watchers/index.md, the skill SKILL.md (rule 8) and references/records.md (the requestBody and responseBody rows) now say JSON or form-encoded.

Gates. dart format --set-exit-if-changed shows 0 changed; flutter analyze reports no issues; flutter test --exclude-tags=integration --timeout=30s gives +379, all passed.

@kodizm please re-review.

@kodizm

kodizm Bot commented Sep 29, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

The fix for the round 1 finding looks correct, and the PR is still safe to merge.

Since my last review, 1d762e8 masks form-encoded bodies pair by pair, which settles the earlier Minor. I only reviewed that commit's changes to lib/src/telescope_redaction.dart, the two test files and the CHANGELOG. I did not reread the doc or skill edits.

What I checked:

  • _redactForm only runs when jsonDecode has failed.
  • The _formBody regex only accepts well-formed %XX escapes. That means Uri.decodeQueryComponent cannot throw, so recordHttp still never throws.
  • A value containing = (such as base64 padding) still matches, because only &, whitespace and a bare % are excluded from values.
  • latin1 comes from the dart:convert import that is already there.
  • Empty values still follow the _hides/_isEmpty rule.
  • When nothing matches, the same instance comes back, and the test checks this with same(...).
  • Form-encoded response bodies are also masked now, for example an OAuth token reply such as access_token=...&token_type=bearer.
  • None of the added lines contain em or en dashes.

Minor

lib/src/telescope_redaction.dart:173 (security): form keys are only compared as a whole, so a bracketed key like user[password]=hunter2 (or user%5Bpassword%5D=...) is still stored as sent. Nested JSON is masked at any depth, so the two formats now behave differently. If you want to cover this, you could match on the last bracketed part of the key, in a follow-up or with a note in "Not covered".

Tests

New cases in telescope_redaction_test.dart cover these:

  • the OAuth password grant
  • a key written in a different case
  • a percent-encoded key
  • an empty value
  • a form body with nothing to hide

telescope_store_test.dart now checks that the store masks a form body and still leaves a truncated JSON body alone.

CI

  • Lint & Test: success
  • codecov/patch: success (99.13% of diff hit, target 97.66%)
  • Auto-merge low-risk Dependabot PRs: skipped

user[password]=... and codes[]=... were compared whole and kept as sent,
while the same key nested in JSON is masked at any depth.

Co-Authored-By: Anılcan Çakır <anilcan.cakir@gmail.com>
@anilcancakir

Copy link
Copy Markdown
Member Author

Round 2 Minor, addressed in 20fec47.

telescope_redaction.dart:173, bracketed form keys compared whole (Minor): fixed. A form key now goes through _formLeaf before it is matched, so it addresses the same leaf a nested JSON key would: user[password] and user%5Bpassword%5D match password, and a list key codes[] matches codes. Only the value changes; the key keeps its original bytes.

Test. masks a form-encoded body pair by pair... gained the case user[password]=hunter2&user%5Bemail%5D=a&client_secret[]=x&user%5Bclient_secret%5D=y, which becomes user[password]=********&user%5Bemail%5D=a&client_secret[]=********&user%5Bclient_secret%5D=********. It failed before the change, with Actual equal to the input. The CHANGELOG Security entry and doc/watchers/index.md mention the bracketed keys.

Gates. flutter analyze reports no issues; flutter test --exclude-tags=integration --timeout=30s gives +379, all passed.

@kodizm please re-review.

@kodizm

kodizm Bot commented Sep 29, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

The fix in 20fec47 resolves my round 2 finding, and I found no new issues. This is still safe to merge.

This review covers only 20fec47: lib/src/telescope_redaction.dart, the new test case and the CHANGELOG line. I did not reread doc/watchers/index.md.

Round 2 flagged that bracketed form keys went unmasked. They are now masked: _formLeaf reduces a key to the name it points at, the same way a nested JSON key is matched. I checked _formLeaf against a range of keys:

  • It cannot throw. It only calls substring once it knows the key ends in ] and contains a [. That [ can never be the last character, so the slice is always valid.
  • Unusual keys are handled. [], x], a[b]c] and user[password][] all fall back to a name or reduce correctly. None of them crash.
  • The recordHttp guarantees still hold. recordHttp never throws on odd input. Only the value of a matching pair changes, and the key keeps its original bytes. A body with nothing to hide comes back as the same instance, and the same(...) assertion still covers that.

Tests

The new case in telescope_redaction_test.dart covers:

  • a literal key: user[password]
  • a percent-encoded key: user%5Bclient_secret%5D
  • a list key: client_secret[]
  • a non-secret bracketed key that stays unchanged: user%5Bemail%5D

CI

  • Lint & Test: success
  • codecov/patch: success (99.18% of diff hit, target 97.66%)
  • Auto-merge low-risk Dependabot PRs: skipped

@anilcancakir
anilcancakir merged commit e3d8bd6 into master Sep 29, 2026
4 checks passed
@anilcancakir
anilcancakir deleted the feat/redact-credentials branch September 29, 2026 17:40
@anilcancakir anilcancakir mentioned this pull request Sep 29, 2026
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.

1 participant