mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
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.
151 lines
5.7 KiB
Swift
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)
|
|
}
|