Skip to content

Commit 2e8607f

Browse files
authored
Redact secrets from log output (#623)
Adds a LogRedactor helper and applies it across every known leaky log site so users can share logs without leaking APNs tokens, p8 keys, Nightscout URLs and tokens, Dexcom usernames, key/team IDs, or bundle identifiers. LogManager.log also runs a safety-net sweep that catches PEM PRIVATE KEY blocks, ?token= query values, and JWTs regardless of the call site.
1 parent 57f5f11 commit 2e8607f

15 files changed

Lines changed: 210 additions & 40 deletions

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@
250250
DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; };
251251
DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; };
252252
DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC01DC2E244B3100D9975C /* JWTManager.swift */; };
253+
A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; };
253254
DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; };
254255
DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; };
255256
DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; };
@@ -705,6 +706,7 @@
705706
DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = "<group>"; };
706707
DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = "<group>"; };
707708
DDDC01DC2E244B3100D9975C /* JWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTManager.swift; sourceTree = "<group>"; };
709+
A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = "<group>"; };
708710
DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = "<group>"; };
709711
DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = "<group>"; };
710712
DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = "<group>"; };
@@ -1706,6 +1708,7 @@
17061708
DDF699952C5582290058A8D9 /* TextFieldWithToolBar.swift */,
17071709
DDC7E5372DBD887400EB1127 /* isOnPhoneCall.swift */,
17081710
DDDC01DC2E244B3100D9975C /* JWTManager.swift */,
1711+
A1A1A10002000000A0CFEED2 /* LogRedactor.swift */,
17091712
6541341B2E1DC28000BDBE08 /* DateExtensions.swift */,
17101713
);
17111714
path = Helpers;
@@ -2296,6 +2299,7 @@
22962299
DD4A407E2E6AFEE6007B318B /* AuthService.swift in Sources */,
22972300
654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */,
22982301
DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */,
2302+
A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */,
22992303
DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
23002304
DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */,
23012305
DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */,

LoopFollow/Application/AppDelegate.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
8787

8888
Observable.shared.loopFollowDeviceToken.value = tokenString
8989

90-
LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)")
90+
LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(LogRedactor.tail(tokenString))")
9191
}
9292

9393
/// Called when failed to register for remote notifications
@@ -97,7 +97,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
9797

9898
/// Called when a remote notification is received
9999
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
100-
LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)")
100+
let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
101+
LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)")
101102

102103
// Check if this is a response notification from Loop or Trio
103104
if let aps = userInfo["aps"] as? [String: Any] {
@@ -232,7 +233,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
232233
{
233234
// Log the notification
234235
let userInfo = notification.request.content.userInfo
235-
LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)")
236+
let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
237+
LogManager.shared.log(category: .general, message: "Will present notification: keys=\(userInfoKeys)")
236238

237239
// Show the notification even when app is in foreground
238240
completionHandler([.banner, .sound, .badge])

LoopFollow/Helpers/JWTManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class JWTManager {
4545
let signedJWT = "\(signingInput).\(signatureBase64)"
4646

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

LoopFollow/Helpers/NightscoutUtils.swift

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,25 +86,22 @@ class NightscoutUtils {
8686
completion(.success(decodedObject))
8787
}
8888
} catch let decodingError as DecodingError {
89-
print("[ERROR] Failed to decode \(T.self):")
89+
let typeName = String(describing: T.self)
9090
switch decodingError {
9191
case let .typeMismatch(type, context):
92-
print("Type mismatch for type \(type), context: \(context.debugDescription)")
93-
print("Coding path:", context.codingPath)
92+
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) typeMismatch: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
9493
case let .valueNotFound(type, context):
95-
print("Value not found for type \(type), context: \(context.debugDescription)")
96-
print("Coding path:", context.codingPath)
94+
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) valueNotFound: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
9795
case let .keyNotFound(key, context):
98-
print("Key '\(key.stringValue)' not found, context: \(context.debugDescription)")
99-
print("Coding path:", context.codingPath)
100-
case let .dataCorrupted(context):
101-
print("Data corrupted, context: \(context.debugDescription)")
96+
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) keyNotFound: '\(key.stringValue)' at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
97+
case .dataCorrupted:
98+
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) dataCorrupted", isDebug: true)
10299
@unknown default:
103-
print("Unknown decoding error")
100+
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) unknown error", isDebug: true)
104101
}
105102
completion(.failure(decodingError))
106103
} catch {
107-
print("[ERROR] General error:", error)
104+
LogManager.shared.log(category: .nightscout, message: "Decode \(T.self) general error: \(String(describing: type(of: error)))", isDebug: true)
108105
completion(.failure(error))
109106
}
110107
}

LoopFollow/Log/LogManager.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ class LogManager {
5656
/// - limitIdentifier: Optional key to rate-limit similar log messages.
5757
/// - limitInterval: Time interval (in seconds) to wait before logging the same type again.
5858
func log(category: Category, message: String, isDebug: Bool = false, limitIdentifier: String? = nil, limitInterval: TimeInterval = 300) {
59-
let logMessage = formattedLogMessage(for: category, message: message)
59+
let safeMessage = LogRedactor.sweep(message)
60+
let logMessage = formattedLogMessage(for: category, message: safeMessage)
6061

6162
consoleQueue.async {
6263
print(logMessage)

LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ class LoopAPNSService {
162162

163163
// Encrypt and include return notification info using OTP
164164
if let returnInfo = createReturnNotificationInfo() {
165-
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(returnInfo.deviceToken.prefix(8))..., bundleId: \(returnInfo.bundleId)")
165+
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))")
166166
if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) {
167167
finalPayload["encrypted_return_notification"] = encryptedReturnInfo
168168
LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)")
@@ -227,7 +227,7 @@ class LoopAPNSService {
227227

228228
// Encrypt and include return notification info using OTP
229229
if let returnInfo = createReturnNotificationInfo() {
230-
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(returnInfo.deviceToken.prefix(8))..., bundleId: \(returnInfo.bundleId)")
230+
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))")
231231
if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) {
232232
finalPayload["encrypted_return_notification"] = encryptedReturnInfo
233233
LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)")

LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ class RemoteSettingsViewModel: ObservableObject {
201201
self.remoteType = .loopAPNS
202202
self.isLoopDevice = true
203203
self.isTrioDevice = false
204-
LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(code)")
204+
LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(LogRedactor.fingerprint(code))")
205205
case let .failure(error):
206206
self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)"
207207
}

LoopFollow/Remote/TRC/PushNotificationManager.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,11 @@ class PushNotificationManager {
276276
}
277277

278278
if let httpResponse = response as? HTTPURLResponse {
279-
print("Push notification sent.")
280-
print("Status code: \(httpResponse.statusCode)")
279+
LogManager.shared.log(category: .apns, message: "Push notification sent. Status code: \(httpResponse.statusCode)", isDebug: true)
281280

282281
var responseBodyMessage = ""
283282
if let data = data, let responseBody = String(data: data, encoding: .utf8) {
284-
print("Response body: \(responseBody)")
283+
LogManager.shared.log(category: .apns, message: "Response body: \(responseBody)", isDebug: true)
285284
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
286285
let reason = json["reason"] as? String
287286
{

0 commit comments

Comments
 (0)