diff options
Diffstat (limited to 'octosentry')
| -rw-r--r-- | octosentry/AuthStore.swift | 50 | ||||
| -rw-r--r-- | octosentry/GitHubAPIModels.swift | 8 | ||||
| -rw-r--r-- | octosentry/GitHubDeviceAuthClient.swift | 14 | ||||
| -rw-r--r-- | octosentry/GitHubSecurityAPIClient.swift | 20 | ||||
| -rw-r--r-- | octosentry/PersistedState.swift | 40 | ||||
| -rw-r--r-- | octosentry/PrivacyInfo.xcprivacy | 14 | ||||
| -rw-r--r-- | octosentry/SecurityEventListView.swift | 123 | ||||
| -rw-r--r-- | octosentry/SecurityEventStore.swift | 10 | ||||
| -rw-r--r-- | octosentry/UpdateChecker.swift | 85 | ||||
| -rw-r--r-- | octosentry/UpdateStore.swift | 55 | ||||
| -rw-r--r-- | octosentry/octosentryApp.swift | 5 |
11 files changed, 394 insertions, 30 deletions
diff --git a/octosentry/AuthStore.swift b/octosentry/AuthStore.swift index 4386e38..4194bba 100644 --- a/octosentry/AuthStore.swift +++ b/octosentry/AuthStore.swift @@ -6,6 +6,11 @@ // currently in the Keychain. Replaces the GITHUB_TOKEN env var dev // shortcut (spec §13) with the real v1 auth flow (spec §6). // +// Sign-in requests the minimal security_events scope by default. +// Broader "repo" scope (needed to list repos for the picker, #15) is +// only ever requested on demand via requestRepoAccess(), never by +// default — a deliberate choice to keep the default blast radius small. +// import Foundation import Observation @@ -14,12 +19,17 @@ import Observation final class AuthStore { private(set) var state: AuthState private(set) var errorMessage: String? + private(set) var hasRepoAccess = false private let client = GitHubDeviceAuthClient() + private let persistenceStore = PersistenceStore() private var authorizationTask: Task<Void, Never>? init() { state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut + Task { + hasRepoAccess = await persistenceStore.load().hasRepoScope + } } var isSignedIn: Bool { @@ -28,13 +38,32 @@ final class AuthStore { } func signIn() { + beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) + } + + /// Re-runs device auth with broader scope so the repo picker can list + /// repos. Only called explicitly from the repo picker UI, never on + /// the default sign-in path. + func requestRepoAccess() { + beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope) + } + + func signOut() { + authorizationTask?.cancel() + authorizationTask = nil + KeychainTokenStore.delete() + state = .signedOut + hasRepoAccess = false + } + + private func beginAuthorization(scope: String) { guard authorizationTask == nil else { return } errorMessage = nil authorizationTask = Task { defer { authorizationTask = nil } do { - let deviceCode = try await client.requestDeviceCode() + let deviceCode = try await client.requestDeviceCode(scope: scope) state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) let token = try await client.pollForToken( @@ -43,18 +72,21 @@ final class AuthStore { expiresIn: deviceCode.expiresIn ) try KeychainTokenStore.save(token) + + let grantedRepoScope = scope.contains("repo") + var persisted = await persistenceStore.load() + persisted.hasRepoScope = grantedRepoScope + await persistenceStore.save(persisted) + hasRepoAccess = grantedRepoScope + state = .signedIn } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - state = .signedOut + // A failed re-auth (e.g. requestRepoAccess while already + // signed in) shouldn't sign the user out of their existing + // valid token — only reflect reality from the Keychain. + state = KeychainTokenStore.load() != nil ? .signedIn : .signedOut } } } - - func signOut() { - authorizationTask?.cancel() - authorizationTask = nil - KeychainTokenStore.delete() - state = .signedOut - } } diff --git a/octosentry/GitHubAPIModels.swift b/octosentry/GitHubAPIModels.swift index 3a84aa8..2b95b70 100644 --- a/octosentry/GitHubAPIModels.swift +++ b/octosentry/GitHubAPIModels.swift @@ -86,3 +86,11 @@ nonisolated struct SecretScanningAlertDTO: Decodable { case validity } } + +nonisolated struct GitHubRepoDTO: Decodable { + let fullName: String + + enum CodingKeys: String, CodingKey { + case fullName = "full_name" + } +} diff --git a/octosentry/GitHubDeviceAuthClient.swift b/octosentry/GitHubDeviceAuthClient.swift index 63799ff..3105733 100644 --- a/octosentry/GitHubDeviceAuthClient.swift +++ b/octosentry/GitHubDeviceAuthClient.swift @@ -15,10 +15,14 @@ actor GitHubDeviceAuthClient { // Not a secret — safe to embed in source. private let clientID = "Ov23li6tqaTghDc4IJYv" - // Grants Dependabot/code scanning/secret scanning alert access. Classic OAuth - // scopes have no read-only variant (unlike fine-grained PATs); this is the - // narrowest scope GitHub offers for these three endpoints via OAuth Apps. - private let scope = "security_events" + // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert + // access. Classic OAuth scopes have no read-only variant (unlike fine-grained + // PATs); this is the narrowest scope GitHub offers for these three endpoints. + static let defaultScope = "security_events" + + // Broader scope requested only on demand (repo picker, #15) — never the + // default, since it's a real increase in blast radius over defaultScope alone. + static let repoAccessScope = "security_events repo" private let session: URLSession @@ -26,7 +30,7 @@ actor GitHubDeviceAuthClient { self.session = session } - func requestDeviceCode() async throws -> DeviceCodeResponse { + func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse { let data = try await post( url: URL(string: "https://github.com/login/device/code")!, parameters: ["client_id": clientID, "scope": scope] diff --git a/octosentry/GitHubSecurityAPIClient.swift b/octosentry/GitHubSecurityAPIClient.swift index 629699a..2e480bd 100644 --- a/octosentry/GitHubSecurityAPIClient.swift +++ b/octosentry/GitHubSecurityAPIClient.swift @@ -3,9 +3,9 @@ // octosentry // // Fetches Dependabot, code scanning, and secret scanning alerts for a -// single repo and normalizes them into SecurityEvent. Auth is a PAT read -// by the caller from the GITHUB_TOKEN environment variable — a dev-only -// shortcut ahead of the device authorization flow (spec §6, §13). +// repo and normalizes them into SecurityEvent, plus (with broader scope) +// listing repos the token can see for the repo picker. The token itself +// comes from Keychain via the device authorization flow (spec §6). // import Foundation @@ -89,6 +89,20 @@ actor GitHubSecurityAPIClient { } } + /// Lists repos the token can see (requires the broader repo-access + /// scope granted via AuthStore.requestRepoAccess(), not the default + /// sign-in scope). Used by the repo picker (#15). + func fetchAccessibleRepos() async throws -> [String] { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.path = "/user/repos" + components.queryItems = [ + URLQueryItem(name: "per_page", value: "100"), + URLQueryItem(name: "sort", value: "full_name"), + ] + let dtos: [GitHubRepoDTO] = try await fetchAllPages(url: components.url!) + return dtos.map(\.fullName) + } + private func alertsURL(owner: String, repo: String, path: String) -> URL { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! components.path = "/repos/\(owner)/\(repo)/\(path)" diff --git a/octosentry/PersistedState.swift b/octosentry/PersistedState.swift index 3ef4234..c8c1bae 100644 --- a/octosentry/PersistedState.swift +++ b/octosentry/PersistedState.swift @@ -3,10 +3,12 @@ // octosentry // // Everything the app remembers across launches: the repo watch list, -// local-only seen-state per event, last-fetch timestamp per repo, and the -// minimum severity filter. Flat JSON over SwiftData (see #1) — small, -// inspectable, and these are already plain Codable values passed across -// actor boundaries, not reference types tied to a persistence context. +// local-only seen-state per event, last-fetch timestamp per repo, the +// minimum severity filter, and whether the current token has the +// broader "repo" scope needed to list repos. Flat JSON over SwiftData +// (see #1) — small, inspectable, and these are already plain Codable +// values passed across actor boundaries, not reference types tied to a +// persistence context. // import Foundation @@ -16,6 +18,36 @@ nonisolated struct PersistedState: Codable { var seenEventIDs: Set<String> var lastFetchByRepo: [String: Date] var minimumSeverity: SecurityEventSeverity + var hasRepoScope: Bool + + enum CodingKeys: String, CodingKey { + case watchedRepos, seenEventIDs, lastFetchByRepo, minimumSeverity, hasRepoScope + } + + init( + watchedRepos: [String], + seenEventIDs: Set<String>, + lastFetchByRepo: [String: Date], + minimumSeverity: SecurityEventSeverity, + hasRepoScope: Bool = false + ) { + self.watchedRepos = watchedRepos + self.seenEventIDs = seenEventIDs + self.lastFetchByRepo = lastFetchByRepo + self.minimumSeverity = minimumSeverity + self.hasRepoScope = hasRepoScope + } + + // Custom decode so existing state.json files saved before hasRepoScope + // existed still load instead of falling back to .placeholder. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + watchedRepos = try container.decode([String].self, forKey: .watchedRepos) + seenEventIDs = try container.decode(Set<String>.self, forKey: .seenEventIDs) + lastFetchByRepo = try container.decode([String: Date].self, forKey: .lastFetchByRepo) + minimumSeverity = try container.decode(SecurityEventSeverity.self, forKey: .minimumSeverity) + hasRepoScope = try container.decodeIfPresent(Bool.self, forKey: .hasRepoScope) ?? false + } static let placeholder = PersistedState( watchedRepos: ["ccleberg/cleberg.net"], diff --git a/octosentry/PrivacyInfo.xcprivacy b/octosentry/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..e08a130 --- /dev/null +++ b/octosentry/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>NSPrivacyTracking</key> + <false/> + <key>NSPrivacyTrackingDomains</key> + <array/> + <key>NSPrivacyCollectedDataTypes</key> + <array/> + <key>NSPrivacyAccessedAPITypes</key> + <array/> +</dict> +</plist> diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift index d817370..84e6638 100644 --- a/octosentry/SecurityEventListView.swift +++ b/octosentry/SecurityEventListView.swift @@ -9,6 +9,7 @@ import SwiftUI struct SecurityEventListView: View { var store: SecurityEventStore var authStore: AuthStore + var updateStore: UpdateStore var isStandaloneWindow: Bool = false @State private var showingRepoManager = false @Environment(\.openWindow) private var openWindow @@ -16,6 +17,9 @@ struct SecurityEventListView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { header + if let release = updateStore.availableRelease { + UpdateBanner(release: release) + } Divider() if !authStore.isSignedIn { SignInView(authStore: authStore) @@ -30,6 +34,9 @@ struct SecurityEventListView: View { await store.refresh() store.startPolling() } + .task { + await updateStore.checkForUpdate() + } } private var header: some View { @@ -142,6 +149,10 @@ private struct RepoManagerView: View { var store: SecurityEventStore var authStore: AuthStore @State private var newRepoText = "" + @State private var isBrowsingRepos = false + @State private var availableRepos: [String] = [] + @State private var isLoadingRepos = false + @State private var browseErrorMessage: String? var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -171,13 +182,24 @@ private struct RepoManagerView: View { Divider() - HStack { - TextField("owner/repo", text: $newRepoText) - .textFieldStyle(.roundedBorder) - .onSubmit(addRepo) + if isBrowsingRepos { + browsingContent + } else { + HStack { + TextField("owner/repo", text: $newRepoText) + .textFieldStyle(.roundedBorder) + .onSubmit(addRepo) + + Button("Add", action: addRepo) + .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } - Button("Add", action: addRepo) - .disabled(newRepoText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button(action: startBrowsing) { + Label("Browse your repos", systemImage: "list.bullet") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) } if let errorMessage = store.watchListErrorMessage { @@ -200,6 +222,75 @@ private struct RepoManagerView: View { .frame(maxWidth: .infinity, alignment: .leading) } + @ViewBuilder + private var browsingContent: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Your Repositories") + .font(.caption.weight(.semibold)) + Spacer() + Button { + isBrowsingRepos = false + } label: { + Image(systemName: "xmark.circle") + } + .buttonStyle(.plain) + } + + if isLoadingRepos { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity) + } else if let browseErrorMessage { + Text(browseErrorMessage) + .font(.caption2) + .foregroundStyle(.red) + } else { + let selectableRepos = availableRepos.filter { !store.watchedRepos.contains($0) } + if selectableRepos.isEmpty { + Text("All visible repos are already watched.") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(selectableRepos, id: \.self) { repo in + Button { + Task { await store.addRepo(repo) } + isBrowsingRepos = false + } label: { + Text(repo) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + } + } + .frame(maxHeight: 160) + } + } + } + } + + private func startBrowsing() { + guard authStore.hasRepoAccess else { + authStore.requestRepoAccess() + return + } + isBrowsingRepos = true + isLoadingRepos = true + browseErrorMessage = nil + Task { + do { + availableRepos = try await store.fetchAccessibleRepos() + } catch { + browseErrorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + } + isLoadingRepos = false + } + } + private func addRepo() { let text = newRepoText newRepoText = "" @@ -207,6 +298,24 @@ private struct RepoManagerView: View { } } +private struct UpdateBanner: View { + let release: UpdateChecker.LatestRelease + + var body: some View { + Button { + NSWorkspace.shared.open(release.htmlURL) + } label: { + Label("Update available: \(release.version)", systemImage: "arrow.down.circle.fill") + .font(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .foregroundStyle(.blue) + .padding(8) + .background(.blue.opacity(0.1)) + } +} + private struct ErrorBanner: View { let messages: [String] @@ -262,6 +371,6 @@ private struct StatusView: View { } #Preview { - SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore()) + SecurityEventListView(store: SecurityEventStore(), authStore: AuthStore(), updateStore: UpdateStore()) .frame(width: 380, height: 420) } diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift index 26dcc16..8f3d0f5 100644 --- a/octosentry/SecurityEventStore.swift +++ b/octosentry/SecurityEventStore.swift @@ -134,6 +134,16 @@ final class SecurityEventStore { await refresh() } + /// Lists repos the current token can see, for the repo picker (#15). + /// Requires broader repo-access scope — throws if the token only has + /// the default security_events scope. + func fetchAccessibleRepos() async throws -> [String] { + guard let token = KeychainTokenStore.load() else { + throw GitHubAPIError.missingToken + } + return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos() + } + /// Local-only triage state (spec §11) — no API write, no scope beyond /// read needed. Removes the event from the active stream. func markSeen(_ eventID: String) async { diff --git a/octosentry/UpdateChecker.swift b/octosentry/UpdateChecker.swift new file mode 100644 index 0000000..e90cab1 --- /dev/null +++ b/octosentry/UpdateChecker.swift @@ -0,0 +1,85 @@ +// +// UpdateChecker.swift +// octosentry +// +// Polls this repo's own GitHub Releases API (spec §9) — no auto-install, +// no Sparkle, just a link to the release page. Skipped entirely on the +// Mac App Store build, detected at runtime via the presence of an App +// Store receipt rather than a separate build configuration: same +// outcome (this code never runs there) with far less project surface +// than maintaining a second Xcode configuration/scheme just for this. +// + +import Foundation + +actor UpdateChecker { + private let session: URLSession + private let repoOwner = "zerolabsco" + private let repoName = "octosentry" + + init(session: URLSession = .shared) { + self.session = session + } + + struct LatestRelease: Sendable { + let version: String + let htmlURL: URL + } + + func fetchLatestRelease() async throws -> LatestRelease { + var request = URLRequest(url: URL(string: "https://api.github.com/repos/\(repoOwner)/\(repoName)/releases/latest")!) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw UpdateCheckError.network(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw UpdateCheckError.requestFailed + } + + let dto: GitHubReleaseDTO + do { + dto = try JSONDecoder().decode(GitHubReleaseDTO.self, from: data) + } catch { + throw UpdateCheckError.decodingFailed(error.localizedDescription) + } + + guard let url = URL(string: dto.htmlUrl) else { + throw UpdateCheckError.decodingFailed("Malformed release URL.") + } + return LatestRelease(version: dto.tagName, htmlURL: url) + } +} + +nonisolated struct GitHubReleaseDTO: Decodable { + let tagName: String + let htmlUrl: String + + enum CodingKeys: String, CodingKey { + case tagName = "tag_name" + case htmlUrl = "html_url" + } +} + +nonisolated enum UpdateCheckError: Error, LocalizedError { + case network(String) + case requestFailed + case decodingFailed(String) + + var errorDescription: String? { + switch self { + case .network(let message): + "Network error checking for updates: \(message)" + case .requestFailed: + "Failed to check for updates." + case .decodingFailed(let message): + "Unexpected response checking for updates: \(message)" + } + } +} diff --git a/octosentry/UpdateStore.swift b/octosentry/UpdateStore.swift new file mode 100644 index 0000000..1ed463f --- /dev/null +++ b/octosentry/UpdateStore.swift @@ -0,0 +1,55 @@ +// +// UpdateStore.swift +// octosentry +// + +import Foundation +import Observation + +@Observable +final class UpdateStore { + private(set) var availableRelease: UpdateChecker.LatestRelease? + + private let checker = UpdateChecker() + + /// True for a Mac App Store build (has an App Store receipt), false for + /// a direct DMG/Homebrew build. Runtime check rather than a build flag — + /// see UpdateChecker.swift for why. + var isMacAppStoreBuild: Bool { + guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false } + return FileManager.default.fileExists(atPath: receiptURL.path) + } + + func checkForUpdate() async { + guard !isMacAppStoreBuild else { return } + guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { return } + guard let latest = try? await checker.fetchLatestRelease() else { return } + + if Self.isNewer(latest.version, than: currentVersion) { + availableRelease = latest + } + } + + static func isNewer(_ candidate: String, than current: String) -> Bool { + let candidateParts = versionComponents(candidate) + let currentParts = versionComponents(current) + let count = max(candidateParts.count, currentParts.count) + + for i in 0..<count { + let candidatePart = i < candidateParts.count ? candidateParts[i] : 0 + let currentPart = i < currentParts.count ? currentParts[i] : 0 + if candidatePart != currentPart { + return candidatePart > currentPart + } + } + return false + } + + private static func versionComponents(_ version: String) -> [Int] { + var trimmed = version + if trimmed.hasPrefix("v") { + trimmed.removeFirst() + } + return trimmed.split(separator: ".").map { Int($0) ?? 0 } + } +} diff --git a/octosentry/octosentryApp.swift b/octosentry/octosentryApp.swift index e80c4a0..d9f388e 100644 --- a/octosentry/octosentryApp.swift +++ b/octosentry/octosentryApp.swift @@ -15,10 +15,11 @@ enum SecurityEventWindow { struct octosentryApp: App { @State private var store = SecurityEventStore() @State private var authStore = AuthStore() + @State private var updateStore = UpdateStore() var body: some Scene { MenuBarExtra { - SecurityEventListView(store: store, authStore: authStore) + SecurityEventListView(store: store, authStore: authStore, updateStore: updateStore) .frame(width: 380, height: 420) } label: { MenuBarIconView(criticalCount: store.unseenCriticalCount) @@ -26,7 +27,7 @@ struct octosentryApp: App { .menuBarExtraStyle(.window) Window("Security Events", id: SecurityEventWindow.id) { - SecurityEventListView(store: store, authStore: authStore, isStandaloneWindow: true) + SecurityEventListView(store: store, authStore: authStore, updateStore: updateStore, isStandaloneWindow: true) .frame(minWidth: 420, minHeight: 480) } } |
