sentry: turn off send_default_pii, drop the scrubber
The scrubber watched the errors, but transactions carried cards;
now no request body leaves, so there is nothing left to guard.
A three-line lambda strips the tailnet names from every send,
and the regex and its test have reached their end.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index 6b3d61b..7513449 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,9 +95,10 @@ not a fixture; edit a `.vcf` to change what the replay serves.
`log/unhandled` in the fixture layout. When implementing something a
client asked for, look there first — and strip the identifying headers
before promoting a capture into `test/fixtures`.
-- Anything added to a request body must be assumed to reach Sentry. Card
- content is redacted by `ProTacts::SentryScrubber`; a new kind of sensitive
- field would need its own rule there.
+- No request body reaches Sentry: `send_default_pii` is off in
+ `config.ru`, because card content never leaves the machine. The URL,
+ headers, exception messages, and `capture_message` text still do, so
+ keep card content out of all four.
- Don't hide errors. A `rescue` that swallows an exception and returns a
fallback value reads as defensive but is actually the opposite: it turns
a bug or corrupt data into a silently wrong screen instead of a loud one.
diff --git a/README.md b/README.md
index 5ea1f28..3c768de 100644
--- a/README.md
+++ b/README.md
@@ -76,9 +76,8 @@ same layout as `test/fixtures/macos-exchange`. A client asking for
something unimplemented therefore leaves behind enough to implement it, and
the capture can be promoted to a fixture by copying it and stripping the
identifying headers.
-Sentry gets the request body too, minus any card content, which
-`ProTacts::SentryScrubber` redacts on the way out — hrefs and tailnet IPs
-are not secrets, but the cards themselves never leave the machine.
+Sentry gets no request body at all, because `send_default_pii` is off, so
+the cards themselves never leave the machine.
## The minimal set macOS Contacts needs
diff --git a/config.ru b/config.ru
index 15c6682..44258b6 100644
--- a/config.ru
+++ b/config.ru
@@ -7,7 +7,6 @@ require "sentry-ruby"
$LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
require "pro_tacts/web"
-require "pro_tacts/sentry_scrubber"
require "pro_tacts/store"
config = ProTacts.config
@@ -26,21 +25,26 @@ Sentry.init do |sentry|
sentry.breadcrumbs_logger = [:sentry_logger, :http_logger]
- # On: request bodies are worth having on a 404, and nothing else this
- # sends is sensitive. Hrefs carry opaque contact UIDs, not names, and the
- # IPs are tailnet addresses.
- sentry.send_default_pii = true
-
- # The one thing that must not leave the machine is card content, which a
- # write path would put directly in a PUT body. Full bodies are kept
- # locally either way, see ProTacts::UnhandledRequests.
- sentry.before_send = ProTacts::SentryScrubber
+ # Off, so no request body, query string, or cookie is sent: a write's
+ # body is a card, and card content never leaves the machine. Sentry
+ # says that something went wrong; the full exchange stays local
+ # (docs/plans/2026-09-13-dav-observability.md).
+ sentry.send_default_pii = false
+
+ # Headers are sent regardless, on transactions as well as errors, and
+ # the tailnet identity is not worth sending.
+ drop_identity = lambda do |event, _hint|
+ event.request&.headers&.reject! { |name, _| name.start_with?("Tailscale-User-") }
+ event
+ end
+ sentry.before_send = drop_identity
+ sentry.before_send_transaction = drop_identity
# The debug log is a local-only record of full exchanges, bodies and
# all. Sentry's sentry_logger hook would otherwise turn every one of
- # its lines into a breadcrumb, and breadcrumbs reach Sentry without
- # passing the scrubber above. Keyed on the progname DebugLogger#write
- # passes with each line.
+ # its lines into a breadcrumb, and send_default_pii has no say over
+ # breadcrumbs. Keyed on the progname DebugLogger#write passes with
+ # each line.
sentry.exclude_loggers = [ProTacts::DebugLogger::PROGNAME]
sentry.traces_sample_rate = 1.0
diff --git a/lib/pro_tacts/debug_logger.rb b/lib/pro_tacts/debug_logger.rb
index 211ea64..faefbcd 100644
--- a/lib/pro_tacts/debug_logger.rb
+++ b/lib/pro_tacts/debug_logger.rb
@@ -19,7 +19,7 @@ module ProTacts
# breadcrumb hook, which keys its exclude list on the progname
# (see config.ru). Every line here is a local-only record of a
# full exchange — bodies included — so none of it may ride to
- # Sentry as a breadcrumb, where SentryScrubber would never see it.
+ # Sentry as a breadcrumb, which send_default_pii does not govern.
PROGNAME = "ProTacts::DebugLogger" #: String
# Builds the Logger the middleware writes to: appended and unbuffered
diff --git a/lib/pro_tacts/sentry_scrubber.rb b/lib/pro_tacts/sentry_scrubber.rb
deleted file mode 100644
index 053c8ad..0000000
--- a/lib/pro_tacts/sentry_scrubber.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-module ProTacts
- # Redacts card content from request bodies on their way to Sentry.
- #
- # Everything else in a request is fine to send: hrefs carry opaque contact
- # UIDs, and the IPs are tailnet addresses. Card content is the exception,
- # and a write path would put it straight in a PUT body.
- #
- # Sentry truncates bodies at 16KB (RequestInterface::MAX_BODY_LIMIT), so a
- # card can arrive with its BEGIN and no END. The trailing alternation
- # handles that, and is load-bearing for the ordinary case too: (?~) is
- # greedy up to the longest run with no complete END:VCARD in it, which
- # runs into the marker and stops at END:VCAR. Requiring END:VCARD or the
- # end of the body is what backtracks it onto the real boundary.
- module SentryScrubber
- VCARD = /BEGIN:VCARD(?~END:VCARD)(?:END:VCARD|\z)/mi
- VCARD_CONTENT_TYPE = %r{\Atext/vcard}i
- REDACTED = "[vcard redacted]".freeze #: String
-
- # Sentry discards the event unless a Sentry::ErrorEvent comes back, so
- # this returns the event it was handed either way.
- #: (untyped event, ?untyped hint) -> untyped
- def self.call(event, _hint = nil)
- request = event.request if event.respond_to?(:request)
- return event unless request
-
- request.data =
- if vcard_body?(request)
- REDACTED
- else
- redact(request.data)
- end
-
- event
- end
-
- # A card PUT is a card whether or not it parses, so the content type is
- # enough on its own.
- #: (untyped request) -> bool
- def self.vcard_body?(request)
- headers = request.headers
- return false unless headers.respond_to?(:[])
-
- headers["Content-Type"].to_s.match?(VCARD_CONTENT_TYPE)
- end
-
- # Form bodies arrive as a params hash rather than a string.
- #: (untyped data) -> untyped
- def self.redact(data)
- case data
- when String then data.gsub(VCARD, REDACTED)
- when Hash then data.transform_values { redact(it) }
- when Array then data.map { redact(it) }
- else data
- end
- end
- end
-end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index b0d08ae..46f5743 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -940,7 +940,7 @@ module ProTacts
# The arrival report: a BDAY the model does not take is unexpected
# input, and storing it verbatim would be the last anyone heard of
# it. The message carries no card content
- # (ProTacts::SentryScrubber's line); the value stays on the
+ # (the line config.ru draws for Sentry); the value stays on the
# machine, where the admin view shows it raw.
#: () -> void
def report_unrecognized_bday_line
@@ -1222,7 +1222,7 @@ module ProTacts
# does not explain, and calling either the edit would be a guess.
# The card is stored as it arrived and the line is news, the same
# bargain report_unrecognized_bday_line makes, and carries no card
- # content for the same reason (ProTacts::SentryScrubber).
+ # content for the same reason (config.ru).
#: (Integer count) -> void
def report_ambiguous_inherited_lines(count)
return if count.zero?
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 2d65247..58febed 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -47,8 +47,8 @@ module ProTacts
end
end
- # RewindableInput allows us to read the request body for Sentry logging
- # and then rewind it so the application can still access it.
+ # RewindableInput lets the capture middlewares below read the request
+ # body and rewind it for the application.
use Rack::RewindableInput::Middleware
use Sentry::Rack::CaptureExceptions
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
index a03628c..7669912 100644
--- a/lib/pro_tacts/web/dav.rb
+++ b/lib/pro_tacts/web/dav.rb
@@ -510,7 +510,7 @@ module ProTacts
# reads every contact on every page load — and one bad card must
# not alert once per page view. An arrival is the event worth a
# message, and a PUT is the only arrival there is. The message
- # carries no card content (ProTacts::SentryScrubber's line); the
+ # carries no card content (the line config.ru draws for Sentry); the
# admin view shows the card raw.
#: (VCard vcard) -> void
def report_broken_assumptions(vcard)
diff --git a/lib/sequel/extensions/sole.rb b/lib/sequel/extensions/sole.rb
index d810cbb..0fc9066 100644
--- a/lib/sequel/extensions/sole.rb
+++ b/lib/sequel/extensions/sole.rb
@@ -34,7 +34,7 @@ module Sequel
# The table rather than the SQL: a filter can carry card content,
# and an exception message is one of the things that reaches
- # Sentry. See ProTacts::SentryScrubber.
+ # Sentry.
raise TooManyRows, "more than one row in #{first_source}" if rows.length > 1
raise Sequel::NoMatchingRow.new(self) if rows.empty?
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
index 919beef..2dba630 100644
--- a/sig/gems/sentry.rbs
+++ b/sig/gems/sentry.rbs
@@ -2,10 +2,6 @@
# exception-capturing middleware web.rb mounts, the 404 and arrival
# messages web.rb and store.rb send, and the photo decode refusal
# contact.rb captures.
-#
-# Events are left untyped. SentryScrubber walks whatever before_send
-# hands it, feature-detecting as it goes, and typing that shape would
-# claim more about the sentry-ruby internals than the scrubber assumes.
module Sentry
def self.capture_message: (String message, ?level: Symbol) -> untyped
diff --git a/test/pro_tacts/test_sentry_scrubber.rb b/test/pro_tacts/test_sentry_scrubber.rb
deleted file mode 100644
index 5c53688..0000000
--- a/test/pro_tacts/test_sentry_scrubber.rb
+++ /dev/null
@@ -1,133 +0,0 @@
-require_relative "../test_helper"
-require "sentry-ruby"
-
-require "pro_tacts/sentry_scrubber"
-
-class SentryScrubberTest < Minitest::Test
- CARD = <<~VCARD.chomp
- BEGIN:VCARD
- VERSION:3.0
- UID:AB12C345
- FN:Real Person
- TEL:+1-555-1234
- END:VCARD
- VCARD
-
- # Stands in for a Sentry event: the scrubber only reaches for #request,
- # and the request only needs a body and headers.
- Event = Struct.new(:request)
- Request = Struct.new(:data, :headers)
-
- def scrub(data, headers = {})
- event = Event.new(Request.new(data, headers))
- ProTacts::SentryScrubber.call(event, nil)
- event.request.data
- end
-
- def test_a_card_is_redacted
- refute_includes scrub(CARD), "Real Person"
- end
-
- def test_a_card_is_replaced_rather_than_emptied
- assert_includes scrub(CARD), "vcard redacted"
- end
-
- # The redaction has to stop at END:VCARD, not run to the end of the body,
- # or a card anywhere in a request would take the rest of it along.
- def test_a_card_embedded_in_xml_is_redacted_without_eating_what_follows
- scrubbed = scrub("<address-data>#{CARD}</address-data><href>/keep/me</href>")
-
- refute_includes scrubbed, "Real Person"
- assert_includes scrubbed, "<address-data>"
- assert_includes scrubbed, "</address-data>"
- assert_includes scrubbed, "<href>/keep/me</href>"
- end
-
- # (?~) is greedy to the longest run holding no complete END:VCARD, which
- # runs into the marker and stops at END:VCAR. Without something forcing it
- # back onto the boundary a stray "D" survives, so assert the exact result
- # rather than just the absence of card content.
- def test_no_fragment_of_the_end_marker_survives
- assert_equal "<d>#{ProTacts::SentryScrubber::REDACTED}</d>TAIL", scrub("<d>#{CARD}</d>TAIL")
- end
-
- def test_content_between_two_cards_survives
- scrubbed = scrub("#{CARD}\nMIDDLE\n#{CARD.sub('Real Person', 'Other Person')}\nTRAILING")
-
- refute_includes scrubbed, "Real Person"
- refute_includes scrubbed, "Other Person"
- assert_includes scrubbed, "MIDDLE"
- assert_includes scrubbed, "TRAILING"
- end
-
- # Only the unterminated card runs to the end; the complete one before it
- # still stops at its own END:VCARD.
- def test_a_complete_card_before_a_truncated_one_stops_at_its_end
- scrubbed = scrub("#{CARD}\nMIDDLE\nBEGIN:VCARD\nFN:Trunc")
-
- refute_includes scrubbed, "Real Person"
- refute_includes scrubbed, "Trunc"
- assert_includes scrubbed, "MIDDLE"
- end
-
- def test_several_cards_are_all_redacted
- scrubbed = scrub("#{CARD}\n#{CARD.sub('Real Person', 'Other Person')}")
-
- refute_includes scrubbed, "Real Person"
- refute_includes scrubbed, "Other Person"
- end
-
- # Sentry truncates at 16KB, so a card can arrive without its END line.
- def test_a_truncated_card_is_redacted
- refute_includes scrub("BEGIN:VCARD\nVERSION:3.0\nFN:Real Pers"), "Real Pers"
- end
-
- def test_lowercase_markers_are_redacted
- refute_includes scrub(CARD.downcase), "real person"
- end
-
- # The href in a multiget names a contact by UID, which is not a secret and
- # is the useful part of a 404 report.
- def test_hrefs_are_left_alone
- body = "<href>/dav/addressbook/AB12C345-6789.vcf</href>"
-
- assert_equal body, scrub(body)
- end
-
- def test_a_vcard_content_type_redacts_the_whole_body
- scrubbed = scrub("garbled but still a card", "Content-Type" => "text/vcard; charset=utf-8")
-
- assert_equal ProTacts::SentryScrubber::REDACTED, scrubbed
- end
-
- def test_form_bodies_are_walked
- scrubbed = scrub({ "card" => CARD, "id" => "AB12C345" })
-
- refute_includes scrubbed.fetch("card"), "Real Person"
- assert_equal "AB12C345", scrubbed.fetch("id")
- end
-
- def test_a_nil_body_is_left_alone
- assert_nil scrub(nil)
- end
-
- def test_an_event_without_a_request_is_returned_unchanged
- event = Event.new(nil)
-
- assert_same event, ProTacts::SentryScrubber.call(event, nil)
- end
-
- # Guards the field names against a sentry-ruby upgrade: this is the real
- # interface Sentry builds from a Rack env, not a stand-in.
- def test_it_scrubs_a_real_sentry_request_interface
- env = Rack::MockRequest.env_for("/dav/addressbook/", method: "PUT", input: CARD)
- env["CONTENT_TYPE"] = "text/xml"
- request = Sentry::RequestInterface.new(env: env, send_default_pii: true, rack_env_whitelist: [])
-
- assert_includes request.data, "Real Person", "precondition: Sentry captured the body"
-
- ProTacts::SentryScrubber.call(Event.new(request), nil)
-
- refute_includes request.data, "Real Person"
- end
-end