Repository navigation
Sentry: scrub file paths / PII / secrets from events before send (beforeSend/beforeBreadcrumb) #5598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sentry: scrub file paths / PII / secrets from events before send (beforeSend/beforeBreadcrumb) #5598
Changes from 1 commit
d960ea1
f7fbe98
74d9371
5860017
ecade1e
0a9d4fb
4db5223
e031784
61b15ce
2e9cbb5
62b4370
3634193
e9404f4
8090d1a
8b51416
5cd67f1
2b19936
47a4185
2d96e1a
4fa9ba7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| public import Foundation | ||
|
|
||
| /// A compiled regular expression with a value-typed, capture-aware replacement helper. | ||
| /// | ||
| /// ``SentryRegexPattern`` wraps `NSRegularExpression` so ``SentryScrubber`` can | ||
| /// describe its redaction rules as plain data and apply them with a closure | ||
| /// that sees each match. The closure receives a ``Match`` exposing the matched | ||
| /// text and its capture groups, which lets a rule keep a field prefix (such as | ||
| /// `token=`) while redacting only the sensitive value. | ||
| public struct SentryRegexPattern: Sendable { | ||
| /// A single regex match handed to a replacement closure. | ||
| public struct Match { | ||
| /// The full text the pattern matched. | ||
| public let value: String | ||
| /// The source string the match was found in (used to resolve capture ranges). | ||
| private let source: NSString | ||
| /// The underlying check result carrying capture-group ranges. | ||
| private let result: NSTextCheckingResult | ||
|
|
||
| /// Creates a match wrapper over an `NSRegularExpression` result. | ||
| fileprivate init(source: NSString, result: NSTextCheckingResult) { | ||
| self.source = source | ||
| self.result = result | ||
| self.value = source.substring(with: result.range) | ||
| } | ||
|
|
||
| /// Returns the substring captured by the given group, or `nil` when the group did not participate. | ||
| /// | ||
| /// - Parameter index: The 1-based capture group index. | ||
| /// - Returns: The captured substring, or `nil`. | ||
| public func captureGroup(_ index: Int) -> String? { | ||
| guard index < result.numberOfRanges else { return nil } | ||
| let range = result.range(at: index) | ||
| guard range.location != NSNotFound, range.length >= 0 else { return nil } | ||
| return source.substring(with: range) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
| } | ||
|
|
||
| /// The compiled expression. `NSRegularExpression` is documented thread-safe for matching. | ||
| private let regex: NSRegularExpression | ||
|
|
||
| /// Creates a pattern from a regex literal. | ||
| /// | ||
| /// Force-unwrapping is intentional: the patterns are compile-time constants | ||
| /// authored in this module, so an invalid pattern is a programmer error that | ||
| /// should fail loudly in tests, not silently disable a redaction rule. | ||
| /// | ||
| /// - Parameters: | ||
| /// - pattern: The ICU regular expression source. | ||
| /// - options: Matching options. Defaults to `.caseInsensitive`. | ||
| public init(_ pattern: String, options: NSRegularExpression.Options = [.caseInsensitive]) { | ||
| // swiftlint:disable:next force_try | ||
| self.regex = try! NSRegularExpression(pattern: pattern, options: options) | ||
| } | ||
|
Comment on lines
+23
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Show the relevant portion of the file with line numbers
FILE="Packages/CmuxFoundation/Sources/CmuxFoundation/SentryRegexPattern.swift"
if [ -f "$FILE" ]; then
echo "=== $FILE (lines 1-140) ==="
nl -ba "$FILE" | sed -n '1,140p'
else
echo "Missing: $FILE"
fi
# Find all initializations / uses
echo "=== rg: SentryRegexPattern init/usage ==="
rg -n "SentryRegexPattern\s*\(" -S . || true
rg -n "init\(_ pattern: String, options:" -S "$FILE" || trueRepository: manaflow-ai/cmux Length of output: 215 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="Packages/CmuxFoundation/Sources/CmuxFoundation/SentryRegexPattern.swift"
nl -ba "$FILE" | sed -n '1,200p'
echo "---- usages ----"
rg -n "SentryRegexPattern\s*\(" -S . || trueRepository: manaflow-ai/cmux Length of output: 103 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="Packages/CmuxFoundation/Sources/CmuxFoundation/SentryRegexPattern.swift"
nl -ba "$FILE" | sed -n '1,200p'
echo "---- usages ----"
rg -n "SentryRegexPattern\s*\(" -S . || trueRepository: manaflow-ai/cmux Length of output: 103 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="Packages/CmuxFoundation/Sources/CmuxFoundation/SentryRegexPattern.swift"
echo "=== file exists? ==="
ls -l "$FILE" || true
echo
echo "=== file contents (1-120) ==="
sed -n '1,120p' "$FILE" || true
echo
echo "=== try! usage in file ==="
rg -n "try!" "$FILE" || true
echo
echo "=== initializer signature in file ==="
rg -n "init\\(.*pattern: String" "$FILE" || true
echo
echo "=== usages across repo (max 200 lines) ==="
rg -n "SentryRegexPattern\\s*\\(" -S . | head -n 200 || trueRepository: manaflow-ai/cmux Length of output: 5524 🏁 Script executed: #!/bin/bash
set -euo pipefail
FILE="Packages/CmuxFoundation/Sources/CmuxFoundation/SentryRegexPattern.swift"
echo "=== file exists? ==="
ls -l "$FILE" || true
echo
echo "=== file contents (1-120) ==="
sed -n '1,120p' "$FILE" || true
echo
echo "=== try! usage in file ==="
rg -n "try!" "$FILE" || true
echo
echo "=== initializer signature in file ==="
rg -n "init\\(.*pattern: String" "$FILE" || true
echo
echo "=== usages across repo (max 200 lines) ==="
rg -n "SentryRegexPattern\\s*\\(" -S . | head -n 200 || trueRepository: manaflow-ai/cmux Length of output: 5524 Avoid
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| /// Replaces every match with the string returned by `replacement`. | ||
| /// | ||
| /// Matches are rewritten from the end backwards so earlier ranges stay valid | ||
| /// while later matches are spliced out. | ||
| /// | ||
| /// - Parameters: | ||
| /// - text: The string to scan. | ||
| /// - replacement: A closure returning the replacement for each ``Match``. | ||
| /// - Returns: The rewritten string, or `text` unchanged when nothing matched. | ||
| public func replace(in text: String, with replacement: (Match) -> String) -> String { | ||
| let source = text as NSString | ||
| let fullRange = NSRange(location: 0, length: source.length) | ||
| let matches = regex.matches(in: text, range: fullRange) | ||
| guard !matches.isEmpty else { return text } | ||
|
|
||
| var result = text | ||
| for checkingResult in matches.reversed() { | ||
| let match = Match(source: source, result: checkingResult) | ||
| guard let swiftRange = Range(checkingResult.range, in: result) else { continue } | ||
| result.replaceSubrange(swiftRange, with: replacement(match)) | ||
| } | ||
| return result | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| public import Foundation | ||
|
|
||
| /// Redacts privacy-sensitive content out of strings and nested values before | ||
| /// they leave the device in a crash/error report. | ||
| /// | ||
| /// ``SentryScrubber`` is a pure value transformer with no Sentry dependency: it | ||
| /// scrubs plain `String` values and recursively walks `[String: Any]` / | ||
| /// `[Any]` payloads. The thin glue that pulls fields off Sentry's `Event` and | ||
| /// `Breadcrumb` types and feeds them through this scrubber lives in the app and | ||
| /// CLI targets where the Sentry SDK is linked, so this type stays testable | ||
| /// without launching the app or linking Sentry. | ||
| /// | ||
| /// What it redacts, in priority order on every string: | ||
| /// - **Tokens / secrets** — `Bearer …`, `sk-…` style API keys, JWTs, `token=…` | ||
| /// / `password=…` assignments, AWS access key IDs. | ||
| /// - **Emails** — `user@example.com → <redacted-email>`. | ||
| /// - **Home / user paths** — both the injected home directory and any | ||
| /// `/Users/<name>/` (and `/home/<name>/`) prefix become a redacted-user | ||
| /// equivalent, so the local username never leaks. The generic `/Users/<name>/` | ||
| /// rule is what protects build-machine stack-frame paths, whose home dir does | ||
| /// not match the user's runtime ``NSHomeDirectory()``. | ||
| /// | ||
| /// It deliberately does **not** touch grouping-relevant fields (exception | ||
| /// `type`, fingerprint, frame `function` / `module` / `lineNumber`): the glue | ||
| /// only routes path/PII/secret-bearing fields through this scrubber. | ||
| /// | ||
| /// ```swift | ||
| /// let scrubber = SentryScrubber() | ||
| /// scrubber.scrub("opening /Users/alice/dev/secret with token=sk-abc123def456ghij") | ||
| /// // → "opening /Users/<redacted>/dev/secret with token=<redacted-secret>" | ||
| /// ``` | ||
| public struct SentryScrubber: Sendable { | ||
| /// The placeholder substituted for the redacted home directory leaf. | ||
| public static let redactedUser = "<redacted>" | ||
| /// The placeholder substituted for an email address. | ||
| public static let redactedEmail = "<redacted-email>" | ||
| /// The placeholder substituted for a token / secret / key / bearer / password match. | ||
| public static let redactedSecret = "<redacted-secret>" | ||
|
|
||
| /// Matches `/Users/<name>/` so the local username can be replaced regardless of the runtime home dir. | ||
| static let userHomePrefix = SentryRegexPattern(#"/Users/[^/\s"']+/"#) | ||
|
|
||
| /// Matches `/home/<name>/` for Linux-style paths that can appear in build-machine stack frames. | ||
| static let linuxHomePrefix = SentryRegexPattern(#"/home/[^/\s"']+/"#) | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| /// Matches an email address. | ||
| static let email = SentryRegexPattern(#"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}"#) | ||
|
|
||
| /// The ordered secret / token / key / bearer / password patterns. | ||
| /// | ||
| /// Patterns that capture a field prefix in group 1 (e.g. `token=`) keep that | ||
| /// prefix in the output so the redacted field stays legible; patterns with no | ||
| /// capture group replace the whole match. | ||
| static let secretPatterns: [SentryRegexPattern] = [ | ||
| // Bearer <token> | ||
| SentryRegexPattern(#"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"#), | ||
| // Authorization: <scheme> <token> (Basic / Digest / token / etc.) | ||
| SentryRegexPattern(#"(Authorization:\s*\w+\s+)\S+"#), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Sentry field routed through Useful? React with 👍 / 👎. |
||
| // key/token/secret/password/pwd/passwd/api[_-]?key/access[_-]?token = value | ||
| // in query strings, env-style, or JSON ("key":"value"). | ||
| SentryRegexPattern( | ||
| #"((?:access[_\-]?token|api[_\-]?key|secret|token|password|passwd|pwd)["']?\s*[:=]\s*["']?)[^\s"'&,}]+"# | ||
| ), | ||
| // Provider-style keys: sk-..., pk-..., ghp_..., xoxb-..., and similar prefixes. | ||
| SentryRegexPattern(#"\b(?:sk|pk|rk|ghp|gho|ghu|ghs|ghr|xox[baprs])[_\-][A-Za-z0-9_\-]{16,}"#), | ||
| // JSON Web Tokens: three base64url segments separated by dots. | ||
| SentryRegexPattern( | ||
| #"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"#, | ||
| options: [] | ||
| ), | ||
| // AWS access key IDs. | ||
| SentryRegexPattern(#"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"#, options: []), | ||
| ] | ||
|
|
||
| /// The absolute home directory whose prefix is replaced wherever it appears. | ||
| private let homeDirectory: String | ||
|
|
||
| /// Creates a scrubber bound to a home directory. | ||
| /// | ||
| /// The default reads the current process home. Tests inject a fixed value so | ||
| /// the scrubber never depends on the developer's real home. The generic | ||
| /// `/Users/<name>/` rule redacts any username independent of this value, so | ||
| /// build-machine stack-frame paths (whose home differs from the runtime one) | ||
| /// are still covered. | ||
| /// | ||
| /// - Parameter homeDirectory: Absolute path replaced wherever it is found. Defaults to ``NSHomeDirectory()``. | ||
| public init(homeDirectory: String = NSHomeDirectory()) { | ||
| self.homeDirectory = homeDirectory | ||
| } | ||
|
|
||
| /// Returns a copy of `text` with secrets, emails, and home/user paths redacted. | ||
| /// | ||
| /// Redaction order is secrets → emails → paths so a token embedded in a path | ||
| /// or after an email is still caught. Returns the input unchanged when it | ||
| /// contains nothing sensitive. | ||
| /// | ||
| /// - Parameter text: The string to scrub. | ||
| /// - Returns: The scrubbed string. | ||
| public func scrub(_ text: String) -> String { | ||
| guard !text.isEmpty else { return text } | ||
| var result = text | ||
| result = redactSecrets(in: result) | ||
| result = redactEmails(in: result) | ||
| result = redactPaths(in: result) | ||
| return result | ||
| } | ||
|
|
||
| /// Returns `text` scrubbed, or `nil` when the input is `nil`. | ||
| /// | ||
| /// - Parameter text: The optional string to scrub. | ||
| /// - Returns: The scrubbed string, or `nil`. | ||
| public func scrub(optional text: String?) -> String? { | ||
| guard let text else { return nil } | ||
| return scrub(text) | ||
| } | ||
|
|
||
| /// Recursively scrubs every string found inside a JSON-like value tree. | ||
| /// | ||
| /// Strings are scrubbed; dictionaries and arrays are walked; all other | ||
| /// values (numbers, booleans, dates, `NSNull`) pass through untouched. | ||
| /// | ||
| /// - Parameter value: A `String`, `[String: Any]`, `[Any]`, or scalar. | ||
| /// - Returns: The value with all nested strings scrubbed. | ||
| public func scrub(value: Any) -> Any { | ||
| switch value { | ||
| case let string as String: | ||
| return scrub(string) | ||
|
Comment on lines
+200
to
+201
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When structured Sentry payloads carry a URL as a string under a non-sensitive key such as breadcrumb Useful? React with 👍 / 👎. |
||
| case let dictionary as [String: Any]: | ||
| return scrub(dictionary: dictionary) | ||
| case let array as [Any]: | ||
| return array.map { scrub(value: $0) } | ||
| default: | ||
| return value | ||
|
Comment on lines
+202
to
+208
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Sentry payload dictionaries contain Useful? React with 👍 / 👎. |
||
| } | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| } | ||
|
|
||
| /// Recursively scrubs every string value inside a dictionary. | ||
| /// | ||
| /// - Parameter dictionary: The dictionary whose values are scrubbed. | ||
| /// - Returns: A new dictionary with the same keys and scrubbed values. | ||
| public func scrub(dictionary: [String: Any]) -> [String: Any] { | ||
| var output = [String: Any](minimumCapacity: dictionary.count) | ||
| for (key, value) in dictionary { | ||
| output[key] = scrub(value: value) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because the dictionary scrubber ignores Useful? React with 👍 / 👎. |
||
| } | ||
| return output | ||
| } | ||
|
|
||
| // MARK: - Paths | ||
|
|
||
| /// Replaces the injected home directory and any `/Users/<name>/` or | ||
| /// `/home/<name>/` prefix with a redacted-user equivalent. | ||
| private func redactPaths(in text: String) -> String { | ||
| var result = text | ||
| if !homeDirectory.isEmpty, homeDirectory != "/" { | ||
| result = result.replacingOccurrences(of: homeDirectory, with: Self.redactedHomePath(for: homeDirectory)) | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the runtime home is a prefix of another username (for example Useful? React with 👍 / 👎. |
||
| } | ||
| result = Self.userHomePrefix.replace(in: result) { _ in "/Users/\(Self.redactedUser)/" } | ||
| result = Self.linuxHomePrefix.replace(in: result) { _ in "/home/\(Self.redactedUser)/" } | ||
| return result | ||
| } | ||
|
|
||
| /// Returns the redacted form of an absolute home directory. | ||
| /// | ||
| /// Replaces the trailing user component of a `/Users/<name>` or | ||
| /// `/home/<name>` home path with `<redacted>`, preserving the rest of the | ||
| /// path shape. Paths that do not fit that shape are replaced wholesale. | ||
| /// | ||
| /// - Parameter homeDirectory: The absolute home directory path. | ||
| /// - Returns: The path with its user component redacted. | ||
| static func redactedHomePath(for homeDirectory: String) -> String { | ||
| let components = homeDirectory.split(separator: "/", omittingEmptySubsequences: false) | ||
| // ["", "Users", "alice"] for "/Users/alice" | ||
| if components.count >= 3, components[1] == "Users" || components[1] == "home" { | ||
| return "/\(components[1])/\(redactedUser)" | ||
| } | ||
| return "/\(redactedUser)" | ||
| } | ||
|
|
||
| // MARK: - Emails | ||
|
|
||
| /// Replaces email addresses with ``redactedEmail``. | ||
| private func redactEmails(in text: String) -> String { | ||
| Self.email.replace(in: text) { _ in Self.redactedEmail } | ||
| } | ||
|
|
||
| // MARK: - Secrets | ||
|
|
||
| /// Replaces token / secret / key / bearer / password patterns with ``redactedSecret``. | ||
| private func redactSecrets(in text: String) -> String { | ||
| var result = text | ||
| for pattern in Self.secretPatterns { | ||
| result = pattern.replace(in: result) { match in | ||
| // Patterns with a captured prefix group (e.g. "token=") keep the | ||
| // prefix and redact only the value, so the field stays legible. | ||
| if let prefix = match.captureGroup(1) { | ||
| return "\(prefix)\(Self.redactedSecret)" | ||
| } | ||
| return Self.redactedSecret | ||
| } | ||
| } | ||
| return result | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
captureGroupguard doesn't enforce the documented 1-based contractThe doc-comment declares the parameter as a "1-based capture group index", but the guard
index < result.numberOfRangesletsindex = 0through — which returns the text of the entire match rather thannil. WhilecaptureGroup(0)is never called today, the mismatch between contract and implementation is a maintenance hazard: a future caller following the docs would expectniland receive unexpected match text. Adding an explicit lower-bound guard makes the 1-based promise machine-checked.