From a47c53e4a460bfa5194a29aa9c406f8c434be2fd Mon Sep 17 00:00:00 2001 From: Jared Henderson Date: Fri, 20 Dec 2024 15:00:51 -0500 Subject: [PATCH 1/3] macapp: extract FilterProxy to test logic in FilterDataProvider --- .../App/Sources/Filter/Decision+Early.swift | 4 + macapp/App/Sources/Filter/Decision+Flow.swift | 8 +- macapp/App/Sources/Filter/Decision.swift | 14 ++ macapp/App/Sources/Filter/Filter.swift | 2 + macapp/App/Sources/Filter/FilterProxy.swift | 225 ++++++++++++++++++ macapp/App/Sources/Filter/FilterStore.swift | 14 +- .../Tests/FilterTests/FilterProxyTests.swift | 191 +++++++++++++++ .../FilterDataProvider.swift | 168 +------------ 8 files changed, 466 insertions(+), 160 deletions(-) create mode 100644 macapp/App/Sources/Filter/FilterProxy.swift create mode 100644 macapp/App/Tests/FilterTests/FilterProxyTests.swift diff --git a/macapp/App/Sources/Filter/Decision+Early.swift b/macapp/App/Sources/Filter/Decision+Early.swift index 59f4e117..9bb627a5 100644 --- a/macapp/App/Sources/Filter/Decision+Early.swift +++ b/macapp/App/Sources/Filter/Decision+Early.swift @@ -4,6 +4,10 @@ import os.log public extension NetworkFilter { func earlyUserDecision(auditToken: Data?) -> FilterDecision.FromUserId { + #if DEBUG + if let mock = self.__TEST_MOCK_EARLY_DECISION { return mock } + #endif + guard let userId = self.security.userIdFromAuditToken(auditToken) else { return self.logDecision(.block(.missingUserId)) } diff --git a/macapp/App/Sources/Filter/Decision+Flow.swift b/macapp/App/Sources/Filter/Decision+Flow.swift index 437ac671..992cfa63 100644 --- a/macapp/App/Sources/Filter/Decision+Flow.swift +++ b/macapp/App/Sources/Filter/Decision+Flow.swift @@ -8,7 +8,10 @@ public extension NetworkFilter { _ flow: FilterFlow, auditToken: Data? = nil ) -> FilterDecision.FromFlow? { - self.flowDecision(flow, auditToken: auditToken, canDefer: true) + #if DEBUG + if let mock = self.__TEST_MOCK_FLOW_DECISION { return mock } + #endif + return self.flowDecision(flow, auditToken: auditToken, canDefer: true) } func completedFlowDecision( @@ -16,6 +19,9 @@ public extension NetworkFilter { readBytes: Data, auditToken: Data? = nil ) -> FilterDecision.FromFlow { + #if DEBUG + if case .some(.some(let mock)) = self.__TEST_MOCK_FLOW_DECISION { return mock } + #endif if flow.url == nil { flow.parseOutboundData(byteString: bytesToAscii(readBytes)) } diff --git a/macapp/App/Sources/Filter/Decision.swift b/macapp/App/Sources/Filter/Decision.swift index 53adfbb9..5d51edba 100644 --- a/macapp/App/Sources/Filter/Decision.swift +++ b/macapp/App/Sources/Filter/Decision.swift @@ -9,6 +9,15 @@ public protocol NetworkFilter: AppDescribing { var now: Date { get } var calendar: Calendar { get } func log(event: FilterLogs.Event) + + #if DEBUG + // this is a bit of a compromise, because the FilterProxy was introduced + // to extract the growing complexity of the logic in the FilterDataProvider + // and now there are one too many layers for ergonomic testing, eventually + // we should collapse them, but this should be safe and explicit for now + var __TEST_MOCK_EARLY_DECISION: FilterDecision.FromUserId? { get set } + var __TEST_MOCK_FLOW_DECISION: FilterDecision.FromFlow?? { get set } + #endif } public extension NetworkFilter { @@ -16,6 +25,11 @@ public extension NetworkFilter { func rootApp(fromAuditToken token: Data?) -> SecurityClient.RootApp { security.rootAppFromAuditToken(token) } + + #if DEBUG + var __TEST_MOCK_EARLY_DECISION: FilterDecision.FromUserId? { get { nil } set {} } + var __TEST_MOCK_FLOW_DECISION: FilterDecision.FromFlow?? { get { nil } set {} } + #endif } public protocol DecisionState { diff --git a/macapp/App/Sources/Filter/Filter.swift b/macapp/App/Sources/Filter/Filter.swift index bf46afff..8b1a9487 100644 --- a/macapp/App/Sources/Filter/Filter.swift +++ b/macapp/App/Sources/Filter/Filter.swift @@ -14,6 +14,8 @@ public struct Filter: Reducer, Sendable { public var appCache: [String: AppDescriptor] = [:] public var blockListeners: [uid_t: Date] = [:] public var logs: FilterLogs = .init(bundleIds: [:], events: [:]) + + public init() {} } public enum Action: Equatable, Sendable { diff --git a/macapp/App/Sources/Filter/FilterProxy.swift b/macapp/App/Sources/Filter/FilterProxy.swift new file mode 100644 index 00000000..4804179a --- /dev/null +++ b/macapp/App/Sources/Filter/FilterProxy.swift @@ -0,0 +1,225 @@ +import Combine +import Core +import Foundation +import NetworkExtension +import os.log + +public class FilterProxy { + #if !DEBUG + let store: FilterStore + #else + var store: FilterStore + #endif + + var flowUserIds: [UUID: uid_t] = [:] + var cancellables: Set = [] + + // give the FilterDataProvider a simple boolean it can + // quickly check to decide if it needs to pass the decision + // on to the store - the vast majority of the time, nothing + // needs to be sent, and the filter is sometimes making a huge + // number of decisions, so keeping this lookup as fast as possible + // for the normal case is important + var sendingBlockDecisions = false + + // right now we just log in verbose mode whenever we're streaming blocks + // but in the future, we might have a separate boolean and an app->filter message + // to enable this for a specific period + var verboseLogging: Bool { self.sendingBlockDecisions } + + public func handleNewFlow(_ flow: NEFilterFlow.DTO) -> NEFilterNewFlowVerdict { + let userId: uid_t + let earlyUserDecision = self.store.earlyUserDecision(auditToken: flow.sourceAppAuditToken) + if self.verboseLogging { + os_log("[D•] FILTER received new flow: %{public}s", "\(flow.description)") + os_log("[D•] FILTER early user decision: %{public}@", "\(earlyUserDecision)") + } + + let filterFlow: FilterFlow + + switch earlyUserDecision { + case .block: + return dropNewFlow() + case .allow: + return .allow() + case .blockDuringDowntime(let id): + userId = id + filterFlow = FilterFlow(flow, userId: userId) + if filterFlow.isFromGertrude || filterFlow.isSystemUiServerInternal { + if self.verboseLogging { + os_log( + "[D•] FILTER ALLOW during downtime, bundleId: %{public}s", + "\(filterFlow.bundleId ?? "(nil)")" + ) + } + return .allow() + } else { + if self.verboseLogging { + os_log( + "[D•] FILTER DROP during downtime, bundleId: %{public}s", + "\(filterFlow.bundleId ?? "(nil)")" + ) + } + return dropNewFlow() + } + case .none(let id): + userId = id + filterFlow = FilterFlow(flow, userId: userId) + } + + self.store.logAppRequest(from: filterFlow.bundleId) + + let decision = self.store.newFlowDecision(filterFlow, auditToken: flow.sourceAppAuditToken) + if self.verboseLogging { + switch decision { + case .some(let decision): + os_log("[D•] FILTER new flow decision: %{public}@", "\(decision)") + case .none: + os_log("[D•] FILTER new flow decision: DEFER") + } + } + + switch decision { + case .block: + if self.sendingBlockDecisions { + self.store.sendBlocked(filterFlow, auditToken: flow.sourceAppAuditToken) + } + return dropNewFlow() + case .allow: + return .allow() + case nil: + self.flowUserIds[flow.identifier] = userId + return .filterDataVerdict( + withFilterInbound: false, + peekInboundBytes: Int.max, + filterOutbound: true, + peekOutboundBytes: 1024 + ) + } + } + + public func handleOutboundData( + from flow: NEFilterFlow.DTO, + readBytes: Data + ) -> NEFilterDataVerdict { + let userId = self.flowUserIds.removeValue(forKey: flow.identifier) + + // safeguard: prevent memory leak + if self.flowUserIds.count > 100 { + self.flowUserIds = [:] + } + + var filterFlow = FilterFlow(flow, userId: userId) + let decision = self.store.completedFlowDecision( + &filterFlow, + readBytes: readBytes, + auditToken: flow.sourceAppAuditToken + ) + + if self.verboseLogging { + os_log("[D•] FILTER outbound flow: %{public}s", "\(filterFlow.shortDescription)") + os_log("[D•] FILTER outbound flow decision: %{public}@", "\(decision)") + os_log("[D•] FILTER outbound flow bytes: %{public}s", bytesToAscii(readBytes)) + } + + switch decision { + case .block: + if self.sendingBlockDecisions { + self.store.sendBlocked(filterFlow, auditToken: flow.sourceAppAuditToken) + } + return dropFlow() + case .allow: + return .allow() + } + } + + public func startFilter() { + self.store.shouldSendBlockDecisions().sink { [weak self] in + os_log("[G•] FILTER data provider: toggle send block decisions %{public}d", $0) + self?.sendingBlockDecisions = $0 + }.store(in: &self.cancellables) + } + + public func filterSettings() -> NEFilterSettings { + let networkRule = NENetworkRule( + remoteNetwork: nil, + remotePrefix: 0, + localNetwork: nil, + localPrefix: 0, + protocol: .any, + direction: .outbound + ) + + let filterRule = NEFilterRule(networkRule: networkRule, action: .filterData) + return NEFilterSettings(rules: [filterRule], defaultAction: .allow) + } + + public func sendExtensionStopping() { + self.store.sendExtensionStopping() + } + + public func sendExtensionStarted() { + self.store.sendExtensionStarted() + } + + public init(store: FilterStore = .init()) { + self.store = store + } +} + +public extension NEFilterFlow { + /// A data transfer object for `NEFilterFlow`. + /// NB: the original object has more information + struct DTO { + let identifier: UUID + let sourceAppAuditToken: Data? + let description: String + let url: URL? + + public init( + identifier: UUID = .init(), + sourceAppAuditToken: Data? = nil, + description: String, + url: URL? = nil + ) { + self.identifier = identifier + self.sourceAppAuditToken = sourceAppAuditToken + self.description = description + self.url = url + } + } + + var dto: DTO { .init( + identifier: self.identifier, + sourceAppAuditToken: self.sourceAppAuditToken, + description: self.description, + url: self.url + ) } +} + +extension FilterFlow { + init(_ flow: NEFilterFlow.DTO, userId: uid_t? = nil) { + self.init(url: flow.url?.absoluteString, description: flow.description) + self.userId = userId + } +} + +private func dropFlow() -> NEFilterDataVerdict { + #if canImport(XCTest) + return .drop() + #elseif DEBUG + return .allow() + #else + return .drop() + #endif +} + +private func dropNewFlow() -> NEFilterNewFlowVerdict { + #if canImport(XCTest) + return .drop() + #elseif DEBUG + return .allow() + #else + return .drop() + #endif +} diff --git a/macapp/App/Sources/Filter/FilterStore.swift b/macapp/App/Sources/Filter/FilterStore.swift index cf2013a0..f4087cac 100644 --- a/macapp/App/Sources/Filter/FilterStore.swift +++ b/macapp/App/Sources/Filter/FilterStore.swift @@ -10,14 +10,18 @@ public struct FilterStore: NetworkFilter { public var state: Filter.State { self.viewStore.state } + #if DEBUG + public var __TEST_MOCK_EARLY_DECISION: FilterDecision.FromUserId? + public var __TEST_MOCK_FLOW_DECISION: FilterDecision.FromFlow?? + #endif + @Dependency(\.security) public var security @Dependency(\.date.now) public var now @Dependency(\.calendar) public var calendar - public init() { - self.store = Store(initialState: Filter.State(), reducer: { Filter() }) + public init(initialState: Filter.State = .init()) { + self.store = Store(initialState: initialState, reducer: { Filter() }) self.viewStore = ViewStore(self.store, observe: { $0 }) - self.viewStore.send(.extensionStarted) } public func sendBlocked(_ flow: FilterFlow, auditToken: Data?) { @@ -47,6 +51,10 @@ public struct FilterStore: NetworkFilter { self.viewStore.send(.logEvent(event)) } + public func sendExtensionStarted() { + self.viewStore.send(.extensionStarted) + } + public func sendExtensionStopping() { self.viewStore.send(.extensionStopping) } diff --git a/macapp/App/Tests/FilterTests/FilterProxyTests.swift b/macapp/App/Tests/FilterTests/FilterProxyTests.swift new file mode 100644 index 00000000..c5e38802 --- /dev/null +++ b/macapp/App/Tests/FilterTests/FilterProxyTests.swift @@ -0,0 +1,191 @@ +import ConcurrencyExtras +import Core +import Dependencies +import NetworkExtension +import TestSupport +import XCTest +import XExpect + +@testable import Filter + +final class FilterProxyTests: XCTestCase { + func testEarlyDecisionBlockBecomesDropWithNoLog() { + let proxy = FilterProxy(earlyDecision: .block(.missingUserId)) + let verdict = proxy.handleNewFlow(.mock) + expect(verdict.isDrop).toBeTrue() + // this is current behavior, but i'm not certain we don't want to log here + // as i'm not sure if this ever happens in practice + expect(proxy.store.state.logs.bundleIds).toEqual([:]) // <-- not logged + } + + func testEarlyDecisionAllowBecomesAllowWithNoLog() { + let proxy = FilterProxy(earlyDecision: .allow(.systemUser(501))) + let verdict = proxy.handleNewFlow(.mock) + expect(verdict.isDrop).toBeFalse() + expect(proxy.store.state.logs.bundleIds).toEqual([:]) // current behavior + } + + func testEarlyDecisionAllowingGertrudeDuringDowntime() { + let proxy = FilterProxy(earlyDecision: .blockDuringDowntime(501)) + let verdict = proxy.handleNewFlow(.gertrude) + expect(verdict.isDrop).toBeFalse() + expect(proxy.store.state.logs.bundleIds).toEqual([:]) + } + + func testEarlyDecisionAllowingSystemUIServerInternalDuringDowntime() { + let proxy = FilterProxy(earlyDecision: .blockDuringDowntime(501)) + let verdict = proxy.handleNewFlow(.init(description: """ + sourceAppIdentifier = .com.apple.systemuiserver + remoteEndpoint = 192.168.0.1:8080 + """)) + expect(verdict.isDrop).toBeFalse() + expect(proxy.store.state.logs.bundleIds).toEqual([:]) + } + + func testEarlyDecisionBlockingDuringDowntime() { + let proxy = FilterProxy(earlyDecision: .blockDuringDowntime(501)) + let verdict = proxy.handleNewFlow(.mock) + expect(verdict.isDrop).toBeTrue() + expect(proxy.store.state.logs.bundleIds).toEqual([:]) // current behavior + } + + func testBlockedNewFlowWithoutPeekingBytesDropsAndLogs() { + let proxy = FilterProxy( + earlyDecision: .none(501), + flowDecision: .block(.defaultNotAllowed) + ) + let verdict = proxy.handleNewFlow(.mock) + expect(verdict.isDrop).toBeTrue() + expect(proxy.store.state.logs.bundleIds).toEqual(["com.acme.app": 1]) + } + + @MainActor + func testBlockedNewFlowSendsDecisionIfSending() async { + let sent = LockIsolated<[String]>([]) + await withDependencies { + $0.filterExtension.version = { "2.6.0" } + $0.date = .constant(.init(timeIntervalSinceReferenceDate: 0)) + $0.uuid = .incrementing + $0.xpc.sendBlockedRequest = { _, req in + sent.withValue { $0.append(req.app.bundleId) } + } + } operation: { + let proxy = FilterProxy( + earlyDecision: .none(501), + flowDecision: .block(.defaultNotAllowed) + ) { + $0.blockListeners[501] = .distantFuture + } + proxy.sendingBlockDecisions = true // <-- sending decisions + let verdict = proxy.handleNewFlow(.mock) + expect(verdict.isDrop).toBeTrue() + await Task.megaYield() + expect(sent.value).toEqual(["com.acme.app"]) + } + } + + func testAllowedNewFlowWithoutPeekingBytesAllowsAndLogs() { + let proxy = FilterProxy( + earlyDecision: .none(501), + flowDecision: .allow(.permittedByKey(.init())) + ) + let verdict = proxy.handleNewFlow(.mock) + expect(verdict.isDrop).toBeFalse() + expect(proxy.store.state.logs.bundleIds).toEqual(["com.acme.app": 1]) + } + + func testDeferredNewFlowLogsAndSetsUserId() { + let proxy = FilterProxy(flowDecision: .some(.none)) + let flow = NEFilterFlow.DTO.mock + let verdict = proxy.handleNewFlow(flow) + expect(verdict.isExamineBytes).toBeTrue() + expect(proxy.flowUserIds[flow.identifier]).toEqual(501) + expect(proxy.store.state.logs.bundleIds).toEqual(["com.acme.app": 1]) + } + + func testOutboundFlowDecisionBlock() { + let proxy = FilterProxy(flowDecision: .block(.defaultNotAllowed)) + let flow = NEFilterFlow.DTO.mock + proxy.flowUserIds[flow.identifier] = 501 + + let verdict = proxy.handleOutboundData(from: flow, readBytes: .init()) + expect(verdict.isDrop).toBeTrue() + expect(proxy.flowUserIds).toEqual([:]) // removes the flow + } + + func testOutboundFlowDecisionAllow() { + let proxy = FilterProxy(flowDecision: .allow(.permittedByKey(.init()))) + let flow = NEFilterFlow.DTO.mock + proxy.flowUserIds[flow.identifier] = 501 + + let verdict = proxy.handleOutboundData(from: flow, readBytes: .init()) + expect(verdict.isDrop).toBeFalse() + expect(proxy.flowUserIds).toEqual([:]) // removes the flow + } + + @MainActor + func testBlockedDataFlowSendsDecisionIfSending() async { + let sent = LockIsolated<[String]>([]) + await withDependencies { + $0.filterExtension.version = { "2.6.0" } + $0.date = .constant(.init(timeIntervalSinceReferenceDate: 0)) + $0.uuid = .incrementing + $0.xpc.sendBlockedRequest = { _, req in + sent.withValue { $0.append(req.app.bundleId) } + } + } operation: { + let proxy = FilterProxy(flowDecision: .block(.defaultNotAllowed)) { + $0.blockListeners[501] = .distantFuture + } + let flow = NEFilterFlow.DTO.mock + proxy.flowUserIds[flow.identifier] = 501 + proxy.sendingBlockDecisions = true // <-- sending decisions + let verdict = proxy.handleOutboundData(from: flow, readBytes: .init()) + expect(verdict.isDrop).toBeTrue() + await Task.megaYield() + expect(sent.value).toEqual(["com.acme.app"]) + } + } +} + +// helpers + +extension NEFilterFlow.DTO { + static var mock: Self { + .init(description: "sourceAppIdentifier = com.acme.app") + } + + static var gertrude: Self { + .init(description: "sourceAppIdentifier = com.netrivet.gertrude.app") + } +} + +extension FilterProxy { + convenience init( + earlyDecision: FilterDecision.FromUserId = .none(501), + flowDecision: FilterDecision.FromFlow?? = .some(.some(.block(.defaultNotAllowed))), + config: (inout Filter.State) -> Void = { _ in } + ) { + var state = Filter.State() + config(&state) + self.init(store: .init(initialState: state)) + self.store.__TEST_MOCK_EARLY_DECISION = earlyDecision + self.store.__TEST_MOCK_FLOW_DECISION = flowDecision + } +} + +extension NEFilterDataVerdict { + var isDrop: Bool { + self.description.contains("drop = YES") + } +} + +extension NEFilterNewFlowVerdict { + var isDrop: Bool { + self.description.contains("drop = YES") + } + + var isExamineBytes: Bool { + !self.isDrop && self.description.contains("filterOutbound = YES") + } +} diff --git a/macapp/Xcode/GertrudeFilterExtension/FilterDataProvider.swift b/macapp/Xcode/GertrudeFilterExtension/FilterDataProvider.swift index a785bcc0..966ce2d5 100644 --- a/macapp/Xcode/GertrudeFilterExtension/FilterDataProvider.swift +++ b/macapp/Xcode/GertrudeFilterExtension/FilterDataProvider.swift @@ -5,50 +5,24 @@ import NetworkExtension import os.log class FilterDataProvider: NEFilterDataProvider { - let store = FilterStore() - var flowUserIds: [UUID: uid_t] = [:] - var cancellables: Set = [] + let proxy = FilterProxy() - // give the FilterDataProvider a simple boolean it can - // quickly check to decide if it needs to pass the decision - // on to the store - the vast majority of the time, nothing - // needs to be sent, and the filter is sometimes making a huge - // number of decisions, so keeping this lookup as fast as possible - // for the normal case is important - var sendingBlockDecisions = false - - // right now we just log in verbose mode whenever we're streaming blocks - // but in the future, we might have a separate boolean and an app->filter message - // to enable this for a specific period - var verboseLogging: Bool { sendingBlockDecisions } + override init() { + super.init() + self.proxy.sendExtensionStarted() + } override func startFilter(completionHandler: @escaping (Error?) -> Void) { - let networkRule = NENetworkRule( - remoteNetwork: nil, - remotePrefix: 0, - localNetwork: nil, - localPrefix: 0, - protocol: .any, - direction: .outbound - ) - - let filterRule = NEFilterRule(networkRule: networkRule, action: .filterData) - let filterSettings = NEFilterSettings(rules: [filterRule], defaultAction: .allow) - - apply(filterSettings) { errorOrNil in - completionHandler(errorOrNil) - if let error = errorOrNil { + self.apply(self.proxy.filterSettings()) { error in + completionHandler(error) + if let error { os_log( "[G•] FILTER data provider: error applying filter settings: %{public}s", error.localizedDescription ) } } - - self.store.shouldSendBlockDecisions().sink { [weak self] in - os_log("[G•] FILTER data provider: toggle send block decisions %{public}d", $0) - self?.sendingBlockDecisions = $0 - }.store(in: &self.cancellables) + self.proxy.startFilter() } override func stopFilter( @@ -60,74 +34,7 @@ class FilterDataProvider: NEFilterDataProvider { } override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict { - let userId: uid_t - let earlyUserDecision = self.store.earlyUserDecision(auditToken: flow.sourceAppAuditToken) - if self.verboseLogging { - os_log("[D•] FILTER received new flow: %{public}s", "\(flow.description)") - os_log("[D•] FILTER early user decision: %{public}@", "\(earlyUserDecision)") - } - - let filterFlow: FilterFlow - - switch earlyUserDecision { - case .block: - return dropNewFlow() - case .allow: - return .allow() - case .blockDuringDowntime(let id): - userId = id - filterFlow = FilterFlow(flow, userId: userId) - if filterFlow.isFromGertrude || filterFlow.isSystemUiServerInternal { - if self.verboseLogging { - os_log( - "[D•] FILTER ALLOW during downtime, bundleId: %{public}s", - "\(filterFlow.bundleId ?? "(nil)")" - ) - } - return .allow() - } else { - if self.verboseLogging { - os_log( - "[D•] FILTER DROP during downtime, bundleId: %{public}s", - "\(filterFlow.bundleId ?? "(nil)")" - ) - } - return dropNewFlow() - } - case .none(let id): - userId = id - filterFlow = FilterFlow(flow, userId: userId) - } - - self.store.logAppRequest(from: filterFlow.bundleId) - - let decision = self.store.newFlowDecision(filterFlow, auditToken: flow.sourceAppAuditToken) - if self.verboseLogging { - switch decision { - case .some(let decision): - os_log("[D•] FILTER new flow decision: %{public}@", "\(decision)") - case .none: - os_log("[D•] FILTER new flow decision: DEFER") - } - } - - switch decision { - case .block: - if self.sendingBlockDecisions { - self.store.sendBlocked(filterFlow, auditToken: flow.sourceAppAuditToken) - } - return dropNewFlow() - case .allow: - return .allow() - case nil: - self.flowUserIds[flow.identifier] = userId - return .filterDataVerdict( - withFilterInbound: false, - peekInboundBytes: Int.max, - filterOutbound: true, - peekOutboundBytes: 1024 - ) - } + self.proxy.handleNewFlow(flow.dto) } override func handleOutboundData( @@ -135,61 +42,10 @@ class FilterDataProvider: NEFilterDataProvider { readBytesStartOffset _: Int, readBytes: Data ) -> NEFilterDataVerdict { - let userId = self.flowUserIds.removeValue(forKey: flow.identifier) - - // safeguard: prevent memory leak - if self.flowUserIds.count > 100 { - self.flowUserIds = [:] - } - - var filterFlow = FilterFlow(flow, userId: userId) - let decision = self.store.completedFlowDecision( - &filterFlow, - readBytes: readBytes, - auditToken: flow.sourceAppAuditToken - ) - - if self.verboseLogging { - os_log("[D•] FILTER outbound flow: %{public}s", "\(filterFlow.shortDescription)") - os_log("[D•] FILTER outbound flow decision: %{public}@", "\(decision)") - os_log("[D•] FILTER outbound flow bytes: %{public}s", bytesToAscii(readBytes)) - } - - switch decision { - case .block: - if self.sendingBlockDecisions { - self.store.sendBlocked(filterFlow, auditToken: flow.sourceAppAuditToken) - } - return dropFlow() - case .allow: - return .allow() - } + self.proxy.handleOutboundData(from: flow.dto, readBytes: readBytes) } deinit { - store.sendExtensionStopping() - } -} - -private func dropFlow() -> NEFilterDataVerdict { - #if DEBUG - return .allow() - #else - return .drop() - #endif -} - -private func dropNewFlow() -> NEFilterNewFlowVerdict { - #if DEBUG - return .allow() - #else - return .drop() - #endif -} - -extension FilterFlow { - init(_ rawFlow: NEFilterFlow, userId: uid_t? = nil) { - self.init(url: rawFlow.url?.absoluteString, description: rawFlow.description) - self.userId = userId + self.proxy.sendExtensionStopping() } } From 12ac4483ddc5eee4ac832651bfa7942c0987a5c3 Mon Sep 17 00:00:00 2001 From: Jared Henderson Date: Fri, 27 Dec 2024 14:26:58 -0500 Subject: [PATCH 2/3] macapp: filter blocks user reqs if macapp awol --- justfile | 5 +- .../App/AdminWindow/AdminWindowFeature.swift | 4 +- macapp/App/Sources/App/AppReducer.swift | 45 +++++++---- .../App/Sources/App/ApplicationFeature.swift | 6 +- .../App/Sources/App/FilterControlling.swift | 52 ++++++------ macapp/App/Sources/App/FilterFeature.swift | 6 +- .../App/Onboarding/OnboardingFeature.swift | 80 ++++++++++--------- .../Sources/App/UserConnectionFeature.swift | 1 + .../ClientInterfaces/FilterXPCClient.swift | 13 +++ macapp/App/Sources/Core/FilterDecision.swift | 11 ++- macapp/App/Sources/Core/XPCInterfaces.swift | 4 + macapp/App/Sources/Core/XPCTypes.swift | 50 ++++++++++++ .../App/Sources/Filter/Decision+Early.swift | 3 +- macapp/App/Sources/Filter/Decision+Flow.swift | 14 +++- macapp/App/Sources/Filter/Decision.swift | 1 + macapp/App/Sources/Filter/Filter.swift | 31 +++++++ macapp/App/Sources/Filter/FilterProxy.swift | 23 +++--- macapp/App/Sources/Filter/FilterStore.swift | 4 + .../Sources/Filter/ReceiveAppMessage.swift | 14 ++++ macapp/App/Sources/Filter/Types.swift | 1 + .../LiveFilterXPCClient/FilterXPC.swift | 6 ++ .../LiveFilterXPCClient.swift | 23 ++++++ .../App/Sources/Relauncher/Relauncher.swift | 2 +- .../App/Tests/AppTests/AppReducerTests.swift | 1 + .../AppTests/ApplicationFeatureTests.swift | 10 +++ .../Tests/AppTests/FilterFeatureTests.swift | 9 +++ .../AppTests/OnboardingFeatureTests.swift | 2 + .../FilterTests/EarlyDecisionTests.swift | 17 ++++ .../FilterTests/FilterMigratorTests.swift | 4 +- .../Tests/FilterTests/FilterProxyTests.swift | 29 +++++++ .../FilterTests/FilterReducerTests.swift | 42 +++++++++- .../FilterTests/NewFlowDecisionTests.swift | 20 +++++ macapp/App/Tests/FilterTests/TestFilter.swift | 5 +- 33 files changed, 433 insertions(+), 105 deletions(-) diff --git a/justfile b/justfile index 29795412..01531484 100644 --- a/justfile +++ b/justfile @@ -18,7 +18,7 @@ codegen-typescript: codegen: codegen-typescript codegen-swift -xcode: +macapp: @open macapp/Xcode/Gertrude.xcodeproj # ios @@ -26,6 +26,9 @@ xcode: watch-ios: @just watch-build iosapp/lib-ios +iosapp: + @open iosapp/Gertrude-iOS.xcodeproj + # api watch-api: diff --git a/macapp/App/Sources/App/AdminWindow/AdminWindowFeature.swift b/macapp/App/Sources/App/AdminWindow/AdminWindowFeature.swift index e7f6a5dd..562af2a0 100644 --- a/macapp/App/Sources/App/AdminWindow/AdminWindowFeature.swift +++ b/macapp/App/Sources/App/AdminWindow/AdminWindowFeature.swift @@ -279,14 +279,14 @@ extension AdminWindowFeature.RootReducer { case .webview(.healthCheck(.enableFilterClicked)): state.adminWindow.healthCheck.filterStatus = nil return .merge( - .exec { try await startFilter($0) }, + .exec { try await self.startFilter($0) }, self.withTimeoutAfter(seconds: 3) ) case .webview(.healthCheck(.installFilterClicked)): state.adminWindow.healthCheck.filterStatus = .installing return .merge( - .exec { try await installFilter($0) }, + .exec { try await self.installFilter($0) }, self.withTimeoutAfter(seconds: 20) ) diff --git a/macapp/App/Sources/App/AppReducer.swift b/macapp/App/Sources/App/AppReducer.swift index 57381965..6e0798bc 100644 --- a/macapp/App/Sources/App/AppReducer.swift +++ b/macapp/App/Sources/App/AppReducer.swift @@ -42,6 +42,7 @@ struct AppReducer: Reducer, Sendable { enum CancelId { case heartbeatInterval case websocketMessages + case networkConnectionChanges } enum Action: Equatable, Sendable { @@ -75,6 +76,7 @@ struct AppReducer: Reducer, Sendable { case startProtecting(user: UserData) case websocket(WebSocketFeature.Action) case setTrustedTimestamp(TrustedTimestamp) + case networkConnectionChanged(connected: Bool) indirect case adminAuthed(Action) } @@ -87,6 +89,7 @@ struct AppReducer: Reducer, Sendable { @Dependency(\.network) var network @Dependency(\.storage) var storage @Dependency(\.websocket) var websocket + @Dependency(\.filterXpc) var xpc var body: some ReducerOf { Reduce { state, action in @@ -98,7 +101,7 @@ struct AppReducer: Reducer, Sendable { case .loadedPersistentState(.none): state.onboarding.windowOpen = true return .exec { [new = state.persistent] _ in - try await storage.savePersistentState(new) + try await self.storage.savePersistentState(new) } case .loadedPersistentState(.some(let persisted)): @@ -122,7 +125,7 @@ struct AppReducer: Reducer, Sendable { effects.append(.exec { [persist = state.persistent] _ in var withoutResume = persist withoutResume.resumeOnboarding = nil - try await storage.savePersistentState(withoutResume) + try await self.storage.savePersistentState(withoutResume) }) } return .merge(effects) @@ -130,35 +133,46 @@ struct AppReducer: Reducer, Sendable { case .startProtecting(let user): let onboardingWindowOpen = state.onboarding.windowOpen return .merge( + .exec { [filterVersion = state.filter.version] send in - await api.setUserToken(user.token) - guard network.isConnected() else { return } + await self.api.setUserToken(user.token) + guard self.network.isConnected() else { return } await send(.checkIn( - result: TaskResult { try await api.appCheckIn(filterVersion) }, + result: TaskResult { try await self.api.appCheckIn(filterVersion) }, reason: .startProtecting )) }, + .exec { _ in - if onboardingWindowOpen == false, (await app.isLaunchAtLoginEnabled()) == false { - await app.enableLaunchAtLogin() + if onboardingWindowOpen == false, (await self.app.isLaunchAtLoginEnabled()) == false { + await self.app.enableLaunchAtLogin() } }, + .publisher { - websocket.receive() + self.websocket.receive() .map { .websocket(.receivedMessage($0)) } - .receive(on: mainQueue) + .receive(on: self.mainQueue) }.cancellable(id: CancelId.websocketMessages), + + .publisher { + self.network.connectionChanges() + .map { .networkConnectionChanged(connected: $0) } + .receive(on: self.mainQueue) + }.cancellable(id: CancelId.networkConnectionChanges), + .exec { send in var numTicks = 0 - for await _ in bgQueue.timer(interval: .seconds(60)) { + for await _ in self.bgQueue.timer(interval: .seconds(60)) { numTicks += 1 for interval in heartbeatIntervals(for: numTicks) { await send(.heartbeat(interval)) } } }.cancellable(id: CancelId.heartbeatInterval), + .exec { _ in - try await app.startRelaunchWatcher() + try await self.app.startRelaunchWatcher() } ) @@ -172,9 +186,9 @@ struct AppReducer: Reducer, Sendable { return .exec { _ in switch notification { case .unexpectedError: - await device.notifyUnexpectedError() + await self.device.notifyUnexpectedError() case .text(let title, let body): - await device.showNotification(title, body) + await self.device.showNotification(title, body) } } @@ -184,7 +198,7 @@ struct AppReducer: Reducer, Sendable { return .exec { [persist = state.persistent] _ in var copy = persist copy.resumeOnboarding = resume - try await storage.savePersistentState(copy) + try await self.storage.savePersistentState(copy) } case .onboarding(.delegate(.onboardingConfigComplete)): @@ -199,6 +213,9 @@ struct AppReducer: Reducer, Sendable { state.timestamp = timestamp return .none + case .networkConnectionChanged(connected: true): + return .exec { _ in _ = await self.xpc.sendAlive() } + default: return .none } diff --git a/macapp/App/Sources/App/ApplicationFeature.swift b/macapp/App/Sources/App/ApplicationFeature.swift index 44b1298e..660d9999 100644 --- a/macapp/App/Sources/App/ApplicationFeature.swift +++ b/macapp/App/Sources/App/ApplicationFeature.swift @@ -55,7 +55,7 @@ extension ApplicationFeature.RootReducer: RootReducing { let setupState = await self.filterExtension.setup() await send(.filter(.receivedState(setupState))) if setupState.installed { - _ = await filterXpc.establishConnection() + _ = await self.filterXpc.establishConnection() } }, @@ -140,10 +140,14 @@ extension ApplicationFeature.RootReducer: RootReducing { } } + case .application(.didWake): + return .exec { _ in _ = await self.filterXpc.sendAlive() } + case .application(.willTerminate): return .merge( .cancel(id: AppReducer.CancelId.heartbeatInterval), .cancel(id: AppReducer.CancelId.websocketMessages), + .cancel(id: AppReducer.CancelId.networkConnectionChanges), .exec { _ in await self.app.stopRelaunchWatcher() } ) diff --git a/macapp/App/Sources/App/FilterControlling.swift b/macapp/App/Sources/App/FilterControlling.swift index 07fd340e..a670b158 100644 --- a/macapp/App/Sources/App/FilterControlling.swift +++ b/macapp/App/Sources/App/FilterControlling.swift @@ -13,30 +13,30 @@ protocol FilterControlling: RootReducing { extension FilterControlling { func installFilter(_ send: Send) async throws { - await api.securityEvent(.systemExtensionChangeRequested, "install") - _ = await filter.install() - try await mainQueue.sleep(for: .milliseconds(10)) - let result = await xpc.establishConnection() + await self.api.securityEvent(.systemExtensionChangeRequested, "install") + _ = await self.filter.install() + try await self.mainQueue.sleep(for: .milliseconds(10)) + let result = await self.xpc.establishConnection() os_log("[G•] APP FilterControlling.installFilter() result: %{public}s", "\(result)") - await afterFilterChange(send, repairing: false) + await self.afterFilterChange(send, repairing: false) } func restartFilter(_ send: Send) async throws { - await api.securityEvent(.systemExtensionChangeRequested, "restart") - _ = await filter.restart() - try await mainQueue.sleep(for: .milliseconds(100)) - let result = await xpc.establishConnection() + await self.api.securityEvent(.systemExtensionChangeRequested, "restart") + _ = await self.filter.restart() + try await self.mainQueue.sleep(for: .milliseconds(100)) + let result = await self.xpc.establishConnection() os_log("[G•] APP FilterControlling.restartFilter() result: %{public}s", "\(result)") - await afterFilterChange(send, repairing: false) + await self.afterFilterChange(send, repairing: false) } func startFilter(_ send: Send) async throws { - await api.securityEvent(.systemExtensionChangeRequested, "start") - _ = await filter.start() - try await mainQueue.sleep(for: .milliseconds(100)) - let result = await xpc.establishConnection() + await self.api.securityEvent(.systemExtensionChangeRequested, "start") + _ = await self.filter.start() + try await self.mainQueue.sleep(for: .milliseconds(100)) + let result = await self.xpc.establishConnection() os_log("[G•] APP FilterControlling.startFilter() result: %{public}s", "\(result)") - await afterFilterChange(send, repairing: false) + await self.afterFilterChange(send, repairing: false) } func replaceFilter( @@ -44,34 +44,34 @@ extension FilterControlling { attempt: Int = 1, reinstallOnFail: Bool = true ) async throws { - _ = await filter.replace() - await api.securityEvent(.systemExtensionChangeRequested, "replace") - var result = await xpc.establishConnection() + _ = await self.filter.replace() + await self.api.securityEvent(.systemExtensionChangeRequested, "replace") + var result = await self.xpc.establishConnection() os_log( "[G•] APP FilterControlling.replaceFilter() attempt: %{public}d, result: %{public}s", attempt, "\(result)" ) - await afterFilterChange(send, repairing: true) + await self.afterFilterChange(send, repairing: true) // trying up to 4 times seems to get past some funky states fairly // reliably, especially the one i observe locally only, where the filter // shows up in an "orange" state in the system preferences pane - if attempt < 4, await xpc.notConnected() { - return try await self.replaceFilter( + if attempt < 4, await self.xpc.notConnected() { + return try await self.self.replaceFilter( send, attempt: attempt + 1, reinstallOnFail: reinstallOnFail ) } - if reinstallOnFail, await xpc.notConnected() { + if reinstallOnFail, await self.xpc.notConnected() { os_log("[G•] APP FilterControlling.replaceFilter() failed, reinstalling") - _ = await filter.reinstall() - try await mainQueue.sleep(for: .milliseconds(500)) - result = await xpc.establishConnection() + _ = await self.filter.reinstall() + try await self.mainQueue.sleep(for: .milliseconds(500)) + result = await self.xpc.establishConnection() os_log("[G•] APP FilterControlling.replaceFilter() final: %{public}s", "\(result)") - await afterFilterChange(send, repairing: false) + await self.afterFilterChange(send, repairing: false) } } } diff --git a/macapp/App/Sources/App/FilterFeature.swift b/macapp/App/Sources/App/FilterFeature.swift index 76c4c4cc..b035fab6 100644 --- a/macapp/App/Sources/App/FilterFeature.swift +++ b/macapp/App/Sources/App/FilterFeature.swift @@ -21,6 +21,7 @@ struct FilterFeature: Feature { struct Reducer: FeatureReducer { @Dependency(\.api) var api + @Dependency(\.filterXpc) var xpc func reduce(into state: inout State, action: Action) -> Effect { switch action { @@ -134,7 +135,7 @@ extension FilterFeature.RootReducer { if let expiration = state.filter.currentSuspensionExpiration, expiration <= now { state.filter.currentSuspensionExpiration = nil } - return .none + return .exec { _ in _ = await self.xpc.sendAlive() } case .heartbeat(.everyFiveMinutes): let appVersionString = state.appUpdates.installedVersion @@ -200,7 +201,8 @@ extension FilterFeature.RootReducer { let installResult = await self.filterExtension.install() switch installResult { case .installedSuccessfully: - break + try await self.mainQueue.sleep(for: .milliseconds(10)) + _ = await self.xpc.establishConnection() case .timedOutWaiting: // event `9ffabfe5` logged w/ more detail in FilterFeature.swift await send(.focusedNotification(.filterInstallTimeout)) diff --git a/macapp/App/Sources/App/Onboarding/OnboardingFeature.swift b/macapp/App/Sources/App/Onboarding/OnboardingFeature.swift index 9d275a97..696ee05c 100644 --- a/macapp/App/Sources/App/Onboarding/OnboardingFeature.swift +++ b/macapp/App/Sources/App/Onboarding/OnboardingFeature.swift @@ -83,15 +83,15 @@ struct OnboardingFeature: Feature { state.step = step return .exec { send in await send(.receivedDeviceData( - currentUserId: device.currentUserId(), - users: try await device.listMacOSUsers() + currentUserId: self.device.currentUserId(), + users: try await self.device.listMacOSUsers() )) } case .resume(.checkingScreenRecordingPermission): state.windowOpen = true return .exec { send in - let granted = await monitoring.screenRecordingPermissionGranted() + let granted = await self.monitoring.screenRecordingPermissionGranted() log("resume checking screen recording, granted=\(granted)", "5d1d27fe") if granted { await send(.setStep(.allowScreenshots_success)) @@ -115,15 +115,15 @@ struct OnboardingFeature: Feature { state.step = self.app.inCorrectLocation ? .confirmGertrudeAccount : .wrongInstallDir return .exec { send in await send(.receivedDeviceData( - currentUserId: device.currentUserId(), - users: try await device.listMacOSUsers() + currentUserId: self.device.currentUserId(), + users: try await self.device.listMacOSUsers() )) } case .webview(.primaryBtnClicked) where step == .wrongInstallDir: log(step, action, "1d7defca") state.windowOpen = false - return .exec { _ in await app.quit() } + return .exec { _ in await self.app.quit() } case .webview(.primaryBtnClicked) where step == .confirmGertrudeAccount: log(step, action, "36a1852c") @@ -143,8 +143,8 @@ struct OnboardingFeature: Feature { case .webview(.secondaryBtnClicked) where step == .noGertrudeAccount: log("quit from no gertrude acct", "236defcb") return .exec { _ in - await storage.deleteAll() - await app.quit() + await self.storage.deleteAll() + await self.app.quit() } case .webview(.primaryBtnClicked) where step == .macosUserAccountType && !userIsAdmin: @@ -191,7 +191,7 @@ struct OnboardingFeature: Feature { state.connectChildRequest = .ongoing return .exec { send in await send(.connectUser((TaskResult { - try await api.connectUser(.init(code: code, device: device, app: app)) + try await self.api.connectUser(.init(code: code, device: device, app: app)) }))) } @@ -216,7 +216,7 @@ struct OnboardingFeature: Feature { where step == .connectChild && state.connectChildRequest.isFailed: log("connect user failed secondary", "08de43c1") return .exec { _ in - await device.openWebUrl(.contact) + await self.device.openWebUrl(.contact) } case .webview(.primaryBtnClicked) @@ -235,14 +235,14 @@ struct OnboardingFeature: Feature { log(step, action, "b183d96d") state.step = .allowNotifications_grant return .exec { _ in - await device.requestNotificationAuthorization() - await device.openSystemPrefs(.notifications) + await self.device.requestNotificationAuthorization() + await self.device.openSystemPrefs(.notifications) } case .webview(.primaryBtnClicked) where step == .allowNotifications_grant: log(step, action, "9fa094ac") return .exec { send in - if await device.notificationsSetting() == .none { + if await self.device.notificationsSetting() == .none { await send(.setStep(.allowNotifications_failed)) } else { await nextRequiredStage(from: step, send) @@ -257,9 +257,9 @@ struct OnboardingFeature: Feature { case .webview(.primaryBtnClicked) where step == .allowNotifications_failed: log(step, action, "b183d96d") return .exec { send in - if await device.notificationsSetting() == .none { - await device.requestNotificationAuthorization() - await device.openSystemPrefs(.notifications) + if await self.device.notificationsSetting() == .none { + await self.device.requestNotificationAuthorization() + await self.device.openSystemPrefs(.notifications) await send(.setStep(.allowNotifications_grant)) } else { await nextRequiredStage(from: step, send) @@ -275,12 +275,12 @@ struct OnboardingFeature: Feature { case .webview(.primaryBtnClicked) where step == .allowScreenshots_required: return .exec { send in - let granted = await monitoring.screenRecordingPermissionGranted() + let granted = await self.monitoring.screenRecordingPermissionGranted() log("primary from .allowScreenshots_required, already granted=\(granted)", "ce78b67b") if granted { await nextRequiredStage(from: step, send) } else { - try? await monitoring.takeScreenshot(500) // trigger permission prompt + try? await self.monitoring.takeScreenshot(500) // trigger permission prompt await send(.delegate(.saveForResume(.checkingScreenRecordingPermission))) await send(.setStep(.allowScreenshots_grantAndRestart)) } @@ -306,10 +306,10 @@ struct OnboardingFeature: Feature { case .webview(.primaryBtnClicked) where step == .allowScreenshots_failed: log(step, action, "cfb65d32") return .exec { send in - if await monitoring.screenRecordingPermissionGranted() { + if await self.monitoring.screenRecordingPermissionGranted() { await send(.setStep(.allowScreenshots_success)) } else { - await device.openSystemPrefs(.security(.screenRecording)) + await self.device.openSystemPrefs(.security(.screenRecording)) await send(.setStep(.allowScreenshots_grantAndRestart)) } } @@ -346,7 +346,7 @@ struct OnboardingFeature: Feature { case .webview(.primaryBtnClicked) where step == .allowKeylogging_grant: return .exec { send in - let granted = await monitoring.keystrokeRecordingPermissionGranted() + let granted = await self.monitoring.keystrokeRecordingPermissionGranted() log("primary from .allowKeylogging_grant, granted=\(granted)", "ce78b67b") if granted { await nextRequiredStage(from: step, send) @@ -358,7 +358,7 @@ struct OnboardingFeature: Feature { case .webview(.secondaryBtnClicked) where step == .allowKeylogging_grant: log(step, action, "5ccce8b9") return .exec { send in - if await monitoring.keystrokeRecordingPermissionGranted() { + if await self.monitoring.keystrokeRecordingPermissionGranted() { await nextRequiredStage(from: step, send) } else { await send(.setStep(.allowKeylogging_failed)) @@ -368,10 +368,10 @@ struct OnboardingFeature: Feature { case .webview(.primaryBtnClicked) where step == .allowKeylogging_failed: log(step, action, "36181833") return .exec { send in - if await monitoring.keystrokeRecordingPermissionGranted() { + if await self.monitoring.keystrokeRecordingPermissionGranted() { await nextRequiredStage(from: step, send) } else { - await device.openSystemPrefs(.security(.accessibility)) + await self.device.openSystemPrefs(.security(.accessibility)) await send(.setStep(.allowKeylogging_grant)) } } @@ -386,15 +386,17 @@ struct OnboardingFeature: Feature { log(step, action, "880e4b49") state.step = .installSysExt_allow return .exec { send in - try? await mainQueue.sleep(for: .seconds(3)) // let them see the explanation gif - let installResult = await systemExtension.installOverridingTimeout(60 * 4) // 4 minutes + try? await self.mainQueue.sleep(for: .seconds(3)) // let them see the explanation gif + let installResult = await self.systemExtension.installOverridingTimeout(60 * 4) log("sys ext install result=\(installResult)", "adbc0453") switch installResult { case .installedSuccessfully: await send(.setStep(.installSysExt_success)) - // safeguard, make sure onboarding user not exempted - _ = await systemExtensionXpc.setUserExemption(device.currentUserId(), false) - switch await systemExtensionXpc.requestUserTypes() { + // NB: the two xpc calls below also implicitly establish the XPC connection + // so if they are ever removed, we should call requestAck() or similar here + // NB: safeguard, make sure onboarding user not exempted + _ = await self.systemExtensionXpc.setUserExemption(self.device.currentUserId(), false) + switch await self.systemExtensionXpc.requestUserTypes() { case .success(let userTypes): await send(.receivedFilterUsers(userTypes)) case .failure(let err): @@ -417,7 +419,7 @@ struct OnboardingFeature: Feature { case .webview(.primaryBtnClicked) where step == .installSysExt_explain: return .exec { send in - let startingState = await systemExtension.state() + let startingState = await self.systemExtension.state() log("primary from .installSysExt_explain, state=\(startingState)", "e585331d") switch startingState { case .notInstalled: @@ -427,7 +429,7 @@ struct OnboardingFeature: Feature { case .installedAndRunning: await send(.setStep(.installSysExt_success)) case .installedButNotRunning: - if await systemExtension.start() == .installedAndRunning { + if await self.systemExtension.start() == .installedAndRunning { log("non-running sys ext started successfully", "d0021f5d") await send(.setStep(.installSysExt_success)) } else { @@ -449,7 +451,7 @@ struct OnboardingFeature: Feature { case .webview(.primaryBtnClicked) where step == .installSysExt_allow, .webview(.secondaryBtnClicked) where step == .installSysExt_allow: return .exec { send in - let state = await systemExtension.state() + let state = await self.systemExtension.state() log("\(action) from .installSysExt_allow, state=\(state)", "b0e6e683") if state == .installedAndRunning { await send(.setStep(.installSysExt_success)) @@ -479,7 +481,7 @@ struct OnboardingFeature: Feature { state.filterUsers?.exempt.removeAll(where: { $0 == userId }) } return .exec { _ in - _ = await systemExtensionXpc.setUserExemption(userId, enabled) + _ = await self.systemExtensionXpc.setUserExemption(userId, enabled) } case .webview(.primaryBtnClicked) where step == .exemptUsers: @@ -510,18 +512,18 @@ struct OnboardingFeature: Feature { state.windowOpen = false guard childConnected else { return .exec { _ in - let persisted = try await storage.loadPersistentState() + let persisted = try await self.storage.loadPersistentState() if persisted?.user == nil, step < .allowNotifications_start { // unexpected super early bail, so we need to delete all storage and quit // so that if they launch Gertrude again, they get the onboarding flow again - await storage.deleteAll() - await app.quit() + await self.storage.deleteAll() + await self.app.quit() } } } return .exec { send in - if await app.isLaunchAtLoginEnabled() == false { - await app.enableLaunchAtLogin() + if await self.app.isLaunchAtLoginEnabled() == false { + await self.app.enableLaunchAtLogin() } if step < .locateMenuBarIcon { // unexpected early bail, so we need to start protection @@ -631,7 +633,7 @@ extension OnboardingFeature.State.MacUser { extension OnboardingFeature.Reducer { func eventMeta() -> String { - "os: \(device.osVersion().name), sn: \(device.serialNumber() ?? ""), time: \(Date())" + "os: \(self.device.osVersion().name), sn: \(self.device.serialNumber() ?? ""), time: \(Date())" } func log(_ msg: String, _ id: String) { diff --git a/macapp/App/Sources/App/UserConnectionFeature.swift b/macapp/App/Sources/App/UserConnectionFeature.swift index 6c2f1a66..ee25b198 100644 --- a/macapp/App/Sources/App/UserConnectionFeature.swift +++ b/macapp/App/Sources/App/UserConnectionFeature.swift @@ -91,6 +91,7 @@ extension UserConnectionFeature.RootReducer { await app.stopRelaunchWatcher() }, .cancel(id: AppReducer.CancelId.heartbeatInterval), + .cancel(id: AppReducer.CancelId.networkConnectionChanges), .cancel(id: AppReducer.CancelId.websocketMessages) ) } diff --git a/macapp/App/Sources/ClientInterfaces/FilterXPCClient.swift b/macapp/App/Sources/ClientInterfaces/FilterXPCClient.swift index 42c1ec04..9484a8fe 100644 --- a/macapp/App/Sources/ClientInterfaces/FilterXPCClient.swift +++ b/macapp/App/Sources/ClientInterfaces/FilterXPCClient.swift @@ -14,7 +14,9 @@ public struct FilterXPCClient: Sendable { public var pauseDowntime: @Sendable (Date) async -> Result public var requestAck: @Sendable () async -> Result public var requestUserTypes: @Sendable () async -> Result + public var sendAlive: @Sendable () async -> Result public var sendDeleteAllStoredState: @Sendable () async -> Result + public var sendURLMessage: @Sendable (XPC.URLMessage) async -> Void public var sendUserRules: @Sendable (AppIdManifest, [RuleKeychain], Downtime?) async -> Result public var setBlockStreaming: @Sendable (Bool) async -> Result @@ -31,7 +33,9 @@ public struct FilterXPCClient: Sendable { pauseDowntime: @escaping @Sendable (Date) async -> Result, requestAck: @escaping @Sendable () async -> Result, requestUserTypes: @escaping @Sendable () async -> Result, + sendAlive: @escaping @Sendable () async -> Result, sendDeleteAllStoredState: @escaping @Sendable () async -> Result, + sendURLMessage: @escaping @Sendable (XPC.URLMessage) async -> Void, sendUserRules: @escaping @Sendable (AppIdManifest, [RuleKeychain], Downtime?) async -> Result, setBlockStreaming: @escaping @Sendable (Bool) async -> Result, @@ -47,7 +51,9 @@ public struct FilterXPCClient: Sendable { self.pauseDowntime = pauseDowntime self.requestAck = requestAck self.requestUserTypes = requestUserTypes + self.sendAlive = sendAlive self.sendDeleteAllStoredState = sendDeleteAllStoredState + self.sendURLMessage = sendURLMessage self.sendUserRules = sendUserRules self.setBlockStreaming = setBlockStreaming self.setUserExemption = setUserExemption @@ -105,10 +111,15 @@ extension FilterXPCClient: TestDependencyKey { "FilterXPCClient.requestUserTypes", placeholder: .success(.init(exempt: [], protected: [])) ), + sendAlive: unimplemented( + "FilterXPCClient.sendAlive", + placeholder: .success(true) + ), sendDeleteAllStoredState: unimplemented( "FilterXPCClient.sendDeleteAllStoredState", placeholder: .success(()) ), + sendURLMessage: unimplemented("FilterXPCClient.sendURLMessage"), sendUserRules: unimplemented( "FilterXPCClient.sendUserRules", placeholder: .success(()) @@ -147,7 +158,9 @@ extension FilterXPCClient: TestDependencyKey { numUserKeys: 0 )) }, requestUserTypes: { .success(.init(exempt: [], protected: [])) }, + sendAlive: { .success(true) }, sendDeleteAllStoredState: { .success(()) }, + sendURLMessage: { _ in }, sendUserRules: { _, _, _ in .success(()) }, setBlockStreaming: { _ in .success(()) }, setUserExemption: { _, _ in .success(()) }, diff --git a/macapp/App/Sources/Core/FilterDecision.swift b/macapp/App/Sources/Core/FilterDecision.swift index 374937f4..c226da44 100644 --- a/macapp/App/Sources/Core/FilterDecision.swift +++ b/macapp/App/Sources/Core/FilterDecision.swift @@ -6,6 +6,8 @@ public enum FilterDecision: Equatable, Sendable { case missingUserId case noUserKeys case defaultNotAllowed + case urlMessage(XPC.URLMessage) + case macappAWOL(uid_t) } public enum AllowReason: Equatable, Sendable { @@ -21,15 +23,18 @@ public enum FilterDecision: Equatable, Sendable { } public enum FromUserId: Equatable, Sendable { - public enum Reason: Equatable, Sendable { + public enum BlockReason: Equatable, Sendable { case missingUserId + } + + public enum AllowReason: Equatable, Sendable { case systemUser(uid_t) case exemptUser(uid_t) case filterSuspended(uid_t) } - case block(Reason) - case allow(Reason) + case block(BlockReason) + case allow(AllowReason) case blockDuringDowntime(uid_t) case none(uid_t) } diff --git a/macapp/App/Sources/Core/XPCInterfaces.swift b/macapp/App/Sources/Core/XPCInterfaces.swift index 27807b41..cb235dfe 100644 --- a/macapp/App/Sources/Core/XPCInterfaces.swift +++ b/macapp/App/Sources/Core/XPCInterfaces.swift @@ -8,6 +8,10 @@ public typealias XPCErrorData = Data userId: uid_t, reply: @escaping (Data?, XPCErrorData?) -> Void ) + func receiveAlive( + for userId: uid_t, + reply: @escaping (Bool, XPCErrorData?) -> Void + ) func receiveListUserTypesRequest( reply: @escaping (Data?, XPCErrorData?) -> Void ) diff --git a/macapp/App/Sources/Core/XPCTypes.swift b/macapp/App/Sources/Core/XPCTypes.swift index 0ab7535c..883f2265 100644 --- a/macapp/App/Sources/Core/XPCTypes.swift +++ b/macapp/App/Sources/Core/XPCTypes.swift @@ -72,6 +72,56 @@ public enum XPCEvent: Sendable, Equatable { } } +public extension XPC { + enum URLMessage: Sendable, Equatable { + case alive(uid_t) + case restartListener(uid_t) + + public var string: String { + switch self { + case .alive(let userId): + return "x-alive--\(userId)" + case .restartListener(let userId): + return "x-restart-listener--\(userId)" + } + } + + public var hostname: String { + "\(self.string).xpc.gertrude.app" + } + + public var url: URL { + URL(string: "https://\(self.hostname)")! + } + + public init?(string: String) { + guard string.starts(with: "x") else { + return nil + } + if string.starts(with: "x-alive--") { + if let uid = uid_t(login: string.dropFirst(9)) { + self = .alive(uid) + } + } else if string.starts(with: "x-restart-listener--") { + if let uid = uid_t(login: string.dropFirst(20)) { + self = .restartListener(uid) + } + } + return nil + } + } +} + +private extension uid_t { + init?(login: Substring) { + if let num = UInt32(login), num > 500 { + self = num + } else { + return nil + } + } +} + public extension XPC { struct FilterAck: Sendable, Equatable, Codable { public var randomInt: Int diff --git a/macapp/App/Sources/Filter/Decision+Early.swift b/macapp/App/Sources/Filter/Decision+Early.swift index 9bb627a5..0df1d3bf 100644 --- a/macapp/App/Sources/Filter/Decision+Early.swift +++ b/macapp/App/Sources/Filter/Decision+Early.swift @@ -34,7 +34,8 @@ public extension NetworkFilter { if let suspension = self.state.suspensions[userId], suspension.isActive, - suspension.scope == .unrestricted { + suspension.scope == .unrestricted, + self.state.macappsAliveUntil[userId] != nil { return self.logDecision(.allow(.filterSuspended(userId))) } diff --git a/macapp/App/Sources/Filter/Decision+Flow.swift b/macapp/App/Sources/Filter/Decision+Flow.swift index 992cfa63..62bf1ee0 100644 --- a/macapp/App/Sources/Filter/Decision+Flow.swift +++ b/macapp/App/Sources/Filter/Decision+Flow.swift @@ -44,7 +44,8 @@ public extension NetworkFilter { return .allow(.systemUiServerInternal) } - if flow.isFromGertrude { + let fromGertrude = flow.isFromGertrude + if fromGertrude, flow.hostname?.contains(".xpc.") != true { return .allow(.fromGertrudeApp) } @@ -52,12 +53,21 @@ public extension NetworkFilter { return .block(.missingUserId) } + if fromGertrude, flow.hostname == XPC.URLMessage.alive(userId).hostname { + return .block(.urlMessage(.alive(userId))) + } + + if self.state.macappsAliveUntil[userId] == nil, + self.state.userKeychains[userId] != nil { + return .block(.macappAWOL(userId)) + } + let app = appDescriptor(for: flow.bundleId ?? "", auditToken: auditToken) if self.activeSuspension(for: userId, permits: app) { return .allow(.filterSuspended) } - let keychains = state.userKeychains[userId] ?? [] + let keychains = self.state.userKeychains[userId] ?? [] guard !keychains.isEmpty else { return .block(.noUserKeys) } diff --git a/macapp/App/Sources/Filter/Decision.swift b/macapp/App/Sources/Filter/Decision.swift index 5d51edba..b6ca54d2 100644 --- a/macapp/App/Sources/Filter/Decision.swift +++ b/macapp/App/Sources/Filter/Decision.swift @@ -39,4 +39,5 @@ public protocol DecisionState { var exemptUsers: Set { get } var suspensions: [uid_t: FilterSuspension] { get } var appCache: [String: AppDescriptor] { get } + var macappsAliveUntil: [uid_t: Date] { get } } diff --git a/macapp/App/Sources/Filter/Filter.swift b/macapp/App/Sources/Filter/Filter.swift index 8b1a9487..5dea3025 100644 --- a/macapp/App/Sources/Filter/Filter.swift +++ b/macapp/App/Sources/Filter/Filter.swift @@ -13,6 +13,7 @@ public struct Filter: Reducer, Sendable { public var suspensions: [uid_t: FilterSuspension] = [:] public var appCache: [String: AppDescriptor] = [:] public var blockListeners: [uid_t: Date] = [:] + public var macappsAliveUntil: [uid_t: Date] = [:] public var logs: FilterLogs = .init(bundleIds: [:], events: [:]) public init() {} @@ -22,6 +23,7 @@ public struct Filter: Reducer, Sendable { case extensionStarted case extensionStopping case xpc(XPCEvent.Filter) + case urlMessage(XPC.URLMessage) case flowBlocked(FilterFlow, AppDescriptor) case cacheAppDescriptor(String, AppDescriptor) case loadedPersistentState(Persistent.State?) @@ -120,6 +122,12 @@ public struct Filter: Reducer, Sendable { } } + for (userId, expiration) in state.macappsAliveUntil { + if expiration < self.now { + state.macappsAliveUntil[userId] = nil + } + } + for userId in state.userDowntime.keys { if let pauseExpiry = state.userDowntime[userId]?.pausedUntil, pauseExpiry < self.now { state.userDowntime[userId]?.pausedUntil = nil @@ -170,11 +178,13 @@ public struct Filter: Reducer, Sendable { return .none case .xpc(.receivedAppMessage(.setBlockStreaming(true, let userId))): + state.recordAppActivity(from: userId) state.blockListeners[userId] = self.now + .minutes(5) os_log("[D•] FILTER state start streaming: %{public}@", "\(state.debug)") return .none case .xpc(.receivedAppMessage(.setBlockStreaming(false, let userId))): + state.recordAppActivity(from: userId) state.blockListeners[userId] = nil return .none @@ -185,10 +195,12 @@ public struct Filter: Reducer, Sendable { return self.saving(state.persistent) case .xpc(.receivedAppMessage(.endFilterSuspension(let userId))): + state.recordAppActivity(from: userId) state.suspensions[userId] = nil return .cancel(id: CancelId.suspensionTimer(for: userId)) case .xpc(.receivedAppMessage(.suspendFilter(let userId, let duration))): + state.recordAppActivity(from: userId) state.suspensions[userId] = .init( scope: .unrestricted, duration: duration, @@ -208,6 +220,7 @@ public struct Filter: Reducer, Sendable { let downtime, let manifest ))): + state.recordAppActivity(from: userId) if !keychains.isEmpty { state.userKeychains[userId] = keychains state.exemptUsers.remove(userId) @@ -232,15 +245,26 @@ public struct Filter: Reducer, Sendable { } case .xpc(.receivedAppMessage(.pauseDowntime(let userId, let expiration))): + state.recordAppActivity(from: userId) state.userDowntime[userId]?.pausedUntil = expiration return .none case .xpc(.receivedAppMessage(.endDowntimePause(let userId))): + state.recordAppActivity(from: userId) state.userDowntime[userId]?.pausedUntil = nil return .none + case .xpc(.receivedAppMessage(.macappAlive(let userId))), + .urlMessage(.alive(let userId)): + state.recordAppActivity(from: userId) + return .none + case .xpc(.decodingAppMessageDataFailed): return .none + + case .urlMessage(.restartListener): + // not implemented yet + return .none } } @@ -251,6 +275,13 @@ public struct Filter: Reducer, Sendable { } } +public extension Filter.State { + mutating func recordAppActivity(from userId: uid_t) { + @Dependency(\.date.now) var now + self.macappsAliveUntil[userId] = now + .seconds(150) + } +} + public extension Filter.State { struct Debug { public var userKeys: [uid_t: Int] = [:] diff --git a/macapp/App/Sources/Filter/FilterProxy.swift b/macapp/App/Sources/Filter/FilterProxy.swift index 4804179a..6b0a27c6 100644 --- a/macapp/App/Sources/Filter/FilterProxy.swift +++ b/macapp/App/Sources/Filter/FilterProxy.swift @@ -4,6 +4,9 @@ import Foundation import NetworkExtension import os.log +/// A proxy for the FilterDataProvider, which is impossible to test +/// and now mostly forwards functionality to this class, passing DTOs +/// where it traffics in types that are not constructable in tests. public class FilterProxy { #if !DEBUG let store: FilterStore @@ -80,11 +83,17 @@ public class FilterProxy { } switch decision { + case .block(.urlMessage(let message)): + self.store.send(urlMessage: message) + return .drop() case .block: if self.sendingBlockDecisions { self.store.sendBlocked(filterFlow, auditToken: flow.sourceAppAuditToken) } return dropNewFlow() + case .allow(.fromGertrudeApp): + self.store.send(urlMessage: .alive(userId)) + return .allow() case .allow: return .allow() case nil: @@ -169,7 +178,7 @@ public class FilterProxy { public extension NEFilterFlow { /// A data transfer object for `NEFilterFlow`. - /// NB: the original object has more information + /// NB: the original object has more data struct DTO { let identifier: UUID let sourceAppAuditToken: Data? @@ -205,20 +214,16 @@ extension FilterFlow { } private func dropFlow() -> NEFilterDataVerdict { - #if canImport(XCTest) - return .drop() - #elseif DEBUG - return .allow() + #if DEBUG + return getuid() < 500 ? .allow() : .drop() #else return .drop() #endif } private func dropNewFlow() -> NEFilterNewFlowVerdict { - #if canImport(XCTest) - return .drop() - #elseif DEBUG - return .allow() + #if DEBUG + return getuid() < 500 ? .allow() : .drop() #else return .drop() #endif diff --git a/macapp/App/Sources/Filter/FilterStore.swift b/macapp/App/Sources/Filter/FilterStore.swift index f4087cac..ffa08520 100644 --- a/macapp/App/Sources/Filter/FilterStore.swift +++ b/macapp/App/Sources/Filter/FilterStore.swift @@ -29,6 +29,10 @@ public struct FilterStore: NetworkFilter { self.viewStore.send(.flowBlocked(flow, app)) } + public func send(urlMessage: XPC.URLMessage) { + self.viewStore.send(.urlMessage(urlMessage)) + } + public func shouldSendBlockDecisions() -> AnyPublisher { self.viewStore.publisher.blockListeners.map { !$0.isEmpty }.eraseToAnyPublisher() } diff --git a/macapp/App/Sources/Filter/ReceiveAppMessage.swift b/macapp/App/Sources/Filter/ReceiveAppMessage.swift index 10e4b5f5..38192b00 100644 --- a/macapp/App/Sources/Filter/ReceiveAppMessage.swift +++ b/macapp/App/Sources/Filter/ReceiveAppMessage.swift @@ -94,6 +94,17 @@ import os.log reply(nil) } + func receiveAlive( + for userId: uid_t, + reply: @escaping (Bool, XPCErrorData?) -> Void + ) { + os_log("[G•] FILTER xpc.receiveAlive(for: %{public}d)", userId) + self.subject.withValue { + $0.send(.receivedAppMessage(.macappAlive(userId: userId))) + } + reply(true, nil) + } + func receiveAckRequest( randomInt: Int, userId: uid_t, @@ -114,6 +125,9 @@ import os.log ) let data = try XPC.encode(ack) reply(data, nil) + self.subject.withValue { + $0.send(.receivedAppMessage(.macappAlive(userId: userId))) + } } catch { os_log("[G•] FILTER xpc.receiveAckRequest error: %{public}@", "\(error)") reply(nil, XPC.errorData(error)) diff --git a/macapp/App/Sources/Filter/Types.swift b/macapp/App/Sources/Filter/Types.swift index aa814254..4a2dac5c 100644 --- a/macapp/App/Sources/Filter/Types.swift +++ b/macapp/App/Sources/Filter/Types.swift @@ -20,6 +20,7 @@ public extension XPCEvent { case suspendFilter(userId: uid_t, duration: Seconds) case setUserExemption(userId: uid_t, enabled: Bool) case deleteAllStoredState + case macappAlive(userId: uid_t) } case receivedAppMessage(MessageFromApp) diff --git a/macapp/App/Sources/LiveFilterXPCClient/FilterXPC.swift b/macapp/App/Sources/LiveFilterXPCClient/FilterXPC.swift index 9c5fbf75..6a95f1a5 100644 --- a/macapp/App/Sources/LiveFilterXPCClient/FilterXPC.swift +++ b/macapp/App/Sources/LiveFilterXPCClient/FilterXPC.swift @@ -56,6 +56,12 @@ struct FilterXPC: Sendable { return ack } + func sendAlive() async throws -> Bool { + try await withTimeout(connection: sharedConnection) { filterProxy, continuation in + filterProxy.receiveAlive(for: getuid(), reply: continuation.dataHandler) + } + } + func sendUserRules( manifest: AppIdManifest, keychains: [RuleKeychain], diff --git a/macapp/App/Sources/LiveFilterXPCClient/LiveFilterXPCClient.swift b/macapp/App/Sources/LiveFilterXPCClient/LiveFilterXPCClient.swift index f1ac32ea..defc1712 100644 --- a/macapp/App/Sources/LiveFilterXPCClient/LiveFilterXPCClient.swift +++ b/macapp/App/Sources/LiveFilterXPCClient/LiveFilterXPCClient.swift @@ -33,9 +33,18 @@ extension FilterXPCClient: DependencyKey { requestUserTypes: { await .init { try await xpc.requestUserTypes() }}, + sendAlive: { await .init { + let success = try await xpc.sendAlive() + if !success { + await send(urlMessage: .alive(getuid())) + _ = try await xpc.requestAck() + } + return success + }}, sendDeleteAllStoredState: { await .init { try await xpc.sendDeleteAllStoredState() }}, + sendURLMessage: send(urlMessage:), sendUserRules: { manifest, keychains, downtime in await .init { try await xpc.sendUserRules(manifest: manifest, keychains: keychains, downtime: downtime) }}, @@ -92,6 +101,10 @@ actor ThreadSafeFilterXPC { try await self.filterXpc.requestAck() } + func sendAlive() async throws -> Bool { + try await self.filterXpc.sendAlive() + } + func sendUserRules( manifest: AppIdManifest, keychains: [RuleKeychain], @@ -120,3 +133,13 @@ actor ThreadSafeFilterXPC { try await self.filterXpc.sendDeleteAllStoredState() } } + +// helpers + +@Sendable +private func send(urlMessage: XPC.URLMessage) async { + var request = URLRequest(url: urlMessage.url) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.timeoutInterval = 2 + _ = try? await URLSession.shared.data(for: request) +} diff --git a/macapp/App/Sources/Relauncher/Relauncher.swift b/macapp/App/Sources/Relauncher/Relauncher.swift index dac913a5..aa197200 100644 --- a/macapp/App/Sources/Relauncher/Relauncher.swift +++ b/macapp/App/Sources/Relauncher/Relauncher.swift @@ -66,7 +66,7 @@ public enum Relauncher { client.sleepForSeconds(sleepInterval) if iterations % 20 == 0 { os_log("[G•] HELPER checking for program termination...") - } else { + } else if iterations % 5 == 0 { os_log("[D•] HELPER checking for program termination...") } diff --git a/macapp/App/Tests/AppTests/AppReducerTests.swift b/macapp/App/Tests/AppTests/AppReducerTests.swift index 1b5881b9..77f6924f 100644 --- a/macapp/App/Tests/AppTests/AppReducerTests.swift +++ b/macapp/App/Tests/AppTests/AppReducerTests.swift @@ -43,6 +43,7 @@ final class AppReducerTests: XCTestCase { } await store.receive(.startProtecting(user: .mock)) + await store.receive(.networkConnectionChanged(connected: true)) await store.receive(.websocket(.connectedSuccessfully)) await expect(setUserToken.calls).toEqual([UserData.mock.token]) diff --git a/macapp/App/Tests/AppTests/ApplicationFeatureTests.swift b/macapp/App/Tests/AppTests/ApplicationFeatureTests.swift index e49511ed..e91c394e 100644 --- a/macapp/App/Tests/AppTests/ApplicationFeatureTests.swift +++ b/macapp/App/Tests/AppTests/ApplicationFeatureTests.swift @@ -1,3 +1,4 @@ +import Core import Gertie import MacAppRoute import TestSupport @@ -53,4 +54,13 @@ final class ApplicationFeatureTests: XCTestCase { await expect(securityEvent.calls) .toEqual([Both(.init(.blockedAppLaunchAttempted, "app: FaceSkype"), nil)]) } + + @MainActor + func testSendsFilterAliveOnWake() async { + let (store, _) = AppReducer.testStore() + let alive = mock(once: Result.success(true)) + store.deps.filterXpc.sendAlive = alive.fn + await store.send(.application(.didWake)) + await expect(alive.called).toEqual(true) + } } diff --git a/macapp/App/Tests/AppTests/FilterFeatureTests.swift b/macapp/App/Tests/AppTests/FilterFeatureTests.swift index f0b9133b..b1537e4e 100644 --- a/macapp/App/Tests/AppTests/FilterFeatureTests.swift +++ b/macapp/App/Tests/AppTests/FilterFeatureTests.swift @@ -44,6 +44,15 @@ final class FilterFeatureTests: XCTestCase { await expect(quitBrowsers.calls).toEqual([[.name("Arc")]]) } + @MainActor + func testEveryMinuteSendsAliveMsgToFilter() async { + let (store, _) = AppReducer.testStore() + let alive = mock(once: Result.success(true)) + store.deps.filterXpc.sendAlive = alive.fn + await store.send(.heartbeat(.everyMinute)) + await expect(alive.called).toEqual(true) + } + @MainActor func testHeartbeatUpdatesFilterVersionIfPossible() async { let (store, _) = AppReducer.testStore { diff --git a/macapp/App/Tests/AppTests/OnboardingFeatureTests.swift b/macapp/App/Tests/AppTests/OnboardingFeatureTests.swift index e1c8c8dc..94c87c88 100644 --- a/macapp/App/Tests/AppTests/OnboardingFeatureTests.swift +++ b/macapp/App/Tests/AppTests/OnboardingFeatureTests.swift @@ -19,6 +19,7 @@ final class OnboardingFeatureTests: XCTestCase { store.deps.mainQueue = .immediate store.deps.filterExtension.stateChanges = { Empty().eraseToAnyPublisher() } store.deps.filterXpc.events = { Empty().eraseToAnyPublisher() } + store.deps.filterXpc.sendAlive = { .success(true) } store.deps.websocket.state = { .notConnected } store.deps.app.installLocation = { .inApplicationsDir } @@ -300,6 +301,7 @@ final class OnboardingFeatureTests: XCTestCase { await store.receive(.onboarding(.delegate(.onboardingConfigComplete))) await store.receive(.startProtecting(user: user)) + await store.receive(.networkConnectionChanged(connected: true)) await store.receive(.websocket(.connectedSuccessfully)) await store.receive(.checkIn(result: .success(checkInResult), reason: .startProtecting)) { diff --git a/macapp/App/Tests/FilterTests/EarlyDecisionTests.swift b/macapp/App/Tests/FilterTests/EarlyDecisionTests.swift index 83e7d9d0..a2f96ddd 100644 --- a/macapp/App/Tests/FilterTests/EarlyDecisionTests.swift +++ b/macapp/App/Tests/FilterTests/EarlyDecisionTests.swift @@ -26,6 +26,15 @@ final class EarlyDecisionTests: XCTestCase { expect(filter.earlyUserDecision(auditToken: .init())).toEqual(.none(502)) } + func testExemptUserNotConsideredAwol() { + let filter = TestFilter.scenario( + userIdFromAuditToken: 502, + macappsAliveUntil: [:], // <-- AWOL + exemptUsers: [502] // <-- but exempt + ) + expect(filter.earlyUserDecision(auditToken: .init())).toEqual(.allow(.exemptUser(502))) + } + func testBlockedByDowntime() { let downtime = PlainTimeWindow( start: .init(hour: 22, minute: 0), @@ -103,6 +112,14 @@ final class EarlyDecisionTests: XCTestCase { expect(filter.earlyUserDecision(auditToken: .init())).toEqual(.allow(.filterSuspended(502))) } + func testFilterSuspensionAllowNotGrantedEarlyIfMacappAppearsAWOL() { + let filter = TestFilter.scenario( + macappsAliveUntil: [:], + suspensions: [502: .init(scope: .unrestricted, duration: 100)] + ) + expect(filter.earlyUserDecision(auditToken: .init())).toEqual(.none(502)) + } + func testUserWithBrowserScopeFilterSuspensionNoDecision() { let filter = TestFilter.scenario(suspensions: [502: .init(scope: .webBrowsers, duration: 1000)]) expect(filter.earlyUserDecision(auditToken: .init())).toEqual(.none(502)) diff --git a/macapp/App/Tests/FilterTests/FilterMigratorTests.swift b/macapp/App/Tests/FilterTests/FilterMigratorTests.swift index f4bd1417..8f7fd955 100644 --- a/macapp/App/Tests/FilterTests/FilterMigratorTests.swift +++ b/macapp/App/Tests/FilterTests/FilterMigratorTests.swift @@ -34,7 +34,7 @@ class FilterMigratorTests: XCTestCase { var migrator = self.testMigrator let setStringInvocations = LockIsolated<[Both]>([]) - migrator.userDefaults.setString = { key, value in + migrator.userDefaults.setString = { @Sendable key, value in setStringInvocations.append(.init(key, value)) } @@ -83,7 +83,7 @@ class FilterMigratorTests: XCTestCase { var migrator = self.testMigrator let setStringInvocations = LockIsolated<[Both]>([]) - migrator.userDefaults.setString = { key, value in + migrator.userDefaults.setString = { @Sendable key, value in setStringInvocations.append(.init(key, value)) } diff --git a/macapp/App/Tests/FilterTests/FilterProxyTests.swift b/macapp/App/Tests/FilterTests/FilterProxyTests.swift index c5e38802..0692f823 100644 --- a/macapp/App/Tests/FilterTests/FilterProxyTests.swift +++ b/macapp/App/Tests/FilterTests/FilterProxyTests.swift @@ -25,6 +25,20 @@ final class FilterProxyTests: XCTestCase { expect(proxy.store.state.logs.bundleIds).toEqual([:]) // current behavior } + func testUrlMessagePassedToStore() { + withDependencies { + $0.filterExtension.version = { "2.6.0" } + $0.date = .constant(.reference) + } operation: { + let proxy = FilterProxy(flowDecision: .block(.urlMessage(.alive(501)))) + let verdict = proxy.handleNewFlow(.mock) + // 1) flow is dropped (it was just an xpc message) + expect(verdict.isDrop).toBeTrue() + // 2) the store received the message + expect(proxy.store.state.macappsAliveUntil).toEqual([501: .reference + .seconds(150)]) + } + } + func testEarlyDecisionAllowingGertrudeDuringDowntime() { let proxy = FilterProxy(earlyDecision: .blockDuringDowntime(501)) let verdict = proxy.handleNewFlow(.gertrude) @@ -94,6 +108,21 @@ final class FilterProxyTests: XCTestCase { expect(proxy.store.state.logs.bundleIds).toEqual(["com.acme.app": 1]) } + func testApiReqFromGertrudeAppAllowedAndSetsAlive() { + withDependencies { + $0.filterExtension.version = { "2.6.0" } + $0.date = .constant(.init(timeIntervalSinceReferenceDate: 0)) + } operation: { + let proxy = FilterProxy( + earlyDecision: .none(501), // <-- we know the user id is 501 + flowDecision: .allow(.fromGertrudeApp) // <-- so count any api req as an alive + ) + let verdict = proxy.handleNewFlow(.mock) + expect(verdict.isDrop).toBeFalse() + expect(proxy.store.state.macappsAliveUntil).toEqual([501: .reference + .seconds(150)]) + } + } + func testDeferredNewFlowLogsAndSetsUserId() { let proxy = FilterProxy(flowDecision: .some(.none)) let flow = NEFilterFlow.DTO.mock diff --git a/macapp/App/Tests/FilterTests/FilterReducerTests.swift b/macapp/App/Tests/FilterTests/FilterReducerTests.swift index 32befef6..10f77366 100644 --- a/macapp/App/Tests/FilterTests/FilterReducerTests.swift +++ b/macapp/App/Tests/FilterTests/FilterReducerTests.swift @@ -65,6 +65,7 @@ final class FilterReducerTests: XCTestCase { $0.userKeychains[502] = [keychain] $0.appIdManifest = manifest $0.appCache = [:] // clears out app cache when new manifest is received + $0.macappsAliveUntil[502] = .epoch + .macappAliveBuffer } await expect(saveState.calls).toEqual([.init( @@ -169,6 +170,7 @@ final class FilterReducerTests: XCTestCase { func testStreamBlockedRequests() async { let (store, _) = Filter.testStore(exhaustive: true) store.deps.filterExtension = .mock + store.deps.date = .constant(.epoch) // user not streaming, so we won't send the request store.deps.xpc.sendBlockedRequest = { _, _ in fatalError() } @@ -182,7 +184,8 @@ final class FilterReducerTests: XCTestCase { enabled: true, userId: 502 )))) { - $0.blockListeners[502] = Date(timeIntervalSince1970: 60 * 5) + $0.blockListeners[502] = .epoch + .minutes(5) + $0.macappsAliveUntil[502] = .epoch + .macappAliveBuffer } // now we're streaming blocks for 502 @@ -193,12 +196,14 @@ final class FilterReducerTests: XCTestCase { await store.send(.flowBlocked(flow, .mock)) await store.send(.flowBlocked(FilterFlow(userId: 503), .mock)) // <-- different user await expect(sendBlocked.calls).toEqual([Both(502, flow.testBlockedReq())]) + store.deps.date = .constant(.epoch + .minutes(5)) await store.send(.xpc(.receivedAppMessage(.setBlockStreaming( enabled: false, userId: 502 )))) { $0.blockListeners = [:] + $0.macappsAliveUntil[502] = .epoch + .minutes(5) + .macappAliveBuffer } // no more blocks should be sent @@ -390,10 +395,13 @@ final class FilterReducerTests: XCTestCase { 502: .init(scope: .unrestricted, duration: 600, now: store.deps.date.now), 503: otherUserSuspension, ] + $0.macappsAliveUntil[502] = .epoch + .macappAliveBuffer } + store.deps.date = .constant(.epoch + .minutes(2)) await store.send(.xpc(.receivedAppMessage(.endFilterSuspension(userId: 502)))) { $0.suspensions = [503: otherUserSuspension] + $0.macappsAliveUntil[502] = .epoch + .minutes(2) + .macappAliveBuffer } await mainQueue.advance(by: .seconds(600)) @@ -550,13 +558,36 @@ final class FilterReducerTests: XCTestCase { await store .send(.xpc(.receivedAppMessage(.pauseDowntime(userId: 502, until: now + .minutes(5))))) { $0.userDowntime[502] = Downtime(window: "22:00-05:00", pausedUntil: now + .minutes(5)) + $0.macappsAliveUntil[502] = now + .macappAliveBuffer } + store.deps.date = .constant(now + .minutes(2)) await store.send(.xpc(.receivedAppMessage(.endDowntimePause(userId: 502)))) { $0.userDowntime[502] = Downtime(window: "22:00-05:00", pausedUntil: nil) + $0.macappsAliveUntil[502] = now + .minutes(2) + .macappAliveBuffer + } + } + + @MainActor + func testAliveMessageReceived() async { + let (store, _) = Filter.testStore() + await store.send(.xpc(.receivedAppMessage(.macappAlive(userId: 502)))) { + $0.macappsAliveUntil[502] = .epoch + .macappAliveBuffer } } + @MainActor + func testMacAppsPastAliveBufferCleanedUpInHeartbeat() async { + let (store, _) = Filter.testStore { + $0.macappsAliveUntil[502] = .epoch + .macappAliveBuffer + } + await store.send(.heartbeat) + store.deps.date = .constant(.epoch + .macappAliveBuffer - .seconds(1)) + await store.send(.heartbeat) + store.deps.date = .constant(.epoch + .macappAliveBuffer + .seconds(1)) + await store.send(.heartbeat) { $0.macappsAliveUntil = [:] } + } + @MainActor func testLogsAppRequests() async { let (store, _) = Filter.testStore() @@ -651,3 +682,12 @@ extension FilterFlow { ) } } + +public extension Date { + static let epoch = Date(timeIntervalSince1970: 0) + static let reference = Date(timeIntervalSinceReferenceDate: 0) +} + +public extension Double { + static let macappAliveBuffer = 150.0 +} diff --git a/macapp/App/Tests/FilterTests/NewFlowDecisionTests.swift b/macapp/App/Tests/FilterTests/NewFlowDecisionTests.swift index a868bf13..715b7c6b 100644 --- a/macapp/App/Tests/FilterTests/NewFlowDecisionTests.swift +++ b/macapp/App/Tests/FilterTests/NewFlowDecisionTests.swift @@ -29,6 +29,16 @@ final class NewFlowDecisionTests: XCTestCase { expect(TestFilter.scenario().newFlowDecision(flow)).toBeNil() } + func testAwolMacappBlocked() { + let flow = FilterFlow.test(ipAddress: "4.4.4.4", hostname: "abc123.com", bundleId: "com.foo") + let key = RuleKey(key: .skeleton(scope: .bundleId("com.foo"))) + let filter = TestFilter.scenario( + userKeychains: [502: key.into()], // <-- we would normally permit by this key... + macappsAliveUntil: [:] // <-- but the macapp is awol + ) + expect(filter.newFlowDecision(flow)).toEqual(.block(.macappAWOL(502))) + } + func testDecisionIsBlockWhenNoKeyAllowsAndUrlIsPresent() { let flow = FilterFlow.test( url: "https://unknown.com/foo", @@ -38,6 +48,16 @@ final class NewFlowDecisionTests: XCTestCase { expect(TestFilter.scenario().newFlowDecision(flow)).toEqual(.block(.defaultNotAllowed)) } + func testFilterSuspensionAllowNotGrantedIfMacappAppearsAWOL() { + let flow = FilterFlow.test(url: nil, hostname: "unknown.com") + let filter = TestFilter.scenario( + userKeychains: [502: [.mock]], + macappsAliveUntil: [:], // <-- macapp is awol! + suspensions: [502: .init(scope: .unrestricted, duration: 100)] + ) + expect(filter.newFlowDecision(flow)).toEqual(.block(.macappAWOL(502))) + } + func testFlowAllowedForAppWithUnrestrictedScope() { let flow = FilterFlow.test(ipAddress: "4.4.4.4", hostname: "abc123.com", bundleId: "com.foo") let key = RuleKey(key: .skeleton(scope: .bundleId("com.foo"))) diff --git a/macapp/App/Tests/FilterTests/TestFilter.swift b/macapp/App/Tests/FilterTests/TestFilter.swift index bc03bddc..9194bd18 100644 --- a/macapp/App/Tests/FilterTests/TestFilter.swift +++ b/macapp/App/Tests/FilterTests/TestFilter.swift @@ -13,6 +13,7 @@ class TestFilter: NetworkFilter { var exemptUsers: Set = [] var suspensions: [uid_t: FilterSuspension] = [:] var appCache: [String: AppDescriptor] = [:] + var macappsAliveUntil: [uid_t: Date] = [:] } var state = State() @@ -33,6 +34,7 @@ class TestFilter: NetworkFilter { userIdFromAuditToken userId: uid_t? = 502, userKeychains: [uid_t: [RuleKeychain]] = [502: [.mock]], userDowntime: [uid_t: PlainTimeWindow] = [:], + macappsAliveUntil: [uid_t: Date] = [502: .distantFuture, 503: .distantFuture], date: Dependencies.DateGenerator = .init { Date() }, appIdManifest: AppIdManifest = .init( apps: ["chrome": ["com.chrome"]], @@ -55,7 +57,8 @@ class TestFilter: NetworkFilter { userDowntime: userDowntime.mapValues { Downtime(window: $0) }, appIdManifest: appIdManifest, exemptUsers: exemptUsers, - suspensions: suspensions + suspensions: suspensions, + macappsAliveUntil: macappsAliveUntil ) return filter } From ab3441a00af7ce946bd77746ffc61d6181f2314b Mon Sep 17 00:00:00 2001 From: Jared Henderson Date: Fri, 27 Dec 2024 14:41:49 -0500 Subject: [PATCH 3/3] macapp: bump v2.6.1 for canary --- macapp/Xcode/Gertrude/Info.plist | 4 ++-- macapp/Xcode/GertrudeFilterExtension/Info.plist | 4 ++-- macapp/readme.md | 2 ++ 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/macapp/Xcode/Gertrude/Info.plist b/macapp/Xcode/Gertrude/Info.plist index 0caeb7a1..12acf044 100644 --- a/macapp/Xcode/Gertrude/Info.plist +++ b/macapp/Xcode/Gertrude/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.6.0 + 2.6.1 CFBundleVersion - 2.6.0 + 2.6.1 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) LSUIElement diff --git a/macapp/Xcode/GertrudeFilterExtension/Info.plist b/macapp/Xcode/GertrudeFilterExtension/Info.plist index 8b610865..dcb1ced6 100644 --- a/macapp/Xcode/GertrudeFilterExtension/Info.plist +++ b/macapp/Xcode/GertrudeFilterExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 2.6.0 + 2.6.1 CFBundleVersion - 2.6.0 + 2.6.1 LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) NSHumanReadableCopyright diff --git a/macapp/readme.md b/macapp/readme.md index b87fdf21..20f76d33 100644 --- a/macapp/readme.md +++ b/macapp/readme.md @@ -76,6 +76,8 @@ - don't take screenshots when login window is frontmost application - `2.6.0` (canary as of 12/17/24) - app blocking +- `2.6.1` (canary as of 12/27/24) + - filter polices app, blocks if awol ## Sparkle Releases