Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <token>` — 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 <token>" 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)")
}
}
Comment thread
ManthanNimodiya marked this conversation as resolved.
}()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 }

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
ManthanNimodiya marked this conversation as resolved.

override func tearDown() {
Logging.shared.removeDestination(destination)
Logging.shared.configure(originalConfig) // restore original state
super.tearDown()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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]"))
}
}
Loading