mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +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(
|
.target(
|
||||||
name: "BitLogger",
|
name: "BitLogger",
|
||||||
path: "Sources"
|
path: "Sources"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "BitLoggerTests",
|
||||||
|
dependencies: ["BitLogger"]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,22 +68,6 @@ public final class SecureLogger {
|
|||||||
return formatter
|
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
|
// MARK: - Log Levels
|
||||||
|
|
||||||
enum LogLevel {
|
enum LogLevel {
|
||||||
@@ -161,8 +145,8 @@ public extension SecureLogger {
|
|||||||
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
static func error(_ error: Error, context: @autoclosure () -> String, category: OSLog = .noise,
|
||||||
file: String = #file, line: Int = #line, function: String = #function) {
|
file: String = #file, line: Int = #line, function: String = #function) {
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize(context())
|
let sanitized = context().sanitized()
|
||||||
let errorDesc = sanitize(error.localizedDescription)
|
let errorDesc = error.localizedDescription.sanitized()
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
os_log("%{public}@ Error in %{public}@: %{public}@", log: category, type: .error, location, sanitized, errorDesc)
|
||||||
@@ -186,15 +170,15 @@ public extension SecureLogger {
|
|||||||
var message: String {
|
var message: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .handshakeStarted(let peerID):
|
case .handshakeStarted(let peerID):
|
||||||
return "Handshake started with peer: \(sanitize(peerID))"
|
return "Handshake started with peer: \(peerID.sanitized())"
|
||||||
case .handshakeCompleted(let peerID):
|
case .handshakeCompleted(let peerID):
|
||||||
return "Handshake completed with peer: \(sanitize(peerID))"
|
return "Handshake completed with peer: \(peerID.sanitized())"
|
||||||
case .handshakeFailed(let peerID, let error):
|
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):
|
case .sessionExpired(let peerID):
|
||||||
return "Session expired for peer: \(sanitize(peerID))"
|
return "Session expired for peer: \(peerID.sanitized())"
|
||||||
case .authenticationFailed(let peerID):
|
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) {
|
file: String, line: Int, function: String) {
|
||||||
guard shouldLog(level) else { return }
|
guard shouldLog(level) else { return }
|
||||||
let location = formatLocation(file: file, line: line, function: function)
|
let location = formatLocation(file: file, line: line, function: function)
|
||||||
let sanitized = sanitize("\(location) \(message())")
|
let sanitized = "\(location) \(message())".sanitized()
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
os_log("%{public}@", log: category, type: level.osLogType, sanitized)
|
||||||
@@ -282,58 +266,6 @@ private extension SecureLogger {
|
|||||||
let timestamp = timestampFormatter.string(from: Date())
|
let timestamp = timestampFormatter.string(from: Date())
|
||||||
return "[\(timestamp)] [\(fileName):\(line) \(function)]"
|
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
|
// 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