Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d960ea1
Sentry: scrub file paths / PII / secrets from events before send
lawrencecchen Jun 8, 2026
f7fbe98
Sentry scrubber: drop user identity fields and cookies wholesale
lawrencecchen Jun 8, 2026
74d9371
Sentry scrubber: cover exception mechanism data and debug-image paths
lawrencecchen Jun 8, 2026
5860017
Sentry scrubber: redact values under sensitive dictionary/header keys
lawrencecchen Jun 8, 2026
ecade1e
Sentry scrubber: scrub all free-text fields; preserve only grouping a…
lawrencecchen Jun 8, 2026
0a9d4fb
Sentry scrubber: split SentryRegexMatch into its own file
lawrencecchen Jun 8, 2026
4db5223
Sentry scrubber: key-aware tags + env-style secret-key assignments
lawrencecchen Jun 8, 2026
e031784
Sentry scrubber: scrub performance span description/data/tags
lawrencecchen Jun 8, 2026
61b15ce
Sentry scrubber: unify free-text assignment markers with sensitive-ke…
lawrencecchen Jun 8, 2026
2e9cbb5
Sentry: disable performance tracing to close the un-scrubbable root-t…
lawrencecchen Jun 8, 2026
62b4370
Sentry scrubber: scrub URL/object values that Sentry stringifies
lawrencecchen Jun 8, 2026
3634193
Sentry scrub: redact bare session=/sid= aliases (autoreview P1)
lawrencecchen Jun 8, 2026
e9404f4
Sentry scrub: redact raw Data values (autoreview P2)
lawrencecchen Jun 8, 2026
8090d1a
Sentry scrub: redact URL userinfo creds + exact home paths (autorevie…
lawrencecchen Jun 8, 2026
8b51416
Sentry scrub: adopt maintained denylists (sentry-python keys + relay …
lawrencecchen Jun 8, 2026
5cd67f1
Sentry scrub: redact full URL userinfo when password contains unencod…
lawrencecchen Jun 8, 2026
2b19936
Sentry scrub: redact full quoted secret values containing delimiters …
lawrencecchen Jun 8, 2026
47a4185
Sentry scrub: structured query-string redaction + bound home-path rep…
lawrencecchen Jun 8, 2026
2d96e1a
Sentry scrub: treat sensitive context NAME as a redaction boundary (a…
lawrencecchen Jun 8, 2026
4fa9ba7
Merge remote-tracking branch 'origin/main' into feat-sentry-scrub
lawrencecchen Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -296,14 +296,21 @@ private final class CLISocketSentryTelemetry {
options.environment = "production-cli"
#endif
options.debug = false
options.sendDefaultPii = true
// Defense-in-depth: keep default PII (user, IP, etc.) off the wire.
// The scrubber below additionally redacts any user fields that slip in.
options.sendDefaultPii = false
options.attachStacktrace = true
options.tracesSampleRate = 0.0
options.enableAppHangTracking = false
options.enableWatchdogTerminationTracking = false
options.enableAutoSessionTracking = false
options.enableCaptureFailedRequests = false
options.enableMetricKit = false
// Redact file paths, emails, and secrets from every outgoing event
// and breadcrumb before it leaves the device.
let scrubber = SentryEventScrubber()
options.beforeSend = { event in scrubber.scrub(event) }
options.beforeBreadcrumb = { breadcrumb in scrubber.scrub(breadcrumb) }
}
started = true
}
Expand Down
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 }

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.

P2 captureGroup guard doesn't enforce the documented 1-based contract

The doc-comment declares the parameter as a "1-based capture group index", but the guard index < result.numberOfRanges lets index = 0 through — which returns the text of the entire match rather than nil. While captureGroup(0) is never called today, the mismatch between contract and implementation is a maintenance hazard: a future caller following the docs would expect nil and receive unexpected match text. Adding an explicit lower-bound guard makes the 1-based promise machine-checked.

Suggested change
public func captureGroup(_ index: Int) -> String? {
guard index < result.numberOfRanges else { return nil }
public func captureGroup(_ index: Int) -> String? {
guard index >= 1, 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)
Comment thread
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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" || true

Repository: 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 . || true

Repository: 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 . || true

Repository: 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 || true

Repository: 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 || true

Repository: manaflow-ai/cmux

Length of output: 5524


Avoid try! in SentryRegexPattern’s public initializer

public init(_ pattern: String, ...) force-unwraps NSRegularExpression, so invalid runtime input can crash the process. Change the initializer to throws or make the initializer non-public/internal if external callers aren’t meant to construct arbitrary patterns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxFoundation/Sources/CmuxFoundation/SentryRegexPattern.swift`
around lines 51 - 54, The public initializer SentryRegexPattern.init currently
uses try! to create self.regex which can crash on invalid patterns; change the
API to either make the initializer throwing (declare public init(_ pattern:
String, options: NSRegularExpression.Options = [.caseInsensitive]) throws and
use try to construct NSRegularExpression then assign to regex, propagating the
error to callers) or, if external construction isn’t intended, reduce the
initializer’s access level (make it internal or fileprivate) and handle the
NSRegularExpression construction with a failable/guard path instead of try!;
update any callers of SentryRegexPattern.init to either catch/propagate the
thrown error or to use the new internal factory so the code no longer
force-unwraps NSRegularExpression.

Source: 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
}
}
204 changes: 204 additions & 0 deletions Packages/CmuxFoundation/Sources/CmuxFoundation/SentryScrubber.swift
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"']+/"#)
Comment thread
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+"#),

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.

P2 Badge Redact credentials embedded in URL user-info

When a Sentry field routed through scrub(_:) contains a URL with RFC 3986 user-info, such as https://alice:hunter2@example.com/path, none of these secret patterns match because there is no password= key or Authorization: prefix. This leaves credentials in event.request.url, breadcrumb data, or context URL strings even though this scrubber is intended to sanitize URLs before send; add URL-aware redaction for the user[:password]@host component.

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

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.

P2 Badge Route URL strings through query scrubbing

When structured Sentry payloads carry a URL as a string under a non-sensitive key such as breadcrumb data["url"] or mechanism.data["url"], this dispatch sends it only through the free-text regex scrubber. Exact denylist query aliases like _csrf, sid, su, or _vercel_jwt are intentionally handled by scrubQueryString(_:) and are not covered by the free-text assignment patterns, so https://host/callback?_csrf=opaque remains unsanitized outside event.request.url; detect URL-like strings (or URL-valued keys) and route their query component through the structured query scrubber before returning.

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

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.

P2 Badge Scrub URL values before passing dictionaries through

When Sentry payload dictionaries contain URL/NSURL objects rather than strings (for example NSError.userInfo entries such as a failing file/HTTP URL copied into mechanism.data), this switch falls through to default and leaves the object untouched, so Sentry can still serialize an absolute URL containing /Users/<name>/... or token query parameters. Please normalize URL values to their string form and run them through the same scrubber before returning.

Useful? React with 👍 / 👎.

}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
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)

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.

P1 Badge Redact values based on sensitive dictionary keys

Because the dictionary scrubber ignores key and only scans the value string, structured Sentry payloads like scope.setContext(value: ["token": "abcdef0123456789secretvalue"], key: ...), extra, or breadcrumb data still send the raw secret when the value does not independently match a prefixed token/JWT/API-key regex. This directly affects the new event scrubber paths that pass context/extra/data through this method, so key names such as token, password, or api_key need to force the corresponding value to <redacted-secret>.

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))
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

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.

P2 Badge Respect path boundaries when replacing the home directory

When the runtime home is a prefix of another username (for example homeDirectory == "/Users/ann" and a stack frame or NSError path contains /Users/anna/project), this unbounded string replacement rewrites it to /Users/<redacted>a/project before the generic /Users/<name>/ rule can run, leaving part of the other username in the outgoing Sentry payload. Match only the exact home path component (or let the generic user-home regex handle other /Users/.../ paths) so similarly prefixed usernames are fully redacted.

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
}
}
Loading
Loading