From 39b9057058d74609e5ea93fbcf9bea0f2e997d4c Mon Sep 17 00:00:00 2001 From: Matt MacDougall Date: Sun, 18 Oct 2026 00:54:14 -0400 Subject: [PATCH] Deliver the sensor-offline alert out of band from the app (#63) The one alert the sensor can never send is that it has gone away. Until now the app noticed after five minutes, appended a Medium alert to its own list, and told nobody: the synthetic alert never went through onAlertNotificationsRequested, so it did not even reach Notification Center, and nothing left the Mac. Now the app delivers that alert itself: - Notification Center, through the same hook WebSocket alerts use, so the banner shows with the window closed. - Slack, through the operator's configured webhook, formatted exactly as the sensor formats its own Slack messages and honoring the Slack minimum severity. The sensor redacts the webhook URL on every read, so the app caches it in its own Keychain item at the one moment it holds the real value: when the operator types it in Settings. Clearing the field removes the copy. Posting uses an ephemeral URLSession. - Severity is High when the sensor had been live earlier in this app session and Medium when it never connected. Also fixes the outage timer guard. It required exactly .disconnected, but reconnects run every 30 s, so a health check in flight at the deadline dropped the alert for the rest of the outage. .connecting now counts as offline; authFailed and incompatible still do not, since the sensor answered. APNs stays sensor-side: the app never holds relay credentials and has no UI to enter them. Docs: USER_GUIDE alert table and reconnect paragraph, a "where deliveries come from" note under Alert Methods, Credential Safety, and the SECURITY_MODEL stored-secrets section. --- app/Sources/SquirrelOpsHome/App.swift | 6 + .../Connection/SensorConnectionService.swift | 61 +++- .../Desktop/MacNotificationService.swift | 11 +- .../Desktop/OfflineAlertDelivery.swift | 278 ++++++++++++++++++ .../Views/Dashboard/SettingsView.swift | 23 ++ .../ConnectionServiceTests.swift | 108 +++++++ .../OfflineAlertDeliveryTests.swift | 233 +++++++++++++++ docs/SECURITY_MODEL.md | 9 + docs/USER_GUIDE.md | 29 +- 9 files changed, 734 insertions(+), 24 deletions(-) create mode 100644 app/Sources/SquirrelOpsHome/Desktop/OfflineAlertDelivery.swift create mode 100644 app/Tests/SquirrelOpsHomeTests/OfflineAlertDeliveryTests.swift diff --git a/app/Sources/SquirrelOpsHome/App.swift b/app/Sources/SquirrelOpsHome/App.swift index 01ffe75..b440804 100644 --- a/app/Sources/SquirrelOpsHome/App.swift +++ b/app/Sources/SquirrelOpsHome/App.swift @@ -148,6 +148,12 @@ struct SquirrelOpsHomeApp: App { localSensorProbe: { [pairingManager] in await pairingManager.detectLocalSensor() != nil }, + offlineDelivery: { alert in + await OfflineAlertDispatcher.shared.deliver( + alert, + detail: "The SquirrelOps Home app has not been able to reach the sensor for five minutes." + ) + }, onEvent: { [appState] frame in Task { @MainActor in WSEventProcessor.process(frame, into: appState) diff --git a/app/Sources/SquirrelOpsHome/Connection/SensorConnectionService.swift b/app/Sources/SquirrelOpsHome/Connection/SensorConnectionService.swift index a7ad254..28a81f5 100644 --- a/app/Sources/SquirrelOpsHome/Connection/SensorConnectionService.swift +++ b/app/Sources/SquirrelOpsHome/Connection/SensorConnectionService.swift @@ -43,6 +43,13 @@ public final class SensorConnectionService: @unchecked Sendable { private let appState: AppState? private let localSensorProbe: @Sendable () async -> Bool private let onEvent: @Sendable (WSFrame) -> Void + /// How long an outage lasts before the app raises its own alert. + private let offlineAlertDelay: Duration + /// Out-of-band delivery for the one alert the sensor cannot send about itself. + private let offlineDelivery: @Sendable (AlertSummary) async -> Void + /// Whether this session has ever reached `.live`. Losing a sensor that was + /// working is High; never finding one is Medium. + private var hasBeenLive = false private var reconnectTask: Task? private var listenTask: Task? private var learningPollTask: Task? @@ -58,12 +65,16 @@ public final class SensorConnectionService: @unchecked Sendable { webSocketManager: any WebSocketManagerProtocol, appState: AppState? = nil, localSensorProbe: @escaping @Sendable () async -> Bool = { false }, + offlineAlertDelay: Duration = .seconds(300), + offlineDelivery: @escaping @Sendable (AlertSummary) async -> Void = { _ in }, onEvent: @escaping @Sendable (WSFrame) -> Void ) { self.sensorClient = sensorClient self.webSocketManager = webSocketManager self.appState = appState self.localSensorProbe = localSensorProbe + self.offlineAlertDelay = offlineAlertDelay + self.offlineDelivery = offlineDelivery self.onEvent = onEvent } @@ -71,15 +82,17 @@ public final class SensorConnectionService: @unchecked Sendable { @MainActor private static var syntheticIdCounter: Int = -1 - /// Creates a synthetic "Sensor Disconnected" alert for local display. - @MainActor public static func makeDisconnectAlert() -> AlertSummary { + /// Creates a synthetic "Sensor Disconnected" alert. It is local to the app + /// (the sensor cannot report its own absence) and is also what the app + /// hands to out-of-band delivery. + @MainActor public static func makeDisconnectAlert(previouslyConnected: Bool = false) -> AlertSummary { let id = syntheticIdCounter syntheticIdCounter -= 1 return AlertSummary( id: id, incidentId: nil, alertType: "system.sensor_offline", - severity: "medium", + severity: previouslyConnected ? "high" : "medium", title: "Sensor Disconnected", sourceIp: nil, readAt: nil, @@ -242,6 +255,7 @@ public final class SensorConnectionService: @unchecked Sendable { // Go live state = .live + hasBeenLive = true await syncStateAsync() disconnectAlertTask?.cancel() disconnectAlertTask = nil @@ -405,21 +419,48 @@ public final class SensorConnectionService: @unchecked Sendable { await self.connect(baseURL: baseURL, certFingerprint: certFingerprint) } - // Start 5-minute alert timer if not already running + // Start the outage timer if not already running. It is cancelled only + // by going live or by an explicit disconnect, so one outage raises one + // alert. if disconnectAlertTask == nil { disconnectAlertTask = Task { [weak self] in do { - try await Task.sleep(for: .seconds(300)) + try await self?.sleepForOfflineAlert() } catch { return } - guard let self, self.state == .disconnected else { return } - await MainActor.run { - let alert = SensorConnectionService.makeDisconnectAlert() - self.appState?.addAlert(alert) - } + guard let self else { return } + await self.raiseOfflineAlert() } } } + private func sleepForOfflineAlert() async throws { + try await Task.sleep(for: offlineAlertDelay) + } + + /// States in which the sensor is not answering. A reconnect attempt that is + /// mid-flight when the timer fires is still an outage; `authFailed` and + /// `incompatible` mean the sensor answered and are not. + static func isOffline(_ state: ConnectionState) -> Bool { + switch state { + case .disconnected, .connecting: return true + case .connected, .syncing, .live, .authFailed, .incompatible: return false + } + } + + private func raiseOfflineAlert() async { + guard Self.isOffline(state) else { return } + let previouslyConnected = hasBeenLive + let alert = await MainActor.run { + let alert = SensorConnectionService.makeDisconnectAlert(previouslyConnected: previouslyConnected) + self.appState?.addAlert(alert) + // Same path WebSocket alerts take to Notification Center, so the + // banner shows even when the window is closed. + self.appState?.onAlertNotificationsRequested?([alert]) + return alert + } + await offlineDelivery(alert) + } + private func reconnectDelay() -> Int { 30 } diff --git a/app/Sources/SquirrelOpsHome/Desktop/MacNotificationService.swift b/app/Sources/SquirrelOpsHome/Desktop/MacNotificationService.swift index d34d6e5..b5e59a4 100644 --- a/app/Sources/SquirrelOpsHome/Desktop/MacNotificationService.swift +++ b/app/Sources/SquirrelOpsHome/Desktop/MacNotificationService.swift @@ -15,16 +15,7 @@ enum MacNotificationPolicy { isSilenced: Bool ) -> Bool { guard enabled, !isSilenced, alert.readAt == nil else { return false } - return severityRank(alert.severity) >= severityRank(minimumSeverity) - } - - private static func severityRank(_ severity: String) -> Int { - switch severity.lowercased() { - case "critical": return 3 - case "high": return 2 - case "medium": return 1 - default: return 0 - } + return AlertSeverityRank.rank(alert.severity) >= AlertSeverityRank.rank(minimumSeverity) } } diff --git a/app/Sources/SquirrelOpsHome/Desktop/OfflineAlertDelivery.swift b/app/Sources/SquirrelOpsHome/Desktop/OfflineAlertDelivery.swift new file mode 100644 index 0000000..9869dc4 --- /dev/null +++ b/app/Sources/SquirrelOpsHome/Desktop/OfflineAlertDelivery.swift @@ -0,0 +1,278 @@ +import Foundation +import os + +// The sensor is the normal alert dispatcher. The one alert it can never send +// is "the sensor is gone", so the app delivers that one itself through the +// same Slack webhook the operator configured, formatted the way the sensor +// formats everything else. +// +// The sensor redacts the webhook URL on every configuration response, so the +// only moment the app ever sees it is when the operator types it in Settings. +// `SlackDeliveryCache` keeps that value in the app's Keychain at that moment; +// the non-secret parts (enabled, minimum severity) ride along in defaults and +// are refreshed from every configuration load. + +// MARK: - Severity ranking + +enum AlertSeverityRank { + static func rank(_ severity: String) -> Int { + switch severity.lowercased() { + case "critical": return 3 + case "high": return 2 + case "medium": return 1 + default: return 0 + } + } +} + +// MARK: - Formatter + +/// Mirrors `format_slack_payload` in the sensor so an app-sent message is +/// indistinguishable from a sensor-sent one in the channel. +enum SlackAlertFormatter { + static func emoji(for severity: String) -> String { + switch severity.lowercased() { + case "critical": return "\u{1F534}" + case "high": return "\u{1F7E0}" + case "medium": return "\u{1F7E1}" + default: return "\u{1F535}" + } + } + + static func payload(for alert: AlertSummary, detail: String?) -> [String: Any] { + let severity = alert.severity.lowercased() + let emoji = emoji(for: severity) + let upper = severity.uppercased() + + var blocks: [[String: Any]] = [ + [ + "type": "header", + "text": ["type": "plain_text", "text": "\(emoji) \(alert.title)"], + ], + [ + "type": "section", + "fields": [ + ["type": "mrkdwn", "text": "*Severity:* \(emoji) \(upper)"], + ["type": "mrkdwn", "text": "*Type:* `\(alert.alertType)`"], + ], + ], + ] + + if let sourceIp = alert.sourceIp, !sourceIp.isEmpty { + blocks.append([ + "type": "section", + "fields": [ + ["type": "mrkdwn", "text": "*Source IP:* `\(sourceIp)`"], + ["type": "mrkdwn", "text": "*Time:* \(alert.createdAt)"], + ], + ]) + } else { + blocks.append([ + "type": "section", + "fields": [["type": "mrkdwn", "text": "*Time:* \(alert.createdAt)"]], + ]) + } + + if let detail, !detail.isEmpty { + blocks.append([ + "type": "section", + "text": ["type": "mrkdwn", "text": "_\(detail)_"], + ]) + } + + return ["text": "\(emoji) [\(upper)] \(alert.title)", "blocks": blocks] + } +} + +// MARK: - Secret storage + +protocol SlackSecretStore: Sendable { + func save(_ value: String, account: String) throws + func load(account: String) throws -> String? + func delete(account: String) throws +} + +struct KeychainSlackSecretStore: SlackSecretStore { + func save(_ value: String, account: String) throws { + try KeychainStore.storePassword(value, account: account) + } + + func load(account: String) throws -> String? { + do { + return try KeychainStore.loadPassword(account: account) + } catch KeychainError.itemNotFound { + return nil + } + } + + func delete(account: String) throws { + try KeychainStore.deletePassword(account: account) + } +} + +// MARK: - Cache + +struct SlackDeliveryTarget: Equatable, Sendable { + let enabled: Bool + let webhookURL: URL + let minimumSeverity: String +} + +final class SlackDeliveryCache: @unchecked Sendable { + /// The sensor's `config_secrets.REDACTED`. Echoed back on every load, it + /// means "unchanged" and must never overwrite a real cached URL. + static let redactedSentinel = "__squirrelops_redacted__" + + static let shared = SlackDeliveryCache( + defaults: .standard, + secrets: KeychainSlackSecretStore() + ) + + private static let webhookAccount = "alert_methods.slack.webhook_url" + private static let enabledKey = "slackDeliveryEnabled" + private static let minimumSeverityKey = "slackDeliveryMinimumSeverity" + + private let defaults: UserDefaults + private let secrets: any SlackSecretStore + private let lock = NSLock() + private let logger = Logger(subsystem: "io.squirrelops.home", category: "SlackDeliveryCache") + + init(defaults: UserDefaults, secrets: any SlackSecretStore) { + self.defaults = defaults + self.secrets = secrets + } + + /// Record what the operator saved or what the sensor reported. `webhookURL` + /// is written only when it is a real URL; the redaction sentinel leaves the + /// stored secret alone and an empty string removes it. + func record(enabled: Bool, webhookURL: String?, minimumSeverity: String?) { + lock.withLock { + defaults.set(enabled, forKey: Self.enabledKey) + if let minimumSeverity, !minimumSeverity.isEmpty { + defaults.set(minimumSeverity, forKey: Self.minimumSeverityKey) + } + + guard let webhookURL else { return } + let trimmed = webhookURL.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed == Self.redactedSentinel { return } + do { + if trimmed.isEmpty { + try secrets.delete(account: Self.webhookAccount) + } else if Self.validWebhook(trimmed) != nil { + try secrets.save(trimmed, account: Self.webhookAccount) + } else { + // Not something we would ever post to; drop any stale value + // rather than keep delivering to an address the operator + // has since changed. + try secrets.delete(account: Self.webhookAccount) + } + } catch { + logger.error("Slack webhook cache update failed: \(error.localizedDescription, privacy: .public)") + } + } + } + + func target() -> SlackDeliveryTarget? { + lock.withLock { + let stored: String? + do { + stored = try secrets.load(account: Self.webhookAccount) + } catch { + logger.error("Slack webhook cache read failed: \(error.localizedDescription, privacy: .public)") + return nil + } + guard let stored, let url = Self.validWebhook(stored) else { return nil } + return SlackDeliveryTarget( + enabled: defaults.bool(forKey: Self.enabledKey), + webhookURL: url, + minimumSeverity: defaults.string(forKey: Self.minimumSeverityKey) ?? "low" + ) + } + } + + func clear() { + lock.withLock { + defaults.removeObject(forKey: Self.enabledKey) + defaults.removeObject(forKey: Self.minimumSeverityKey) + try? secrets.delete(account: Self.webhookAccount) + } + } + + private static func validWebhook(_ raw: String) -> URL? { + guard let url = URL(string: raw), + url.scheme?.lowercased() == "https", + let host = url.host, !host.isEmpty else { return nil } + return url + } +} + +// MARK: - Dispatcher + +enum OfflineDeliveryOutcome: Equatable, Sendable { + enum SkipReason: Equatable, Sendable { + case notConfigured + case disabled + case belowMinimumSeverity + } + + case delivered + case skipped(SkipReason) + case failed(String) +} + +final class OfflineAlertDispatcher: Sendable { + typealias Post = @Sendable (URLRequest) async throws -> (Data, URLResponse) + + static let shared = OfflineAlertDispatcher(cache: .shared) + + private let cache: SlackDeliveryCache + private let post: Post + private let logger = Logger(subsystem: "io.squirrelops.home", category: "OfflineAlertDispatcher") + + init(cache: SlackDeliveryCache, post: Post? = nil) { + self.cache = cache + self.post = post ?? { request in + // Ephemeral: nothing about the webhook or the message touches disk. + let session = URLSession(configuration: .ephemeral) + defer { session.finishTasksAndInvalidate() } + return try await session.data(for: request) + } + } + + /// Deliver one alert through the cached Slack target. Never throws; the + /// caller is a connection-loss path that must not fail on a webhook error. + @discardableResult + func deliver(_ alert: AlertSummary, detail: String?) async -> OfflineDeliveryOutcome { + guard let target = cache.target() else { return .skipped(.notConfigured) } + guard target.enabled else { return .skipped(.disabled) } + guard AlertSeverityRank.rank(alert.severity) >= AlertSeverityRank.rank(target.minimumSeverity) else { + return .skipped(.belowMinimumSeverity) + } + + var request = URLRequest(url: target.webhookURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = 10 + do { + request.httpBody = try JSONSerialization.data( + withJSONObject: SlackAlertFormatter.payload(for: alert, detail: detail) + ) + let (_, response) = try await post(request) + guard let http = response as? HTTPURLResponse else { + return failure("Slack webhook returned a non-HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + return failure("Slack webhook returned HTTP \(http.statusCode)") + } + logger.info("Delivered \(alert.alertType, privacy: .public) to Slack from the app") + return .delivered + } catch { + return failure("Slack webhook request failed (\(type(of: error)))") + } + } + + private func failure(_ message: String) -> OfflineDeliveryOutcome { + logger.error("\(message, privacy: .public)") + return .failed(message) + } +} diff --git a/app/Sources/SquirrelOpsHome/Views/Dashboard/SettingsView.swift b/app/Sources/SquirrelOpsHome/Views/Dashboard/SettingsView.swift index 4c576bd..987a6e0 100644 --- a/app/Sources/SquirrelOpsHome/Views/Dashboard/SettingsView.swift +++ b/app/Sources/SquirrelOpsHome/Views/Dashboard/SettingsView.swift @@ -792,6 +792,9 @@ struct SettingsView: View { } } if case .object(let slack) = methods["slack"] { + // Non-secret fields refresh the offline-delivery cache; the + // redacted URL leaves the cached secret untouched. + Self.recordSlackDelivery(slack) if case .bool(let enabled) = slack["enabled"] { slackEnabled = enabled } @@ -1110,8 +1113,28 @@ struct SettingsView: View { scheduleLLMConfigSave() } + private static func recordSlackDelivery(_ config: [String: AnyCodableValue]) { + var enabled = false + if case .bool(let value) = config["enabled"] { enabled = value } + var webhookURL: String? + if case .string(let value) = config["webhook_url"] { webhookURL = value } + var minimumSeverity: String? + if case .string(let value) = config["min_severity"] { minimumSeverity = value } + SlackDeliveryCache.shared.record( + enabled: enabled, + webhookURL: webhookURL, + minimumSeverity: minimumSeverity + ) + } + private func saveAlertMethod(_ method: String, config: [String: AnyCodableValue]) { saveError = nil + if method == "slack" { + // The sensor redacts the webhook on every read, so this save is the + // only time the app holds the real URL. Cache it for the alert the + // sensor can never send: that it has gone offline. + Self.recordSlackDelivery(config) + } Task { do { let body: [String: AnyCodableValue] = [method: .object(config)] diff --git a/app/Tests/SquirrelOpsHomeTests/ConnectionServiceTests.swift b/app/Tests/SquirrelOpsHomeTests/ConnectionServiceTests.swift index d0389b1..d7c57bf 100644 --- a/app/Tests/SquirrelOpsHomeTests/ConnectionServiceTests.swift +++ b/app/Tests/SquirrelOpsHomeTests/ConnectionServiceTests.swift @@ -127,6 +127,9 @@ final class MockWSManager: WebSocketManagerProtocol, @unchecked Sendable { var requestedReplaySeq: Int? { lock.withLock { _requestedReplaySeq } } var framesToDeliver: [WSFrame] = [] + /// When set, the stream ends after the queued frames, which the service + /// treats as a lost connection. + var finishStreamAfterFrames = false func connect() { lock.withLock { _isConnected = true } } func disconnect() { lock.withLock { _isConnected = false } } @@ -148,6 +151,7 @@ final class MockWSManager: WebSocketManagerProtocol, @unchecked Sendable { for frame in frames { continuation.yield(frame) } // A live WebSocket remains open after replay completes. Individual // tests disconnect the service, which cancels the consumer task. + if finishStreamAfterFrames { continuation.finish() } } } } @@ -537,3 +541,107 @@ struct DisconnectAlertTests { #expect(alert1.id != alert2.id) } } + + +// MARK: - Offline alert delivery + +/// Collects what the service hands to the out-of-band delivery hook. +private final class OfflineDeliveryRecorder: @unchecked Sendable { + private let lock = NSLock() + private var _alerts: [AlertSummary] = [] + var alerts: [AlertSummary] { lock.withLock { _alerts } } + func record(_ alert: AlertSummary) { lock.withLock { _alerts.append(alert) } } +} + +@MainActor +private func waitUntil(timeout: Duration = .seconds(3), _ condition: @MainActor () -> Bool) async { + let deadline = ContinuousClock.now + timeout + while !condition() && ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(20)) + } +} + +@Suite("Offline alert out of band") +struct OfflineAlertOutOfBandTests { + + @Test("Never-connected outage is Medium and goes to Notification Center and the delivery hook") + @MainActor + func neverConnectedIsMedium() async { + let client = MockSensorClient() + client.shouldFail = true + let appState = AppState() + let notified = OfflineDeliveryRecorder() + let delivered = OfflineDeliveryRecorder() + appState.onAlertNotificationsRequested = { alerts in alerts.forEach(notified.record) } + + let service = SensorConnectionService( + sensorClient: client, + webSocketManager: MockWSManager(), + appState: appState, + offlineAlertDelay: .milliseconds(50), + offlineDelivery: { alert in delivered.record(alert) }, + onEvent: { _ in } + ) + defer { service.disconnect() } + + await service.connect(baseURL: URL(string: "https://192.168.1.50:8443")!, certFingerprint: "sha256:test") + #expect(service.state == .disconnected) + + await waitUntil { !delivered.alerts.isEmpty } + + let alert = appState.alerts.first { $0.alertType == "system.sensor_offline" } + #expect(alert?.severity == "medium") + #expect(notified.alerts.map(\.id) == [alert?.id]) + #expect(delivered.alerts.map(\.id) == [alert?.id]) + } + + @Test("Losing a live sensor is High") + @MainActor + func previouslyLiveIsHigh() async { + let client = MockSensorClient() + let wsManager = MockWSManager() + wsManager.framesToDeliver = [.replayComplete(lastSeq: 0)] + wsManager.finishStreamAfterFrames = true + let appState = AppState() + let delivered = OfflineDeliveryRecorder() + + let service = SensorConnectionService( + sensorClient: client, + webSocketManager: wsManager, + appState: appState, + offlineAlertDelay: .milliseconds(50), + offlineDelivery: { alert in delivered.record(alert) }, + onEvent: { _ in } + ) + defer { service.disconnect() } + + await service.connect(baseURL: URL(string: "https://192.168.1.50:8443")!, certFingerprint: "sha256:test") + // The stream ends right after replay, which the listener treats as a lost connection. + await waitUntil { !delivered.alerts.isEmpty } + + #expect(service.state == .disconnected) + #expect(delivered.alerts.first?.severity == "high") + #expect(appState.alerts.first { $0.alertType == "system.sensor_offline" }?.severity == "high") + } + + @Test("A reconnect attempt in flight at the deadline still raises the alert") + @MainActor + func connectingAtDeadlineStillAlerts() async { + // Reconnects run every 30 s and a health check can be mid-flight when the + // five-minute timer fires. The old guard required exactly .disconnected + // and silently dropped the alert for the rest of the outage. + #expect(SensorConnectionService.isOffline(.connecting)) + #expect(SensorConnectionService.isOffline(.disconnected)) + #expect(!SensorConnectionService.isOffline(.live)) + #expect(!SensorConnectionService.isOffline(.authFailed)) + #expect(!SensorConnectionService.isOffline(.incompatible)) + } + + @Test("Synthetic alert severity follows prior connectivity") + @MainActor + func syntheticSeverity() { + #expect(SensorConnectionService.makeDisconnectAlert(previouslyConnected: false).severity == "medium") + #expect(SensorConnectionService.makeDisconnectAlert(previouslyConnected: true).severity == "high") + #expect(SensorConnectionService.makeDisconnectAlert().severity == "medium") + } +} diff --git a/app/Tests/SquirrelOpsHomeTests/OfflineAlertDeliveryTests.swift b/app/Tests/SquirrelOpsHomeTests/OfflineAlertDeliveryTests.swift new file mode 100644 index 0000000..3c06576 --- /dev/null +++ b/app/Tests/SquirrelOpsHomeTests/OfflineAlertDeliveryTests.swift @@ -0,0 +1,233 @@ +import Foundation +import Testing + +@testable import SquirrelOpsHome + +// MARK: - Test doubles + +final class InMemorySlackSecretStore: SlackSecretStore, @unchecked Sendable { + private let lock = NSLock() + private var values: [String: String] = [:] + + func save(_ value: String, account: String) throws { + lock.withLock { values[account] = value } + } + + func load(account: String) throws -> String? { + lock.withLock { values[account] } + } + + func delete(account: String) throws { + _ = lock.withLock { values.removeValue(forKey: account) } + } +} + +private func makeCache() -> SlackDeliveryCache { + let defaults = UserDefaults(suiteName: "OfflineAlertDeliveryTests.\(UUID().uuidString)")! + return SlackDeliveryCache(defaults: defaults, secrets: InMemorySlackSecretStore()) +} + +private func offlineAlert(severity: String = "high") -> AlertSummary { + AlertSummary( + id: -1, + alertType: "system.sensor_offline", + severity: severity, + title: "Sensor Disconnected", + createdAt: "2026-10-18T12:00:00Z" + ) +} + +// MARK: - Formatter + +@Suite("Slack alert formatter") +struct SlackAlertFormatterTests { + + @Test("Offline alert renders the same Block Kit shape the sensor sends") + func matchesSensorShape() throws { + let payload = SlackAlertFormatter.payload( + for: offlineAlert(severity: "high"), + detail: "The app has not reached the sensor for 5 minutes." + ) + + #expect(payload["text"] as? String == "\u{1F7E0} [HIGH] Sensor Disconnected") + + let blocks = try #require(payload["blocks"] as? [[String: Any]]) + #expect(blocks.count == 4) + + let header = try #require(blocks[0]["text"] as? [String: String]) + #expect(blocks[0]["type"] as? String == "header") + #expect(header["type"] == "plain_text") + #expect(header["text"] == "\u{1F7E0} Sensor Disconnected") + + let fields = try #require(blocks[1]["fields"] as? [[String: String]]) + #expect(fields.map { $0["text"] } == ["*Severity:* \u{1F7E0} HIGH", "*Type:* `system.sensor_offline`"]) + + // No source IP on an offline alert, so the time stands alone. + let timeFields = try #require(blocks[2]["fields"] as? [[String: String]]) + #expect(timeFields.map { $0["text"] } == ["*Time:* 2026-10-18T12:00:00Z"]) + + let detail = try #require(blocks[3]["text"] as? [String: String]) + #expect(detail["text"] == "_The app has not reached the sensor for 5 minutes._") + } + + @Test("Severity emoji follow the sensor's map") + func severityEmoji() { + #expect(SlackAlertFormatter.emoji(for: "critical") == "\u{1F534}") + #expect(SlackAlertFormatter.emoji(for: "high") == "\u{1F7E0}") + #expect(SlackAlertFormatter.emoji(for: "medium") == "\u{1F7E1}") + #expect(SlackAlertFormatter.emoji(for: "low") == "\u{1F535}") + } + + @Test("Source IP, when present, shares a section with the time") + func sourceIPSection() throws { + let alert = AlertSummary( + id: 5, alertType: "decoy.trip", severity: "medium", title: "Decoy connection", + sourceIp: "192.168.1.9", createdAt: "2026-10-18T12:00:00Z" + ) + let payload = SlackAlertFormatter.payload(for: alert, detail: nil) + let blocks = try #require(payload["blocks"] as? [[String: Any]]) + #expect(blocks.count == 3) + let fields = try #require(blocks[2]["fields"] as? [[String: String]]) + #expect(fields.map { $0["text"] } == ["*Source IP:* `192.168.1.9`", "*Time:* 2026-10-18T12:00:00Z"]) + } +} + +// MARK: - Cache + +@Suite("Slack delivery cache") +struct SlackDeliveryCacheTests { + + @Test("Records a real webhook URL typed by the operator") + func recordsRealURL() throws { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "high") + + let target = try #require(cache.target()) + #expect(target.enabled) + #expect(target.webhookURL.absoluteString == "https://hooks.slack.com/services/T0/B0/x") + #expect(target.minimumSeverity == "high") + } + + @Test("The sensor's redaction sentinel never replaces a cached URL") + func ignoresRedactedSentinel() throws { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + cache.record(enabled: true, webhookURL: SlackDeliveryCache.redactedSentinel, minimumSeverity: "critical") + + let target = try #require(cache.target()) + #expect(target.webhookURL.absoluteString == "https://hooks.slack.com/services/T0/B0/x") + #expect(target.minimumSeverity == "critical") + } + + @Test("Disabling keeps the URL but yields a disabled target") + func disableKeepsURL() throws { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + cache.record(enabled: false, webhookURL: SlackDeliveryCache.redactedSentinel, minimumSeverity: "low") + + let target = try #require(cache.target()) + #expect(target.enabled == false) + #expect(target.webhookURL.absoluteString == "https://hooks.slack.com/services/T0/B0/x") + } + + @Test("An emptied URL removes the cached secret") + func emptyURLClears() { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + cache.record(enabled: true, webhookURL: "", minimumSeverity: "low") + #expect(cache.target() == nil) + } + + @Test("Only https Slack-shaped URLs are cached") + func rejectsNonHTTPS() { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "http://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + #expect(cache.target() == nil) + cache.record(enabled: true, webhookURL: "not a url", minimumSeverity: "low") + #expect(cache.target() == nil) + } + + @Test("Nothing cached means no target") + func emptyCache() { + #expect(makeCache().target() == nil) + } +} + +// MARK: - Dispatcher + +@Suite("Offline alert dispatcher") +struct OfflineAlertDispatcherTests { + + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var _requests: [URLRequest] = [] + var requests: [URLRequest] { lock.withLock { _requests } } + func append(_ r: URLRequest) { lock.withLock { _requests.append(r) } } + } + + private func dispatcher( + cache: SlackDeliveryCache, + status: Int = 200, + recorder: Recorder + ) -> OfflineAlertDispatcher { + OfflineAlertDispatcher(cache: cache) { request in + recorder.append(request) + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + return (Data("ok".utf8), response) + } + } + + @Test("Posts the formatted payload to the cached webhook") + func postsToWebhook() async throws { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "medium") + let recorder = Recorder() + + let delivered = await dispatcher(cache: cache, recorder: recorder) + .deliver(offlineAlert(severity: "high"), detail: "detail") + + #expect(delivered == .delivered) + let request = try #require(recorder.requests.first) + #expect(request.url?.absoluteString == "https://hooks.slack.com/services/T0/B0/x") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + let body = try JSONSerialization.jsonObject(with: try #require(request.httpBody)) as! [String: Any] + #expect(body["text"] as? String == "\u{1F7E0} [HIGH] Sensor Disconnected") + } + + @Test("Honors the operator's minimum severity") + func belowMinimumSkips() async { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "high") + let recorder = Recorder() + + let outcome = await dispatcher(cache: cache, recorder: recorder) + .deliver(offlineAlert(severity: "medium"), detail: nil) + + #expect(outcome == .skipped(.belowMinimumSeverity)) + #expect(recorder.requests.isEmpty) + } + + @Test("Disabled or unconfigured Slack sends nothing") + func disabledSkips() async { + let disabled = makeCache() + disabled.record(enabled: false, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + let recorder = Recorder() + + #expect(await dispatcher(cache: disabled, recorder: recorder).deliver(offlineAlert(), detail: nil) == .skipped(.disabled)) + #expect(await dispatcher(cache: makeCache(), recorder: recorder).deliver(offlineAlert(), detail: nil) == .skipped(.notConfigured)) + #expect(recorder.requests.isEmpty) + } + + @Test("A webhook failure is reported, not thrown") + func failureIsReported() async { + let cache = makeCache() + cache.record(enabled: true, webhookURL: "https://hooks.slack.com/services/T0/B0/x", minimumSeverity: "low") + let recorder = Recorder() + + let outcome = await dispatcher(cache: cache, status: 500, recorder: recorder) + .deliver(offlineAlert(), detail: nil) + + #expect(outcome == .failed("Slack webhook returned HTTP 500")) + } +} diff --git a/docs/SECURITY_MODEL.md b/docs/SECURITY_MODEL.md index e7ecbdc..62ea865 100644 --- a/docs/SECURITY_MODEL.md +++ b/docs/SECURITY_MODEL.md @@ -239,6 +239,15 @@ TLS keys and configuration credentials are stored in the encrypted secret store. Runtime YAML contains non-secret configuration only. Legacy plaintext configuration credentials are migrated and scrubbed at startup. +The Mac app holds one configuration credential of its own: a copy of the Slack +webhook URL, in a Keychain generic-password item under the app's service name. +The sensor redacts the URL in every configuration response, so the copy is +written only when the operator enters the URL in Settings and is deleted when +the field is cleared. The app uses it for the single alert the sensor cannot +send, Sensor Disconnected, and posts through an ephemeral URLSession so neither +the URL nor the message is cached on disk. APNs relay credentials are never +held by the app. + Python components resolve operating-system tools through an absolute path allowlist, never the service `PATH`. An allow-listed path is accepted only if the resolved binary is a root-owned, non-group/other-writable regular diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d7abee3..99caa6e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -916,7 +916,7 @@ Every individual connection is still retained in the decoy's forensic log. | Decoy Trip | High | Unexpected decoy activity; automatic printer discovery is retained without an intrusion alert | | Security Insight | Medium–High | A risky port or service is open on one or more devices (e.g., SSH, VNC, unencrypted admin interfaces) | | ARP Ownership Conflict | High | The scan cannot establish a unique IP/MAC owner | -| Sensor Disconnected | Medium | The Mac app has been disconnected for five minutes; this alert is local to the app | +| Sensor Disconnected | Medium or High | The Mac app has not reached the sensor for five minutes. High when the sensor was connected earlier in this app session, Medium when it never was. Raised by the app, not the sensor, and delivered by the app to Notification Center and to your Slack webhook (see Alert Methods) | New-device, MAC-change, and verification events exist in the sensor, but there is no runtime subscriber turning them into alert-feed rows. Behavioral anomaly, @@ -1032,6 +1032,18 @@ app. APNs delivery is not a ready-to-enable iPhone/Mac feature in Settings. **Slack Webhook** — Posts alert summaries to a Slack channel via an incoming webhook URL. +**Where deliveries come from.** The sensor sends every alert it raises (decoy +trips, credential trips, security insights, device alerts) through the methods +above. One alert cannot come from the sensor: that the sensor itself is +unreachable. The app sends that one (**Sensor Disconnected**) itself, to +Notification Center and to the same Slack webhook, honoring the Slack minimum +severity. To make that possible the app keeps a copy of the webhook URL in its +own macOS Keychain entry from the moment you type it in Settings; the sensor +redacts the URL in every response, so there is no other time the app sees it. +Clearing the Webhook URL field removes the app's copy. The sensor's manually +configured APNs relay is not used by the app: the app never holds the relay +credentials. + When Slack is enabled, additional options appear: - **Webhook URL** — Your Slack incoming webhook URL - **Minimum Severity** — Which alerts trigger a Slack message @@ -1257,8 +1269,14 @@ checking automatically for 20 minutes and shows how long it has waited. The Sensor Not Responding screen appears only after that recovery window expires. The app reconnects automatically on a 30-second interval. After five minutes -of disconnection, it adds a Medium-severity **Sensor Disconnected** alert to -the local app view; this is not a sensor-persisted alert. +of disconnection it raises a **Sensor Disconnected** alert: High if the sensor +had been connected earlier in this app session, Medium if it never was. The +alert is created by the app, so it is not persisted by the sensor, but it is +delivered the same way sensor alerts are: a macOS notification (subject to your +macOS Notifications setting) and a Slack message when Slack is enabled and the +alert meets your Slack minimum severity. The Slack message comes from the Mac +running the app, which is the point: it still arrives when the sensor host has +been stopped. ### No Alerts Appearing @@ -1473,5 +1491,8 @@ On macOS, the sensor uses pfctl packet filter rules (loaded into a dedicated `co The `planted_credentials` table contains generated deception bait. Separately, operator-supplied Home Assistant tokens, AI keys, Slack webhook URLs, and APNs delivery secrets are stored in the encrypted secret store and redacted from -configuration responses. Pairing and TLS keys are also retained locally. +configuration responses. The Mac app additionally keeps the Slack webhook URL +in its own Keychain entry so it can report a sensor outage to Slack; that copy +is written only when you enter the URL in Settings and is removed when you +clear the field. Pairing and TLS keys are also retained locally. Synthetic bait must never be replaced with real service credentials.