curtain/Scripts/probe-detection.swift
Aric Camarata 8c19e960d2 Detection root-cause fix + audit batch: netstat path, UDP activator, settings coherence, refactor, docs
Detection: netstat lives at /usr/sbin/netstat, not /usr/bin — the hardcoded wrong
path silently killed the ESTABLISHED-TCP activator (root cause of the failed live
test). Fixed and live-verified. Added peered-UDP activator (5900-5902) for
High-Performance sessions, per-signal transition logging, unconditional error
logging for dead probe helpers, and probe v2 with full CGSession dictionary
diffing. 7 new parser tests (32 total).

Fixes from a full audit + adversarial review: idle source setting honored
(default now Remote session activity), cover scope reduced to a coherent
two-mode model with legacy migration (per-display toggle was inverted in
onlyMarked and dead in all), curtain test no longer schedules a teardown over a
live session, specific-display password box placement gets a real picker,
refuse-to-arm enforced, activation notification posts a real banner, menu
password gate bypassed when the event tap is dead, shared single-decoder aerial
player with stale-task guard and async playability check, password buffer zeroed
on successful unlock and Esc, XPC interruption/invalidation handlers, modern
Accessibility settings URL, launchPath modernized, codesign failures now abort
release.sh, monotonic CFBundleVersion, install.sh temp cleanup, dead
armDisarmHotkey setting removed.

Refactor: Curtain.swift and PreferencesWindow.swift split into focused files
(largest now 479 lines). Wiki, README, and contributing docs updated to match.
Build clean at 0 warnings, 32/32 tests pass.
2026-06-09 20:36:30 -04:00

151 lines
5.7 KiB
Swift

#!/usr/bin/env swift
//
// probe-detection.swift on-device verification tool for Curtain's detection.
//
// Run: swift Scripts/probe-detection.swift
//
// Prints one timestamped line per second showing every raw signal Curtain can use
// to detect a Screen Sharing session: the CGSession capture key, the on-console
// flag, the presence of screen-sharing helper processes, and the netstat rows on
// the VNC ports (TCP 5900 + UDP 5900-5902). It ALSO diffs the full CGSession
// dictionary and prints any key that appears, disappears, or changes value so a
// live connection reveals every session-dictionary signal macOS exposes, including
// ones we do not know about yet. Start it, then open a real Screen Sharing session
// to this Mac and watch which signals flip. Ctrl-C to stop.
//
// Reading the output during a live test:
// captured=true -> the authoritative capture key works; detection is solid.
// tcp_estab>=1 -> classic VNC transport visible to netstat.
// udp_peered>=1 -> High-Performance (UDP) transport visible to netstat.
// dict change lines -> candidate signals if none of the above fired.
import Cocoa
import CoreGraphics
import Foundation
let captureKey = "CGSSessionScreenIsCaptured"
let consoleKey = kCGSessionOnConsoleKey as String
func sessionDict() -> [String: Any] {
(CGSessionCopyCurrentDictionary() as? [String: Any]) ?? [:]
}
func boolValue(_ dict: [String: Any], _ key: String) -> Bool {
(dict[key] as? Bool) ?? false
}
func shell(_ path: String, _ args: [String]) -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
} catch {
// A probe tool must fail loudly: a silent "" here once masked a dead
// netstat path and made every socket count read as zero.
print("!! probe helper failed to launch: \(path)\(error)")
return ""
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return String(data: data, encoding: .utf8) ?? ""
}
func screenShareProcesses() -> String {
let out = shell("/usr/bin/pgrep", ["-fl", "ScreenSharingAgent|ScreenSharingSubscriber|screensharingd"])
let trimmed = out.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "none" }
// Collapse to a compact one-line summary of matched process names.
let names = trimmed.split(separator: "\n").compactMap { line -> String? in
line.split(separator: " ").dropFirst().first.map(String.init)
}
return names.isEmpty ? "present" : names.joined(separator: ",")
}
func isVNCLocal(_ local: String) -> Bool {
local.hasSuffix(".5900") || local.hasSuffix(".5901") || local.hasSuffix(".5902")
}
func isRealPeer(_ foreign: String) -> Bool {
foreign != "*.*" && !foreign.hasSuffix(".*")
}
func vncSockets() -> String {
// netstat lives in /usr/sbin on macOS NOT /usr/bin.
let out = shell("/usr/sbin/netstat", ["-an"])
var estab = 0 // ESTABLISHED inbound TCP on 5900 a real classic VNC session
var listen = 0 // 5900 LISTEN always present when Screen Sharing is enabled
var udpTotal = 0 // any UDP socket on 5900-5902 informational
var udpPeered = 0 // UDP on 5900-5902 with a real foreign peer High-Performance session
for raw in out.split(separator: "\n") {
let line = String(raw)
let fields = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard fields.count >= 5 else { continue }
let proto = fields[0].lowercased()
let local = fields[3]
let foreign = fields[4]
guard isVNCLocal(local) else { continue }
if proto.hasPrefix("tcp") {
let state = fields.count >= 6 ? fields[5] : ""
if state == "ESTABLISHED", isRealPeer(foreign) {
estab += 1
} else if state == "LISTEN" {
listen += 1
}
} else if proto.hasPrefix("udp") {
udpTotal += 1
if isRealPeer(foreign) { udpPeered += 1 }
}
}
return "tcp_estab=\(estab) tcp_listen=\(listen) udp=\(udpTotal) udp_peered=\(udpPeered)"
}
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
func flatten(_ dict: [String: Any]) -> [String: String] {
var flat: [String: String] = [:]
for (k, v) in dict { flat[k] = String(describing: v) }
return flat
}
print("Curtain detection probe — Ctrl-C to stop")
// Dump the full session dictionary once so the baseline is on record.
var lastDict = flatten(sessionDict())
print("CGSession dictionary at start:")
for (k, v) in lastDict.sorted(by: { $0.key < $1.key }) {
print(" \(k) = \(v)")
}
print("time | captured | onConsole | processes | netstat 5900-5902")
while true {
let dict = sessionDict()
let captured = boolValue(dict, captureKey)
let onConsole = boolValue(dict, consoleKey)
let stamp = formatter.string(from: Date())
let line = "\(stamp) | "
+ "captured=\(captured) | "
+ "onConsole=\(onConsole) | "
+ "procs=\(screenShareProcesses()) | "
+ vncSockets()
print(line)
// Diff the full dictionary: any key that moves during a connection is a
// candidate detection signal, even ones we have never heard of.
let now = flatten(dict)
for (k, v) in now.sorted(by: { $0.key < $1.key }) where lastDict[k] != v {
print("\(stamp) | DICT \(k): \(lastDict[k] ?? "(absent)") -> \(v)")
}
for k in lastDict.keys.sorted() where now[k] == nil {
print("\(stamp) | DICT \(k): removed")
}
lastDict = now
fflush(stdout)
Thread.sleep(forTimeInterval: 1.0)
}