diff --git a/.jules/sentinel.md b/.jules/sentinel.md index fd58571..38ef279 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -31,3 +31,8 @@ **Vulnerability:** External shell command executed in `listLocalSnapshots()` triggered a deadlock when `tmutil` output exceeded 64KB, because stdout and stderr were read synchronously inside the process termination handler. **Learning:** In Swift, reading from a process pipe synchronously inside a `terminationHandler` can result in a permanent deadlock if the child blocks writing to a full pipe, preventing it from exiting. **Prevention:** Asynchronously drain pipes continuously while the process is running using background queues. + +## 2026-05-02 - Fix TOCTOU vulnerability in file creation +**Vulnerability:** High-level Swift file APIs (`FileHandle(forWritingTo:)`, `String.write(to:atomically:)`) follow symlinks by default, leading to Time-of-Check to Time-of-Use (TOCTOU) symlink vulnerabilities. +**Learning:** High-level APIs are inherently insecure when writing to paths that could be tampered with. The vulnerability can be exploited to overwrite arbitrary files if an attacker replaces a file or directory with a symlink before the application writes to it. +**Prevention:** Use POSIX `open(2)` with flags like `O_CREAT | O_WRONLY | O_APPEND | O_NOFOLLOW | O_CLOEXEC` to explicitly refuse following symlinks, securely create the file, and then wrap the resulting file descriptor in a `FileHandle`. diff --git a/Sources/Cacheout/Cleaner/CacheCleaner.swift b/Sources/Cacheout/Cleaner/CacheCleaner.swift index 2111e6a..ebd352a 100644 --- a/Sources/Cacheout/Cleaner/CacheCleaner.swift +++ b/Sources/Cacheout/Cleaner/CacheCleaner.swift @@ -34,6 +34,7 @@ import Foundation import AppKit +import Darwin actor CacheCleaner { private let fileManager = FileManager.default @@ -255,18 +256,27 @@ actor CacheCleaner { private func logCleanup(category: String, bytesFreed: Int64) { let logDir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".cacheout") - try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) let logFile = logDir.appendingPathComponent("cleanup.log") let size = ByteCountFormatter.sharedFile.string(fromByteCount: bytesFreed) let entry = "[\(ISO8601DateFormatter.shared.string(from: Date()))] Cleaned \(category): \(size)\n" - if let handle = try? FileHandle(forWritingTo: logFile) { - handle.seekToEndOfFile() - handle.write(entry.data(using: .utf8) ?? Data()) - handle.closeFile() - } else { - try? entry.write(to: logFile, atomically: true, encoding: .utf8) + _ = logFile.withUnsafeFileSystemRepresentation { pathPtr -> Int32 in + guard let pathPtr = pathPtr else { return -1 } + let fd = open(pathPtr, O_CREAT | O_WRONLY | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0o600) + if fd != -1 { + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + let data = entry.data(using: .utf8) ?? Data() + if #available(macOS 10.15.4, *) { + try? handle.write(contentsOf: data) + try? handle.close() + } else { + handle.write(data) + handle.closeFile() + } + } + return fd } } }