LogRedactor.swift 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. // LoopFollow
  2. // LogRedactor.swift
  3. import CryptoKit
  4. import Foundation
  5. /// Helpers for masking secrets before they hit the log file. The "share logs"
  6. /// feature exposes the on-disk log to the user, so anything sensitive that
  7. /// flows through `LogManager.log` must be reduced to a non-recoverable form
  8. /// while keeping enough signal (short suffix, host, fingerprint) to correlate
  9. /// events during debugging.
  10. enum LogRedactor {
  11. /// Last `keep` characters of `secret`, prefixed with `…`. Matches the
  12. /// existing `.suffix(8)` convention used in `LiveActivityManager`.
  13. static func tail(_ secret: String, keep: Int = 8) -> String {
  14. if secret.isEmpty { return "(empty)" }
  15. if secret.count <= keep { return "(redacted)" }
  16. return "…\(secret.suffix(keep))"
  17. }
  18. /// First `keep` characters of `secret`, suffixed with `…`. Matches the
  19. /// existing `.prefix(8)` convention used in `LoopAPNSService`.
  20. static func head(_ secret: String, keep: Int = 8) -> String {
  21. if secret.isEmpty { return "(empty)" }
  22. if secret.count <= keep { return "(redacted)" }
  23. return "\(secret.prefix(keep))…"
  24. }
  25. /// Known managed-Nightscout host suffixes. When a URL's host ends in one
  26. /// of these, the leading subdomain (which identifies the user) is masked
  27. /// and the suffix is kept so engineers can tell which platform the user
  28. /// is on. Anything else is treated as self-hosted and reduced to the TLD.
  29. private static let knownHostSuffixes: [String] = [
  30. "nightscoutpro.com",
  31. "10be.de",
  32. "herokuapp.com",
  33. ]
  34. /// Keep scheme + a redacted host hint, drop path and query. The Nightscout
  35. /// token rides in `?token=` and the host itself identifies the user when
  36. /// they're on a managed platform, so we mask the subdomain and keep only
  37. /// the platform suffix (or just the TLD for self-hosted setups).
  38. static func url(_ raw: String) -> String {
  39. if raw.isEmpty { return "(empty)" }
  40. if let u = URL(string: raw), let host = u.host {
  41. let scheme = u.scheme.map { "\($0)://" } ?? ""
  42. return "\(scheme)\(maskHost(host))/…"
  43. }
  44. return "(redacted)"
  45. }
  46. private static func maskHost(_ host: String) -> String {
  47. // IPv4 / IPv6 / bracketed — drop entirely.
  48. if host.range(of: "^\\d+\\.\\d+\\.\\d+\\.\\d+$", options: .regularExpression) != nil { return "***" }
  49. if host.contains(":") || host.hasPrefix("[") { return "***" }
  50. let lower = host.lowercased()
  51. for suffix in knownHostSuffixes {
  52. if lower == suffix || lower.hasSuffix("." + suffix) {
  53. return "***." + suffix
  54. }
  55. }
  56. let parts = host.split(separator: ".", omittingEmptySubsequences: false)
  57. if parts.count >= 2, let tld = parts.last, !tld.isEmpty {
  58. return "***." + String(tld)
  59. }
  60. return "***"
  61. }
  62. /// Apple Developer Key ID — 10-char uppercase alphanumeric. Reveals
  63. /// last 2 chars only.
  64. static func keyId(_ keyId: String) -> String {
  65. if keyId.isEmpty { return "(empty)" }
  66. if keyId.count <= 2 { return "(redacted)" }
  67. return "…\(keyId.suffix(2))"
  68. }
  69. /// Apple Team ID — 10-char uppercase alphanumeric. Reveals last 2 chars.
  70. static func teamId(_ teamId: String) -> String {
  71. keyId(teamId)
  72. }
  73. /// App bundle id ("com.example.MyApp"). Mask the middle component(s) but
  74. /// keep the leading TLD and trailing app name so suffixes like
  75. /// `.watchkitapp` or `.push-type.liveactivity` remain visible.
  76. static func bundleId(_ id: String) -> String {
  77. if id.isEmpty { return "(empty)" }
  78. let parts = id.split(separator: ".", omittingEmptySubsequences: false)
  79. guard parts.count >= 3 else { return "(redacted)" }
  80. var masked = [String]()
  81. masked.append(String(parts[0]))
  82. for _ in 1 ..< parts.count - 1 {
  83. masked.append("***")
  84. }
  85. masked.append(String(parts[parts.count - 1]))
  86. return masked.joined(separator: ".")
  87. }
  88. /// Username (Dexcom Share, etc.). Preserves first character and any
  89. /// `@domain` suffix shape so engineers can tell email-shaped from not.
  90. static func username(_ name: String) -> String {
  91. if name.isEmpty { return "(empty)" }
  92. if name.contains("@") {
  93. let parts = name.split(separator: "@", maxSplits: 1).map(String.init)
  94. let local = parts[0]
  95. let domain = parts.count > 1 ? parts[1] : ""
  96. let firstLocal = local.first.map(String.init) ?? "?"
  97. let firstDomain = domain.first.map(String.init) ?? "?"
  98. return "\(firstLocal)***@\(firstDomain)***"
  99. }
  100. let first = name.first.map(String.init) ?? "?"
  101. return "\(first)***"
  102. }
  103. /// Sweep an arbitrary message string for high-confidence secret shapes.
  104. /// Idempotent. Run by `LogManager.log` on every line before write.
  105. static func sweep(_ message: String) -> String {
  106. var out = message
  107. out = redactPEM(out)
  108. out = redactTokenQuery(out)
  109. out = redactJWT(out)
  110. return out
  111. }
  112. /// Replace any `?token=…` or `&token=…` value with `***` (case-insensitive).
  113. private static func redactTokenQuery(_ s: String) -> String {
  114. guard let regex = try? NSRegularExpression(
  115. pattern: "([?&]token=)[^&\\s\"'<>]+",
  116. options: [.caseInsensitive]
  117. ) else { return s }
  118. let range = NSRange(s.startIndex ..< s.endIndex, in: s)
  119. return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "$1***")
  120. }
  121. /// Collapse the body of a PEM PRIVATE KEY block to `(redacted)`.
  122. private static func redactPEM(_ s: String) -> String {
  123. guard let regex = try? NSRegularExpression(
  124. pattern: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----",
  125. options: []
  126. ) else { return s }
  127. let range = NSRange(s.startIndex ..< s.endIndex, in: s)
  128. return regex.stringByReplacingMatches(
  129. in: s, options: [], range: range,
  130. withTemplate: "-----BEGIN PRIVATE KEY----- (redacted) -----END PRIVATE KEY-----"
  131. )
  132. }
  133. /// Collapse the middle segment of a JWT (`ey…\.ey…\.…`).
  134. private static func redactJWT(_ s: String) -> String {
  135. guard let regex = try? NSRegularExpression(
  136. pattern: "ey[A-Za-z0-9_-]{8,}\\.ey[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}",
  137. options: []
  138. ) else { return s }
  139. let range = NSRange(s.startIndex ..< s.endIndex, in: s)
  140. return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "ey…<jwt>…")
  141. }
  142. /// Non-reversible fingerprint for opaque blobs we can't safely log
  143. /// (settings JSON, scanned QR code contents, etc.).
  144. static func fingerprint(_ data: Data) -> String {
  145. let digest = SHA256.hash(data: data)
  146. let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
  147. return "\(data.count) bytes, sha256=\(hex.prefix(8))…"
  148. }
  149. static func fingerprint(_ string: String) -> String {
  150. fingerprint(Data(string.utf8))
  151. }
  152. }