From aca44f9f5566b6a2a6f5c82da3d9ee315dbbe691 Mon Sep 17 00:00:00 2001 From: Islam <2553451+qalandarov@users.noreply.github.com> Date: Sun, 19 Oct 2025 10:34:21 +0100 Subject: [PATCH] Extract sanitization logic into an extensions + add tests (#827) Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com> --- localPackages/BitLogger/Package.swift | 4 + .../BitLogger/Sources/SecureLogger.swift | 84 +--------- .../Sources/String+Sanitization.swift | 67 ++++++++ .../Tests/StringSanitizationTests.swift | 143 ++++++++++++++++++ 4 files changed, 222 insertions(+), 76 deletions(-) create mode 100644 localPackages/BitLogger/Sources/String+Sanitization.swift create mode 100644 localPackages/BitLogger/Tests/StringSanitizationTests.swift diff --git a/localPackages/BitLogger/Package.swift b/localPackages/BitLogger/Package.swift index f0c4a413..33422f42 100644 --- a/localPackages/BitLogger/Package.swift +++ b/localPackages/BitLogger/Package.swift @@ -18,6 +18,10 @@ let package = Package( .target( name: "BitLogger", path: "Sources" + ), + .testTarget( + name: "BitLoggerTests", + dependencies: ["BitLogger"] ) ] ) diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index 84bb997d..b6645ea1 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -68,22 +68,6 @@ public final class SecureLogger { return formatter }() - // MARK: - Cached Regex Patterns - - private static let fingerprintPattern = #/[a-fA-F0-9]{64}/# - private static let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/# - private static let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/# - private static let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/# - - // MARK: - Sanitization Cache - - private static let sanitizationCache: NSCache = { - let cache = NSCache() - cache.countLimit = 100 // Keep last 100 sanitized strings - return cache - }() - private static let cacheQueue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent) - // MARK: - Log Levels enum LogLevel { @@ -161,8 +145,8 @@ public extension SecureLogger { static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { let location = formatLocation(file: file, line: line, function: function) - let sanitized = sanitize(context()) - let errorDesc = sanitize(error.localizedDescription) + let sanitized = context().sanitized() + let errorDesc = error.localizedDescription.sanitized() #if DEBUG os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc) @@ -186,15 +170,15 @@ public extension SecureLogger { var message: String { switch self { case .handshakeStarted(let peerID): - return "Handshake started with peer: \(sanitize(peerID))" + return "Handshake started with peer: \(peerID.sanitized())" case .handshakeCompleted(let peerID): - return "Handshake completed with peer: \(sanitize(peerID))" + return "Handshake completed with peer: \(peerID.sanitized())" case .handshakeFailed(let peerID, let error): - return "Handshake failed with peer: \(sanitize(peerID)), error: \(error)" + return "Handshake failed with peer: \(peerID.sanitized()), error: \(error)" case .sessionExpired(let peerID): - return "Session expired for peer: \(sanitize(peerID))" + return "Session expired for peer: \(peerID.sanitized())" case .authenticationFailed(let peerID): - return "Authentication failed for peer: \(sanitize(peerID))" + return "Authentication failed for peer: \(peerID.sanitized())" } } } @@ -249,7 +233,7 @@ private extension SecureLogger { file: String, line: Int, function: String) { guard shouldLog(level) else { return } let location = formatLocation(file: file, line: line, function: function) - let sanitized = sanitize("\(location) \(message())") + let sanitized = "\(location) \(message())".sanitized() #if DEBUG os_log("%{public}@", log: category, type: level.osLogType, sanitized) @@ -282,58 +266,6 @@ private extension SecureLogger { let timestamp = timestampFormatter.string(from: Date()) return "[\(timestamp)] [\(fileName):\(line) \(function)]" } - - /// Sanitize strings to remove potentially sensitive data - static func sanitize(_ input: String) -> String { - let key = input as NSString - - // Check cache first - var cachedValue: String? - cacheQueue.sync { - cachedValue = sanitizationCache.object(forKey: key) as String? - } - - if let cached = cachedValue { - return cached - } - - // Perform sanitization - var sanitized = input - - // Remove full fingerprints (keep first 8 chars for debugging) - sanitized = sanitized.replacing(fingerprintPattern) { match in - let fingerprint = String(match.output) - return String(fingerprint.prefix(8)) + "..." - } - - // Remove base64 encoded data that might be keys - sanitized = sanitized.replacing(base64Pattern) { _ in - "" - } - - // Remove potential passwords (assuming they're in quotes or after "password:") - sanitized = sanitized.replacing(passwordPattern) { _ in - "password: " - } - - // Truncate peer IDs to first 8 characters - sanitized = sanitized.replacing(peerIDPattern) { match in - "peerID: \(match.1)..." - } - - // Cache the result - cacheQueue.sync { - sanitizationCache.setObject(sanitized as NSString, forKey: key) - } - - return sanitized - } - - /// Sanitize individual values - static func sanitize(_ value: T) -> String { - let stringValue = String(describing: value) - return sanitize(stringValue) - } } // MARK: - Migration Helper diff --git a/localPackages/BitLogger/Sources/String+Sanitization.swift b/localPackages/BitLogger/Sources/String+Sanitization.swift new file mode 100644 index 00000000..96f40146 --- /dev/null +++ b/localPackages/BitLogger/Sources/String+Sanitization.swift @@ -0,0 +1,67 @@ +// +// String+Sanitization.swift +// BitLogger +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +extension String { + /// Sanitize strings to remove potentially sensitive data + func sanitized() -> String { + let key = self as NSString + + // Check cache first + if let cached = Self.queue.sync(execute: { Self.cache.object(forKey: key) }) { + return cached as String + } + + var sanitized = self + + // Remove full fingerprints (keep first 8 chars for debugging) + let fingerprintPattern = #/[a-fA-F0-9]{64}/# + sanitized = sanitized.replacing(fingerprintPattern) { match in + let fingerprint = String(match.output) + return String(fingerprint.prefix(8)) + "..." + } + + // Remove base64 encoded data that might be keys + let base64Pattern = #/[A-Za-z0-9+/]{40,}={0,2}/# + sanitized = sanitized.replacing(base64Pattern) { _ in + "" + } + + // Remove potential passwords (assuming they're in quotes or after "password:") + let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/# + sanitized = sanitized.replacing(passwordPattern) { _ in + "password: " + } + + // Truncate peer IDs to first 8 characters + let peerIDPattern = #/peerID: ([a-zA-Z0-9]{8})[a-zA-Z0-9]+/# + sanitized = sanitized.replacing(peerIDPattern) { match in + "peerID: \(match.1)..." + } + + // Cache the result + Self.queue.sync { + Self.cache.setObject(sanitized as NSString, forKey: key) + } + + return sanitized + } +} + +// MARK: - Cache Helpers + +private extension String { + static let queue = DispatchQueue(label: "chat.bitchat.securelogger.cache", attributes: .concurrent) + + static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 100 // Keep last 100 sanitized strings + return cache + }() +} diff --git a/localPackages/BitLogger/Tests/StringSanitizationTests.swift b/localPackages/BitLogger/Tests/StringSanitizationTests.swift new file mode 100644 index 00000000..8aac664b --- /dev/null +++ b/localPackages/BitLogger/Tests/StringSanitizationTests.swift @@ -0,0 +1,143 @@ +// +// StringSanitizationTests.swift +// BitLogger +// +// Created by Islam on 19/10/2025. +// + +import Testing +@testable import BitLogger + +struct StringSanitizationTests { + + @Test("64-hex fingerprint is truncated to first 8 chars followed by ellipsis") + func fingerprintTruncation() async throws { + let fingerprint = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + #expect(fingerprint.count == 64) + + let input = "fingerprint=\(fingerprint)" + let output = input.sanitized() + + #expect(output.contains("fingerprint=01234567...")) + // Ensure no full fingerprint remains + #expect(output.contains(fingerprint) == false) + } + + @Test("Multiple fingerprints in a string are all truncated") + func multipleFingerprintTruncation() async throws { + let fp1 = String(repeating: "a", count: 64) + let fp2 = String(repeating: "b", count: 64) + let input = "fp1=\(fp1) fp2=\(fp2)" + let output = input.sanitized() + #expect(output.contains("fp1=aaaaaaaa...")) + #expect(output.contains("fp2=bbbbbbbb...")) + #expect(output.contains(fp1) == false) + #expect(output.contains(fp2) == false) + } + + @Test("Base64-like long data is replaced with ") + func base64Replacement() async throws { + // 44+ chars of base64 characters + let base64ish = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE=" + let input = "payload=\(base64ish)" + let output = input.sanitized() + #expect(output == "payload=") + } + + @Test("Base64-like without padding is replaced with ") + func base64NoPaddingReplacement() async throws { + let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + #expect(base64ish.count >= 40) + let input = "b64:\(base64ish)" + let output = input.sanitized() + #expect(output == "b64:") + } + + @Test("Short base64-like strings (below threshold) are not replaced") + func shortBase64NotReplaced() async throws { + let short = "QUJDREVGR0hJSktMTU5P" // < 40 chars + let input = "payload=\(short)" + let output = input.sanitized() + #expect(output == input) + } + + @Test("Password redaction for key:value formats", arguments: [ + "password: secret123", + "password=secret123", + "password = secret123", + "password: 'secret123'", + "password:\"secret123\"", + "password='secret123'" + ]) + func passwordRedactionKeyValue(password: String) async throws { + #expect(password.sanitized() == "password: ") + } + + @Test("Password redaction inside wider messages") + func passwordRedactionInContext() async throws { + let input = "user=john password: 'p@ssW0rd' attempt=1" + let output = input.sanitized() + #expect(output == "user=john password: attempt=1") + } + + @Test("PeerID is truncated to first 8 chars followed by ellipsis") + func peerIDTruncation() async throws { + let peer = "ABCDEF12GHIJKL34" + let input = "peerID: \(peer)" + let output = input.sanitized() + #expect(output == "peerID: ABCDEF12...") + } + + @Test("PeerID not truncated when exactly 8 chars") + func peerIDExactlyEightNotTruncated() async throws { + let peer = "ABCDEF12" + let input = "peerID: \(peer)" + let output = input.sanitized() + // Pattern only matches when there are more than 8 trailing chars, so unchanged + #expect(output == input) + } + + @Test("Non-matching content remains unchanged") + func nonMatchingUnchanged() async throws { + let input = "Hello world 123 - nothing sensitive here." + let output = input.sanitized() + #expect(output == input) + } + + @Test("Idempotency: sanitizing twice yields same result") + func idempotentSanitization() async throws { + let input = """ + fingerprint=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ + password: "superSecret" \ + peerID: ZYXWVUT987654321 \ + payload=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + """ + let once = input.sanitized() + let twice = once.sanitized() + #expect(once == twice) + } + + @Test("Mixed content: all rules apply in a single string") + func mixedContent() async throws { + let fingerprint = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + let peer = "PEERID01EXTRA" + let input = "fp=\(fingerprint) password='x' peerID: \(peer) data=\(base64ish)" + let output = input.sanitized() + #expect(output.contains("fp=fedcba98...")) + #expect(output.contains("password: ")) + #expect(output.contains("peerID: PEERID01...")) + #expect(output.contains("data=")) + #expect(output.contains(fingerprint) == false) + #expect(output.contains(base64ish) == false) + } + + @Test("Cache returns consistent result for repeated inputs") + func cacheHitConsistency() async throws { + let input = "password: hunter2" + let first = input.sanitized() + let second = input.sanitized() + #expect(first == "password: ") + #expect(first == second) + } +}