krz/octosentry
macOS menu bar app to monitor GitHub security alerts
clone: git clone https://gitbay.org/krz/octosentry.git
89d41cc954f40c38f68fe2fe43913069057d38ac
signed_unknown_key
author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T21:33:04Z
committer: <noreply@github.com>
octosentry/Account.swift | 45 +++++++++-- octosentry/AuthStore.swift | 43 ++++++----- octosentry/GitHubDeviceAuthClient.swift | 15 ++-- octosentry/GitHubHost.swift | 67 +++++++++++++++++ octosentry/GitHubSecurityAPIClient.swift | 11 +-- octosentry/SecurityEventListView.swift | 50 ++++++++++++- octosentry/SecurityEventStore.swift | 4 +- octosentryTests/GitHubHostTests.swift | 124 +++++++++++++++++++++++++++++++ 8 files changed, 322 insertions(+), 37 deletions(-) @@ -20,9 +20,37 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable { var login: String var keychainAccount: String var hasRepoScope: Bool + /// Which GitHub this account lives on. Absent in files written before + /// Enterprise support, which means github.com. + var host: GitHubHost = .dotCom + + enum CodingKeys: String, CodingKey { + case id, login, keychainAccount, hasRepoScope, host + } + + init(id: Int, login: String, keychainAccount: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) { + self.id = id + self.login = login + self.keychainAccount = keychainAccount + self.hasRepoScope = hasRepoScope + self.host = host + } + + // Synthesized decoding ignores property defaults, so an account written + // before Enterprise support would fail to decode and take the whole + // state file down with it. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + login = try container.decode(String.self, forKey: .login) + keychainAccount = try container.decode(String.self, forKey: .keychainAccount) + hasRepoScope = try container.decode(Bool.self, forKey: .hasRepoScope) + host = try container.decodeIfPresent(GitHubHost.self, forKey: .host) ?? .dotCom + } var displayName: String { - login.isEmpty ? "GitHub account" : login + let name = login.isEmpty ? "GitHub account" : login + return host.isDotCom ? name : "\(name) @ \(host.displayName)" } /// The account an upgrading install already has a token for. @@ -31,16 +59,21 @@ nonisolated struct Account: Codable, Equatable, Identifiable, Hashable { id: 0, login: "", keychainAccount: KeychainTokenStore.legacyAccount, - hasRepoScope: false + hasRepoScope: false, + host: .dotCom ) } - static func new(id: Int, login: String, hasRepoScope: Bool) -> Account { - Account( + static func new(id: Int, login: String, hasRepoScope: Bool, host: GitHubHost = .dotCom) -> Account { + // Ids are only unique within an instance, so a GHES account's + // Keychain item is namespaced by host too. + let suffix = host.isDotCom ? "\(id)" : "\(host.displayName)-\(id)" + return Account( id: id, login: login, - keychainAccount: "account-\(id)", - hasRepoScope: hasRepoScope + keychainAccount: "account-\(suffix)", + hasRepoScope: hasRepoScope, + host: host ) } } @@ -21,7 +21,6 @@ final class AuthStore { private(set) var errorMessage: String? private(set) var accounts: [Account] = [] - private let client = GitHubDeviceAuthClient() private let persistenceStore = PersistenceStore() private var authorizationTask: Task<Void, Never>? @@ -43,21 +42,22 @@ final class AuthStore { accounts.contains(where: \.hasRepoScope) } - func signIn() { - beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) + func signIn(host: GitHubHost = .dotCom) { + beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host) } - /// Adds another identity. Same flow as signing in — GitHub decides which - /// account authorizes the code. - func addAccount() { - beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope) + /// Adds another identity, optionally on a GitHub Enterprise Server + /// instance. Same flow either way — GitHub decides which account + /// authorizes the code. + func addAccount(host: GitHubHost = .dotCom) { + beginAuthorization(scope: GitHubDeviceAuthClient.defaultScope, host: host) } /// 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 requestRepoAccess(host: GitHubHost = .dotCom) { + beginAuthorization(scope: GitHubDeviceAuthClient.repoAccessScope, host: host) } /// Signs out one account, leaving the others alone. @@ -121,7 +121,7 @@ final class AuthStore { for account in unresolved { guard let token = KeychainTokenStore.load(account: account.keychainAccount), - let user = try? await GitHubSecurityAPIClient(token: token).fetchCurrentUser(), + let user = try? await GitHubSecurityAPIClient(token: token, host: account.host).fetchCurrentUser(), let index = persisted.accounts.firstIndex(where: { $0.keychainAccount == account.keychainAccount }) else { continue } @@ -143,13 +143,14 @@ final class AuthStore { } } - private func beginAuthorization(scope: String) { + private func beginAuthorization(scope: String, host: GitHubHost) { guard authorizationTask == nil else { return } errorMessage = nil authorizationTask = Task { defer { authorizationTask = nil } do { + let client = GitHubDeviceAuthClient(host: host) let deviceCode = try await client.requestDeviceCode(scope: scope) state = .awaitingAuthorization(userCode: deviceCode.userCode, verificationURL: deviceCode.verificationUri) @@ -158,7 +159,7 @@ final class AuthStore { interval: deviceCode.interval, expiresIn: deviceCode.expiresIn ) - try await register(token: token, grantedRepoScope: scope.contains("repo")) + try await register(token: token, grantedRepoScope: scope.contains("repo"), host: host) state = .signedIn } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription @@ -173,30 +174,36 @@ final class AuthStore { /// Stores a freshly authorized token under its own Keychain item and /// records the account. Re-authorizing an account already present updates /// it in place rather than adding a duplicate. - private func register(token: String, grantedRepoScope: Bool) async throws { - let user = try await GitHubSecurityAPIClient(token: token).fetchCurrentUser() + private func register(token: String, grantedRepoScope: Bool, host: GitHubHost) async throws { + let user = try await GitHubSecurityAPIClient(token: token, host: host).fetchCurrentUser() var persisted = await persistenceStore.load() - if let index = persisted.accounts.firstIndex(where: { $0.id == user.id }) { + if let index = persisted.accounts.firstIndex(where: { $0.id == user.id && $0.host == host }) { persisted.accounts[index].login = user.login persisted.accounts[index].hasRepoScope = grantedRepoScope try KeychainTokenStore.save(token, account: persisted.accounts[index].keychainAccount) - } else if let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) { + } else if host.isDotCom, let index = persisted.accounts.firstIndex(where: { $0.id == 0 }) { // The adopted single-account entry, now identified. let keychainAccount = persisted.accounts[index].keychainAccount persisted.accounts[index] = Account( id: user.id, login: user.login, keychainAccount: keychainAccount, - hasRepoScope: grantedRepoScope + hasRepoScope: grantedRepoScope, + host: host ) for repoIndex in persisted.watchedRepos.indices where persisted.watchedRepos[repoIndex].accountID == 0 { persisted.watchedRepos[repoIndex].accountID = user.id } try KeychainTokenStore.save(token, account: keychainAccount) } else { - let account = Account.new(id: user.id, login: user.login, hasRepoScope: grantedRepoScope) + let account = Account.new( + id: user.id, + login: user.login, + hasRepoScope: grantedRepoScope, + host: host + ) try KeychainTokenStore.save(token, account: account.keychainAccount) persisted.accounts.append(account) } @@ -12,8 +12,12 @@ import Foundation actor GitHubDeviceAuthClient { // Public client identifier for the "octosentry" OAuth App (Device Flow enabled). - // Not a secret — safe to embed in source. - private let clientID = "Ov23li6tqaTghDc4IJYv" + // Not a secret — safe to embed in source. A GitHub Enterprise Server + // instance needs its own admin-registered app instead (#20). + static let dotComClientID = "Ov23li6tqaTghDc4IJYv" + + private let host: GitHubHost + private var clientID: String { host.clientID ?? Self.dotComClientID } // Default sign-in scope: grants Dependabot/code scanning/secret scanning alert // access. Classic OAuth scopes have no read-only variant (unlike fine-grained @@ -26,13 +30,14 @@ actor GitHubDeviceAuthClient { private let session: URLSession - init(session: URLSession = .shared) { + init(host: GitHubHost = .dotCom, session: URLSession = .shared) { + self.host = host self.session = session } func requestDeviceCode(scope: String) async throws -> DeviceCodeResponse { let data = try await post( - url: URL(string: "https://github.com/login/device/code")!, + url: host.deviceCodeURL, parameters: ["client_id": clientID, "scope": scope] ) do { @@ -52,7 +57,7 @@ actor GitHubDeviceAuthClient { try Task.checkCancellation() let data = try await post( - url: URL(string: "https://github.com/login/oauth/access_token")!, + url: host.accessTokenURL, parameters: [ "client_id": clientID, "device_code": deviceCode, new file mode 100644 @@ -0,0 +1,67 @@ +// +// GitHubHost.swift +// octosentry +// +// Which GitHub a token talks to. github.com and GitHub Enterprise Server +// differ in more than a hostname: GHES puts the REST API under /api/v3 on +// the same host rather than on a separate api. domain, and its device flow +// needs an OAuth app registered by an instance admin, so octosentry's own +// client ID doesn't apply. +// +// Host is a property of an account (#19), not a global setting — someone +// can reasonably watch repos on github.com and on their employer's GHES at +// the same time. +// + +import Foundation + +nonisolated struct GitHubHost: Codable, Equatable, Hashable { + /// nil means github.com. Otherwise the GHES web host, without a scheme. + var host: String? + /// Required for GHES; github.com uses octosentry's registered app. + var clientID: String? + + static let dotCom = GitHubHost(host: nil, clientID: nil) + + /// Accepts what a user is likely to paste — with or without a scheme, + /// with or without a trailing slash or path. + init(host: String?, clientID: String?) { + self.host = host.flatMap(Self.normalize) + let trimmedClientID = clientID?.trimmingCharacters(in: .whitespacesAndNewlines) + self.clientID = (trimmedClientID?.isEmpty == false) ? trimmedClientID : nil + } + + private static func normalize(_ raw: String) -> String? { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + for prefix in ["https://", "http://"] where value.hasPrefix(prefix) { + value.removeFirst(prefix.count) + } + if let slash = value.firstIndex(of: "/") { + value = String(value[value.startIndex..<slash]) + } + guard !value.isEmpty, value != "github.com" else { return nil } + return value + } + + var isDotCom: Bool { host == nil } + + var displayName: String { host ?? "github.com" } + + /// GHES serves the REST API from the same host under /api/v3. + var apiBaseURL: URL { + guard let host else { return URL(string: "https://api.github.com")! } + return URL(string: "https://\(host)/api/v3")! + } + + var webBaseURL: URL { + URL(string: "https://\(host ?? "github.com")")! + } + + var deviceCodeURL: URL { + webBaseURL.appendingPathComponent("login/device/code") + } + + var accessTokenURL: URL { + webBaseURL.appendingPathComponent("login/oauth/access_token") + } +} @@ -13,7 +13,7 @@ import Foundation actor GitHubSecurityAPIClient { private let token: String private let session: URLSession - private let baseURL = URL(string: "https://api.github.com")! + private let baseURL: URL private static let decoder: JSONDecoder = { let decoder = JSONDecoder() @@ -21,8 +21,9 @@ actor GitHubSecurityAPIClient { return decoder }() - init(token: String, session: URLSession = .shared) { + init(token: String, host: GitHubHost = .dotCom, session: URLSession = .shared) { self.token = token + self.baseURL = host.apiBaseURL self.session = session } @@ -94,7 +95,7 @@ actor GitHubSecurityAPIClient { /// 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.path = baseURL.path + "/user/repos" components.queryItems = [ URLQueryItem(name: "per_page", value: "100"), URLQueryItem(name: "sort", value: "full_name"), @@ -107,14 +108,14 @@ actor GitHubSecurityAPIClient { /// alerts attributed. Needs no scope beyond a valid user token. func fetchCurrentUser() async throws -> GitHubUserDTO { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! - components.path = "/user" + components.path = baseURL.path + "/user" let (data, _) = try await fetchData(url: components.url!) return try decode(data) } private func alertsURL(owner: String, repo: String, path: String) -> URL { var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! - components.path = "/repos/\(owner)/\(repo)/\(path)" + components.path = baseURL.path + "/repos/\(owner)/\(repo)/\(path)" components.queryItems = [ URLQueryItem(name: "state", value: "open"), URLQueryItem(name: "per_page", value: "100"), @@ -347,6 +347,8 @@ private struct RepoManagerView: View { .buttonStyle(.plain) .foregroundStyle(Color.accentColor) + EnterpriseSignInView(authStore: authStore) + if let errorMessage = store.watchListErrorMessage { Text(errorMessage) .font(.caption2) @@ -485,7 +487,7 @@ private struct RepoManagerView: View { private func startBrowsing(_ account: Account) { guard account.hasRepoScope else { - authStore.requestRepoAccess() + authStore.requestRepoAccess(host: account.host) return } browsingAccount = account @@ -509,6 +511,52 @@ private struct RepoManagerView: View { } } +/// Signing in to a GitHub Enterprise Server instance. Collapsed by default — +/// most people are on github.com, and GHES needs details they have to get +/// from an instance admin. +private struct EnterpriseSignInView: View { + var authStore: AuthStore + @State private var isExpanded = false + @State private var hostText = "" + @State private var clientIDText = "" + + private var host: GitHubHost { + GitHubHost(host: hostText, clientID: clientIDText) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + isExpanded.toggle() + } label: { + Label("Add a GitHub Enterprise account", systemImage: "building.2") + .font(.caption) + } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + + if isExpanded { + TextField("github.example.com", text: $hostText) + .textFieldStyle(.roundedBorder) + TextField("OAuth app client ID", text: $clientIDText) + .textFieldStyle(.roundedBorder) + + Text("Device flow on Enterprise Server needs an OAuth app registered on the instance. Ask an admin for its client ID.") + .font(.caption2) + .foregroundStyle(.secondary) + + Button("Sign In") { + authStore.addAccount(host: host) + isExpanded = false + hostText = "" + clientIDText = "" + } + .disabled(host.isDotCom || host.clientID == nil) + } + } + } +} + private struct FilterLabel: View { let title: String let count: Int @@ -111,7 +111,7 @@ final class SecurityEventStore { errors.append("\(repoFullName): no signed-in account can reach this repo.") continue } - let client = GitHubSecurityAPIClient(token: token) + let client = GitHubSecurityAPIClient(token: token, host: account.host) let label = accountsByID.count > 1 ? "\(repoFullName) (\(account.displayName))" : repoFullName @@ -257,7 +257,7 @@ final class SecurityEventStore { guard let token = KeychainTokenStore.load(account: account.keychainAccount) else { throw GitHubAPIError.missingToken } - return try await GitHubSecurityAPIClient(token: token).fetchAccessibleRepos() + return try await GitHubSecurityAPIClient(token: token, host: account.host).fetchAccessibleRepos() } /// Local-only triage state (spec §11) — no API write, no scope beyond new file mode 100644 @@ -0,0 +1,124 @@ +// +// GitHubHostTests.swift +// octosentryTests +// + +import Foundation +import Testing +@testable import octosentry + +struct GitHubHostTests { + + // MARK: - github.com + + @Test func theDefaultIsGitHubDotCom() { + #expect(GitHubHost.dotCom.isDotCom) + #expect(GitHubHost.dotCom.displayName == "github.com") + #expect(GitHubHost.dotCom.apiBaseURL.absoluteString == "https://api.github.com") + #expect(GitHubHost.dotCom.webBaseURL.absoluteString == "https://github.com") + } + + @Test func dotComKeepsItsExistingAuthEndpoints() { + #expect(GitHubHost.dotCom.deviceCodeURL.absoluteString == "https://github.com/login/device/code") + #expect(GitHubHost.dotCom.accessTokenURL.absoluteString == "https://github.com/login/oauth/access_token") + } + + // Naming github.com explicitly is still github.com, not an enterprise host. + @Test func namingGitHubDotComExplicitlyIsNotEnterprise() { + #expect(GitHubHost(host: "github.com", clientID: "abc").isDotCom) + #expect(GitHubHost(host: "https://github.com", clientID: "abc").isDotCom) + } + + // MARK: - Enterprise Server + + // GHES serves the REST API from the same host under /api/v3, not from a + // separate api. domain. + @Test func enterpriseAPILivesUnderApiV3OnTheSameHost() { + let host = GitHubHost(host: "github.example.com", clientID: "abc") + + #expect(host.apiBaseURL.absoluteString == "https://github.example.com/api/v3") + #expect(host.webBaseURL.absoluteString == "https://github.example.com") + #expect(!host.isDotCom) + } + + @Test func enterpriseAuthEndpointsAreOnTheInstance() { + let host = GitHubHost(host: "github.example.com", clientID: "abc") + + #expect(host.deviceCodeURL.absoluteString == "https://github.example.com/login/device/code") + #expect(host.accessTokenURL.absoluteString == "https://github.example.com/login/oauth/access_token") + } + + // MARK: - Normalizing what a user pastes + + @Test func aPastedURLIsReducedToItsHost() { + for input in [ + "https://github.example.com", + "http://github.example.com", + "github.example.com/", + "https://github.example.com/some/path", + " GitHub.Example.com ", + ] { + #expect(GitHubHost(host: input, clientID: "abc").host == "github.example.com", "input: \(input)") + } + } + + @Test func anEmptyHostMeansGitHubDotCom() { + #expect(GitHubHost(host: "", clientID: nil).isDotCom) + #expect(GitHubHost(host: " ", clientID: nil).isDotCom) + #expect(GitHubHost(host: nil, clientID: nil).isDotCom) + } + + @Test func anEmptyClientIDIsTreatedAsAbsent() { + #expect(GitHubHost(host: "github.example.com", clientID: "").clientID == nil) + #expect(GitHubHost(host: "github.example.com", clientID: " ").clientID == nil) + #expect(GitHubHost(host: "github.example.com", clientID: " abc ").clientID == "abc") + } + + // MARK: - Persistence + + @Test func roundTripsThroughCodable() throws { + for host in [GitHubHost.dotCom, GitHubHost(host: "github.example.com", clientID: "abc")] { + let decoded = try JSONDecoder().decode(GitHubHost.self, from: try JSONEncoder().encode(host)) + #expect(decoded == host) + } + } + + // An account written before Enterprise support has no host field. + @Test func anAccountWithoutAHostFieldDecodesAsGitHubDotCom() throws { + let json = """ + {"id": 7, "login": "octocat", "keychainAccount": "account-7", "hasRepoScope": false} + """ + + let account = try JSONDecoder().decode(Account.self, from: Data(json.utf8)) + + #expect(account.host == .dotCom) + #expect(account.displayName == "octocat") + } + + // MARK: - Accounts + + // Ids are only unique within an instance, so two accounts that happen to + // share an id on different hosts must not share a Keychain item. + @Test func sameIdOnDifferentHostsGetsDifferentKeychainItems() { + let dotCom = Account.new(id: 7, login: "octocat", hasRepoScope: false) + let enterprise = Account.new( + id: 7, + login: "octocat", + hasRepoScope: false, + host: GitHubHost(host: "github.example.com", clientID: "abc") + ) + + #expect(dotCom.keychainAccount != enterprise.keychainAccount) + } + + @Test func enterpriseAccountsAreLabelledWithTheirHost() { + let account = Account.new( + id: 7, + login: "octocat", + hasRepoScope: false, + host: GitHubHost(host: "github.example.com", clientID: "abc") + ) + + #expect(account.displayName == "octocat @ github.example.com") + } +}