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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
24 changes: 17 additions & 7 deletions Sources/Cacheout/Cleaner/CacheCleaner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

import Foundation
import AppKit
import Darwin

actor CacheCleaner {
private let fileManager = FileManager.default
Expand Down Expand Up @@ -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])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Harden existing log directory permissions

On upgrades where ~/.cacheout already exists from the previous implementation, this call does not change its mode; createDirectory only applies attributes to newly-created directories (the same repo handles this explicitly in DaemonMode.setup). As a result, users with a pre-existing 0755 state directory continue exposing cleanup.log metadata to other local users even after this security fix, so this path should chmod the directory after creation and similarly handle any existing log file mode.

Useful? React with πŸ‘Β / πŸ‘Ž.


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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject symlinked state directories before logging

If ~/.cacheout is already a symlink, createDirectory accepts it and this open() still follows that intermediate component; O_NOFOLLOW only rejects cleanup.log itself. In that pre-created-symlink scenario the security fix continues writing the log outside the intended state directory, so the directory component needs to be opened/validated without following symlinks before appending the file.

Useful? React with πŸ‘Β / πŸ‘Ž.

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
}
}
}
Loading