mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Extract sanitization logic into an extensions + add tests (#827)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -18,6 +18,10 @@ let package = Package(
|
||||
.target(
|
||||
name: "BitLogger",
|
||||
path: "Sources"
|
||||
),
|
||||
.testTarget(
|
||||
name: "BitLoggerTests",
|
||||
dependencies: ["BitLogger"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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<NSString, NSString> = {
|
||||
let cache = NSCache<NSString, NSString>()
|
||||
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
|
||||
"<base64-data>"
|
||||
}
|
||||
|
||||
// Remove potential passwords (assuming they're in quotes or after "password:")
|
||||
sanitized = sanitized.replacing(passwordPattern) { _ in
|
||||
"password: <redacted>"
|
||||
}
|
||||
|
||||
// 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<T>(_ value: T) -> String {
|
||||
let stringValue = String(describing: value)
|
||||
return sanitize(stringValue)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration Helper
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// String+Sanitization.swift
|
||||
// BitLogger
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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
|
||||
"<base64-data>"
|
||||
}
|
||||
|
||||
// Remove potential passwords (assuming they're in quotes or after "password:")
|
||||
let passwordPattern = #/password["\s:=]+["']?[^"'\s]+["']?/#
|
||||
sanitized = sanitized.replacing(passwordPattern) { _ in
|
||||
"password: <redacted>"
|
||||
}
|
||||
|
||||
// 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<NSString, NSString> = {
|
||||
let cache = NSCache<NSString, NSString>()
|
||||
cache.countLimit = 100 // Keep last 100 sanitized strings
|
||||
return cache
|
||||
}()
|
||||
}
|
||||
@@ -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 <base64-data>")
|
||||
func base64Replacement() async throws {
|
||||
// 44+ chars of base64 characters
|
||||
let base64ish = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo5ODc2NTQzMjE="
|
||||
let input = "payload=\(base64ish)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "payload=<base64-data>")
|
||||
}
|
||||
|
||||
@Test("Base64-like without padding is replaced with <base64-data>")
|
||||
func base64NoPaddingReplacement() async throws {
|
||||
let base64ish = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
#expect(base64ish.count >= 40)
|
||||
let input = "b64:\(base64ish)"
|
||||
let output = input.sanitized()
|
||||
#expect(output == "b64:<base64-data>")
|
||||
}
|
||||
|
||||
@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: <redacted>")
|
||||
}
|
||||
|
||||
@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: <redacted> 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: <redacted>"))
|
||||
#expect(output.contains("peerID: PEERID01..."))
|
||||
#expect(output.contains("data=<base64-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: <redacted>")
|
||||
#expect(first == second)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user