// LoopFollow // LogRedactor.swift import CryptoKit import Foundation /// Helpers for masking secrets before they hit the log file. The "share logs" /// feature exposes the on-disk log to the user, so anything sensitive that /// flows through `LogManager.log` must be reduced to a non-recoverable form /// while keeping enough signal (short suffix, host, fingerprint) to correlate /// events during debugging. enum LogRedactor { /// Last `keep` characters of `secret`, prefixed with `…`. Matches the /// existing `.suffix(8)` convention used in `LiveActivityManager`. static func tail(_ secret: String, keep: Int = 8) -> String { if secret.isEmpty { return "(empty)" } if secret.count <= keep { return "(redacted)" } return "…\(secret.suffix(keep))" } /// First `keep` characters of `secret`, suffixed with `…`. Matches the /// existing `.prefix(8)` convention used in `LoopAPNSService`. static func head(_ secret: String, keep: Int = 8) -> String { if secret.isEmpty { return "(empty)" } if secret.count <= keep { return "(redacted)" } return "\(secret.prefix(keep))…" } /// Known managed-Nightscout host suffixes. When a URL's host ends in one /// of these, the leading subdomain (which identifies the user) is masked /// and the suffix is kept so engineers can tell which platform the user /// is on. Anything else is treated as self-hosted and reduced to the TLD. private static let knownHostSuffixes: [String] = [ "nightscoutpro.com", "10be.de", "herokuapp.com", ] /// Keep scheme + a redacted host hint, drop path and query. The Nightscout /// token rides in `?token=` and the host itself identifies the user when /// they're on a managed platform, so we mask the subdomain and keep only /// the platform suffix (or just the TLD for self-hosted setups). static func url(_ raw: String) -> String { if raw.isEmpty { return "(empty)" } if let u = URL(string: raw), let host = u.host { let scheme = u.scheme.map { "\($0)://" } ?? "" return "\(scheme)\(maskHost(host))/…" } return "(redacted)" } private static func maskHost(_ host: String) -> String { // IPv4 / IPv6 / bracketed — drop entirely. if host.range(of: "^\\d+\\.\\d+\\.\\d+\\.\\d+$", options: .regularExpression) != nil { return "***" } if host.contains(":") || host.hasPrefix("[") { return "***" } let lower = host.lowercased() for suffix in knownHostSuffixes { if lower == suffix || lower.hasSuffix("." + suffix) { return "***." + suffix } } let parts = host.split(separator: ".", omittingEmptySubsequences: false) if parts.count >= 2, let tld = parts.last, !tld.isEmpty { return "***." + String(tld) } return "***" } /// Apple Developer Key ID — 10-char uppercase alphanumeric. Reveals /// last 2 chars only. static func keyId(_ keyId: String) -> String { if keyId.isEmpty { return "(empty)" } if keyId.count <= 2 { return "(redacted)" } return "…\(keyId.suffix(2))" } /// Apple Team ID — 10-char uppercase alphanumeric. Reveals last 2 chars. static func teamId(_ teamId: String) -> String { keyId(teamId) } /// App bundle id ("com.example.MyApp"). Mask the middle component(s) but /// keep the leading TLD and trailing app name so suffixes like /// `.watchkitapp` or `.push-type.liveactivity` remain visible. static func bundleId(_ id: String) -> String { if id.isEmpty { return "(empty)" } let parts = id.split(separator: ".", omittingEmptySubsequences: false) guard parts.count >= 3 else { return "(redacted)" } var masked = [String]() masked.append(String(parts[0])) for _ in 1 ..< parts.count - 1 { masked.append("***") } masked.append(String(parts[parts.count - 1])) return masked.joined(separator: ".") } /// Username (Dexcom Share, etc.). Preserves first character and any /// `@domain` suffix shape so engineers can tell email-shaped from not. static func username(_ name: String) -> String { if name.isEmpty { return "(empty)" } if name.contains("@") { let parts = name.split(separator: "@", maxSplits: 1).map(String.init) let local = parts[0] let domain = parts.count > 1 ? parts[1] : "" let firstLocal = local.first.map(String.init) ?? "?" let firstDomain = domain.first.map(String.init) ?? "?" return "\(firstLocal)***@\(firstDomain)***" } let first = name.first.map(String.init) ?? "?" return "\(first)***" } /// Sweep an arbitrary message string for high-confidence secret shapes. /// Idempotent. Run by `LogManager.log` on every line before write. static func sweep(_ message: String) -> String { var out = message out = redactPEM(out) out = redactTokenQuery(out) out = redactJWT(out) return out } /// Replace any `?token=…` or `&token=…` value with `***` (case-insensitive). private static func redactTokenQuery(_ s: String) -> String { guard let regex = try? NSRegularExpression( pattern: "([?&]token=)[^&\\s\"'<>]+", options: [.caseInsensitive] ) else { return s } let range = NSRange(s.startIndex ..< s.endIndex, in: s) return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "$1***") } /// Collapse the body of a PEM PRIVATE KEY block to `(redacted)`. private static func redactPEM(_ s: String) -> String { guard let regex = try? NSRegularExpression( pattern: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----", options: [] ) else { return s } let range = NSRange(s.startIndex ..< s.endIndex, in: s) return regex.stringByReplacingMatches( in: s, options: [], range: range, withTemplate: "-----BEGIN PRIVATE KEY----- (redacted) -----END PRIVATE KEY-----" ) } /// Collapse the middle segment of a JWT (`ey…\.ey…\.…`). private static func redactJWT(_ s: String) -> String { guard let regex = try? NSRegularExpression( pattern: "ey[A-Za-z0-9_-]{8,}\\.ey[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}", options: [] ) else { return s } let range = NSRange(s.startIndex ..< s.endIndex, in: s) return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "ey……") } /// Non-reversible fingerprint for opaque blobs we can't safely log /// (settings JSON, scanned QR code contents, etc.). static func fingerprint(_ data: Data) -> String { let digest = SHA256.hash(data: data) let hex = digest.compactMap { String(format: "%02x", $0) }.joined() return "\(data.count) bytes, sha256=\(hex.prefix(8))…" } static func fingerprint(_ string: String) -> String { fingerprint(Data(string.utf8)) } }