diff --git a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift index 1e5d1a116e..bb010caa2e 100644 --- a/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift +++ b/sdk/runanywhere-swift/Sources/RunAnywhere/Infrastructure/Logging/SDKLogger.swift @@ -248,13 +248,22 @@ public final class Logging: @unchecked Sendable { output += " | \(metaStr)" } - // Always print when local logging is enabled (controlled by configuration) - // The enableLocalLogging flag already controls whether this method is called - // swiftlint:disable:next no_print_statements - print(output) + // Route through os.Logger so the system log privacy controls apply. + // Message is already sanitized by sanitizeMessage(); mark .public so it + // appears in release Console.app captures without double-redaction. + let osLog = Logger(subsystem: Self.logSubsystem, category: entry.category) + switch entry.level { + case .debug: osLog.debug("\(output, privacy: .public)") + case .info: osLog.info("\(output, privacy: .public)") + case .warning: osLog.warning("\(output, privacy: .public)") + case .error: osLog.error("\(output, privacy: .public)") + case .fault: osLog.fault("\(output, privacy: .public)") + } } - // MARK: - Metadata Sanitization + // MARK: - Sanitization + + private static let logSubsystem = "com.runanywhere.sdk" /// Determines if a metadata key should be redacted by delegating to the /// canonical C++ policy (`rac_log_metadata_should_redact`). Keeps Swift @@ -265,6 +274,47 @@ public final class Logging: @unchecked Sendable { return rc == RAC_SUCCESS && out != 0 } + /// Redacts credential values embedded directly in a log message string. + /// + /// Two patterns are applied in sequence: + /// - `key=value` / `key: value` — explicit `=` or `:` separators only, preventing + /// false positives on phrases like "token count: 5" or "password required". + /// - `Bearer ` — HTTP Authorization scheme (space-separated, 8+ char value). + /// `basic` is intentionally excluded as it appears too often in ordinary prose. + private static let sensitiveMessagePatterns: [(NSRegularExpression, String)] = { + let specs: [(String, String)] = [ + // Bearer scheme FIRST: must run before the key=value pattern so that + // "Authorization: Bearer " has its token captured here before + // the next pattern can consume "Bearer" as the credential value. + // Minimum 8 chars to avoid matching short English words. + (#"(?i)(bearer)(\s+)([A-Za-z0-9+/=._\-]{8,})"#, + "$1$2[REDACTED]"), + // Group 1: keyword Group 2: separator (= or : only) Group 3: value + // "authorization" is safe to include here because the Bearer token has + // already been redacted by the pattern above. + (#"(?i)(api[_\-]?key|apikey|secret|password|token|auth[_\-]?key|authorization|credential)(\s*[=:]\s*)(\S+)"#, + "$1$2[REDACTED]"), + ] + return specs.map { (patternString, template) in + do { + return (try NSRegularExpression(pattern: patternString, options: []), template) + } catch { + preconditionFailure("Invalid sanitization regex '\(patternString)': \(error)") + } + } + }() + + private func sanitizeMessage(_ message: String) -> String { + var result = message + for (pattern, template) in Self.sensitiveMessagePatterns { + let range = NSRange(result.startIndex..., in: result) + result = pattern.stringByReplacingMatches( + in: result, options: [], range: range, withTemplate: template + ) + } + return result + } + private func sanitizeMetadata(_ metadata: [String: Any]?) -> [String: Any]? { // swiftlint:disable:this prefer_concrete_types avoid_any_type guard let metadata = metadata else { return nil } diff --git a/sdk/runanywhere-swift/Tests/RunAnywhereTests/SecurityLoggingTests.swift b/sdk/runanywhere-swift/Tests/RunAnywhereTests/SecurityLoggingTests.swift new file mode 100644 index 0000000000..fe4affde7b --- /dev/null +++ b/sdk/runanywhere-swift/Tests/RunAnywhereTests/SecurityLoggingTests.swift @@ -0,0 +1,207 @@ +// +// SecurityLoggingTests.swift +// RunAnywhere SDK +// +// Verifies that sensitive values (API keys, tokens, passwords) are redacted +// before they reach any log destination — both from message strings and +// metadata dictionaries. Regression guard for issue #246. +// + +import XCTest +@testable import RunAnywhere + +// MARK: - Capturing Destination + +/// Test-only LogDestination that records every LogEntry written to it. +private final class CapturingDestination: LogDestination, @unchecked Sendable { + static let destinationID = "test.security.capturing" + + var identifier: String { Self.destinationID } + var isAvailable: Bool { true } + + private(set) var captured: [LogEntry] = [] + + func write(_ entry: LogEntry) { captured.append(entry) } + func flush() {} + func reset() { captured.removeAll() } +} + +// MARK: - SecurityLoggingTests + +final class SecurityLoggingTests: XCTestCase { + + private var destination: CapturingDestination! + private var originalConfig: LoggingConfiguration! + + override func setUp() { + super.setUp() + // Snapshot config so tearDown can restore it — Logging.shared is a + // process-wide singleton and mutated state would bleed into other test classes. + originalConfig = Logging.shared.configuration + + destination = CapturingDestination() + + var config = Logging.shared.configuration + config.enableLocalLogging = false // suppress os.Logger console output in CI + config.enableSentryLogging = false + config.minLogLevel = .debug + // NOTE: both local + Sentry are disabled here, so the guard in log() would + // short-circuit before reaching CapturingDestination. The guard was updated to + // also pass when !currentDestinations.isEmpty, which is true once we addDestination. + Logging.shared.configure(config) + Logging.shared.addDestination(destination) + } + + override func tearDown() { + Logging.shared.removeDestination(destination) + Logging.shared.configure(originalConfig) // restore original state + super.tearDown() + } + + // MARK: - Message string sanitization + + func testApiKeyEqualsInMessageIsRedacted() { + Logging.shared.log(level: .info, category: "Test", + message: "Configured with apiKey=sk-prod-abc123") + let msg = destination.captured.first?.message ?? "" + XCTAssertFalse(msg.contains("sk-prod-abc123"), "API key value must not appear in log") + XCTAssertTrue(msg.contains("[REDACTED]"), "Redaction marker must be present") + } + + func testApiKeyColonInMessageIsRedacted() { + Logging.shared.log(level: .info, category: "Test", + message: "api_key: supersecret") + let msg = destination.captured.first?.message ?? "" + XCTAssertFalse(msg.contains("supersecret")) + XCTAssertTrue(msg.contains("[REDACTED]")) + } + + func testPasswordInMessageIsRedacted() { + Logging.shared.log(level: .warning, category: "Test", + message: "Login attempt password=hunter2 failed") + let msg = destination.captured.first?.message ?? "" + XCTAssertFalse(msg.contains("hunter2")) + XCTAssertTrue(msg.contains("[REDACTED]")) + } + + func testBearerTokenInMessageIsRedacted() { + Logging.shared.log(level: .error, category: "Test", + message: "Request rejected — Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig") + let msg = destination.captured.first?.message ?? "" + XCTAssertFalse(msg.contains("eyJhbGciOiJIUzI1NiJ9.payload.sig")) + XCTAssertTrue(msg.contains("[REDACTED]")) + } + + func testAuthorizationBearerJWTIsFullyRedacted() { + // Regression for pattern-ordering bug: Pattern 1 must not consume "Bearer" as the + // credential value before Pattern 2 captures the actual JWT. + // Expected: both the scheme value and the header key are scrubbed; JWT never appears. + let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig" + Logging.shared.log(level: .info, category: "Test", message: input) + let msg = destination.captured.first?.message ?? "" + XCTAssertFalse(msg.contains("eyJhbGciOiJIUzI1NiJ9.payload.sig"), + "JWT must not appear in log output") + XCTAssertTrue(msg.contains("[REDACTED]"), + "Redaction marker must be present") + } + + func testSecretInMessageIsRedacted() { + Logging.shared.log(level: .debug, category: "Test", + message: "secret=xyzTopSecret") + let msg = destination.captured.first?.message ?? "" + XCTAssertFalse(msg.contains("xyzTopSecret")) + XCTAssertTrue(msg.contains("[REDACTED]")) + } + + func testTokenColonInMessageIsRedacted() { + Logging.shared.log(level: .info, category: "Test", + message: "Stored token: abc.def.ghi") + let msg = destination.captured.first?.message ?? "" + XCTAssertFalse(msg.contains("abc.def.ghi")) + XCTAssertTrue(msg.contains("[REDACTED]")) + } + + func testNonSensitiveMessagePassesThroughUnchanged() { + let plain = "Model qwen3-0.6b loaded in 340ms" + Logging.shared.log(level: .info, category: "Test", message: plain) + XCTAssertEqual(destination.captured.first?.message, plain) + } + + func testEmptyMessagePassesThroughUnchanged() { + Logging.shared.log(level: .info, category: "Test", message: "") + XCTAssertEqual(destination.captured.first?.message, "") + } + + // MARK: - False-positive regression + + func testTokenCountPhraseIsNotRedacted() { + // "token" followed by a noun, not a separator — must not trigger + let msg = "token count: 5" + Logging.shared.log(level: .info, category: "Test", message: msg) + XCTAssertEqual(destination.captured.first?.message, msg, + "'token count: 5' must pass through unchanged") + } + + func testPasswordRequiredPhraseIsNotRedacted() { + let msg = "password required for this operation" + Logging.shared.log(level: .info, category: "Test", message: msg) + XCTAssertEqual(destination.captured.first?.message, msg, + "'password required' must pass through unchanged — no = or : separator present") + } + + func testBasicEnglishPhraseIsNotRedacted() { + // "basic" is excluded from the pattern entirely to avoid prose false-positives + let msg = "basic authentication is disabled" + Logging.shared.log(level: .info, category: "Test", message: msg) + XCTAssertEqual(destination.captured.first?.message, msg, + "'basic' in plain English must pass through unchanged") + } + + // MARK: - Metadata sanitization (regression) + + func testApiKeyInMetadataIsRedacted() { + Logging.shared.log(level: .info, category: "Test", message: "Init", + metadata: ["apiKey": "sk-supersecret"]) + let meta = destination.captured.first?.metadata ?? [:] + XCTAssertEqual(meta["apiKey"], "[REDACTED]", "apiKey metadata value must be redacted") + } + + func testPasswordInMetadataIsRedacted() { + Logging.shared.log(level: .info, category: "Test", message: "Auth", + metadata: ["password": "letmein"]) + let meta = destination.captured.first?.metadata ?? [:] + XCTAssertEqual(meta["password"], "[REDACTED]") + } + + func testNonSensitiveMetadataPassesThroughUnchanged() { + Logging.shared.log(level: .info, category: "Test", message: "Info", + metadata: ["modelId": "qwen3-0.6b", "duration_ms": 200]) + let meta = destination.captured.first?.metadata ?? [:] + XCTAssertEqual(meta["modelId"], "qwen3-0.6b") + } + + // MARK: - logError call-site sanitization + + func testLogErrorWithSensitiveAdditionalInfoIsRedacted() { + struct FakeError: Error, LocalizedError { + var errorDescription: String? { "Request failed" } + } + let logger = SDKLogger(category: "Test") + logger.logError(FakeError(), additionalInfo: "token=leaked-value-xyz") + let msg = destination.captured.first?.message ?? "" + XCTAssertFalse(msg.contains("leaked-value-xyz"), + "Sensitive value in additionalInfo must be redacted via sanitizeMessage") + XCTAssertTrue(msg.contains("[REDACTED]")) + } + + func testLogErrorWithCleanAdditionalInfoPassesThrough() { + struct FakeError: Error, LocalizedError { + var errorDescription: String? { "Timeout" } + } + let logger = SDKLogger(category: "Test") + logger.logError(FakeError(), additionalInfo: "retrying request") + let msg = destination.captured.first?.message ?? "" + XCTAssertTrue(msg.contains("retrying request")) + XCTAssertFalse(msg.contains("[REDACTED]")) + } +}