| @@ -2,14 +2,16 @@ |
| 2 | 2 | // SecurityEventStore.swift |
| 3 | 3 | // octosentry |
| 4 | 4 | // |
| 5 | | // Holds the fetched event stream for the popover. MVP scope: one |
| 6 | | // hardcoded repo, in-memory only, PAT read from GITHUB_TOKEN (spec §13). |
| 5 | // Holds the fetched event stream for the popover. Watch list, seen-state, |
| 6 | // and last-fetch timestamps are persisted (see PersistedState); the PAT |
| 7 | // is still read from GITHUB_TOKEN as a dev-only shortcut (spec §13). |
| 7 | 8 | // |
| 8 | | // Each alert source is fetched independently so a problem with one |
| 9 | | // endpoint doesn't blank out the other two. A 403/404 on a single source |
| 10 | | // usually just means that alert type is disabled for the repo (or the |
| 11 | | // token lacks that one permission) — not a real failure — so those are |
| 12 | | // reported as quiet "unavailable" notices rather than alarming errors. |
| 9 | // Each alert source is fetched independently, per repo, so a problem |
| 10 | // with one endpoint (or one repo) doesn't blank out the rest. A 403/404 |
| 11 | // on a single source usually just means that alert type is disabled for |
| 12 | // the repo (or the token lacks that one permission) — not a real |
| 13 | // failure — so those are reported as quiet "unavailable" notices rather |
| 14 | // than alarming errors. |
| 13 | 15 | // |
| 14 | 16 | |
| 15 | 17 | import Foundation |
| @@ -21,9 +23,14 @@ final class SecurityEventStore { |
| 21 | 23 | private(set) var isLoading = false |
| 22 | 24 | private(set) var errorMessages: [String] = [] |
| 23 | 25 | private(set) var unavailableNotices: [String] = [] |
| 26 | private(set) var minimumSeverity: SecurityEventSeverity = .low |
| 27 | private(set) var totalFetchedCount = 0 |
| 28 | private(set) var watchedRepos: [String] = [] |
| 29 | private(set) var watchListErrorMessage: String? |
| 24 | 30 | |
| 25 | | private let owner = "ccleberg" |
| 26 | | private let repo = "cleberg.net" |
| 31 | private let persistenceStore = PersistenceStore() |
| 32 | private var rawEvents: [SecurityEvent] = [] |
| 33 | private var pollingTask: Task<Void, Never>? |
| 27 | 34 | |
| 28 | 35 | func refresh() async { |
| 29 | 36 | isLoading = true |
| @@ -31,6 +38,10 @@ final class SecurityEventStore { |
| 31 | 38 | unavailableNotices = [] |
| 32 | 39 | defer { isLoading = false } |
| 33 | 40 | |
| 41 | var state = await persistenceStore.load() |
| 42 | minimumSeverity = state.minimumSeverity |
| 43 | watchedRepos = state.watchedRepos |
| 44 | |
| 34 | 45 | guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { |
| 35 | 46 | errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] |
| 36 | 47 | return |
| @@ -38,37 +49,117 @@ final class SecurityEventStore { |
| 38 | 49 | |
| 39 | 50 | let client = GitHubSecurityAPIClient(token: token) |
| 40 | 51 | |
| 41 | | async let dependabot = fetchSource(label: "Dependabot") { |
| 42 | | try await client.fetchDependabotAlerts(owner: self.owner, repo: self.repo) |
| 43 | | } |
| 44 | | async let codeScanning = fetchSource(label: "Code scanning") { |
| 45 | | try await client.fetchCodeScanningAlerts(owner: self.owner, repo: self.repo) |
| 46 | | } |
| 47 | | async let secretScanning = fetchSource(label: "Secret scanning") { |
| 48 | | try await client.fetchSecretScanningAlerts(owner: self.owner, repo: self.repo) |
| 49 | | } |
| 50 | | |
| 51 | | let outcomes = await [dependabot, codeScanning, secretScanning] |
| 52 | | |
| 53 | 52 | var fetchedEvents: [SecurityEvent] = [] |
| 54 | 53 | var errors: [String] = [] |
| 55 | 54 | var notices: [String] = [] |
| 56 | | for outcome in outcomes { |
| 57 | | switch outcome { |
| 58 | | case .events(let sourceEvents): |
| 59 | | fetchedEvents += sourceEvents |
| 60 | | case .unavailable(let label): |
| 61 | | notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).") |
| 62 | | case .failed(let label, let message): |
| 63 | | errors.append("\(label): \(message)") |
| 55 | |
| 56 | for repoFullName in state.watchedRepos { |
| 57 | let parts = repoFullName.split(separator: "/", maxSplits: 1) |
| 58 | guard parts.count == 2 else { continue } |
| 59 | let owner = String(parts[0]) |
| 60 | let repo = String(parts[1]) |
| 61 | |
| 62 | async let dependabot = fetchSource(label: "\(repoFullName) · Dependabot") { |
| 63 | try await client.fetchDependabotAlerts(owner: owner, repo: repo) |
| 64 | } |
| 65 | async let codeScanning = fetchSource(label: "\(repoFullName) · Code scanning") { |
| 66 | try await client.fetchCodeScanningAlerts(owner: owner, repo: repo) |
| 67 | } |
| 68 | async let secretScanning = fetchSource(label: "\(repoFullName) · Secret scanning") { |
| 69 | try await client.fetchSecretScanningAlerts(owner: owner, repo: repo) |
| 70 | } |
| 71 | |
| 72 | let outcomes = await [dependabot, codeScanning, secretScanning] |
| 73 | var repoSucceeded = false |
| 74 | for outcome in outcomes { |
| 75 | switch outcome { |
| 76 | case .events(let sourceEvents): |
| 77 | fetchedEvents += sourceEvents |
| 78 | repoSucceeded = true |
| 79 | case .unavailable(let label): |
| 80 | notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).") |
| 81 | case .failed(let label, let message): |
| 82 | errors.append("\(label): \(message)") |
| 83 | } |
| 84 | } |
| 85 | if repoSucceeded { |
| 86 | state.lastFetchByRepo[repoFullName] = Date() |
| 64 | 87 | } |
| 65 | 88 | } |
| 66 | 89 | |
| 67 | | events = fetchedEvents.sorted { lhs, rhs in |
| 68 | | lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt |
| 90 | rawEvents = fetchedEvents.map { event in |
| 91 | var event = event |
| 92 | event.seenLocally = state.seenEventIDs.contains(event.id) |
| 93 | return event |
| 69 | 94 | } |
| 95 | totalFetchedCount = rawEvents.count |
| 96 | applyMinimumSeverityFilter() |
| 97 | |
| 70 | 98 | errorMessages = errors |
| 71 | 99 | unavailableNotices = notices |
| 100 | await persistenceStore.save(state) |
| 101 | } |
| 102 | |
| 103 | func setMinimumSeverity(_ severity: SecurityEventSeverity) async { |
| 104 | minimumSeverity = severity |
| 105 | applyMinimumSeverityFilter() |
| 106 | |
| 107 | var state = await persistenceStore.load() |
| 108 | state.minimumSeverity = severity |
| 109 | await persistenceStore.save(state) |
| 110 | } |
| 111 | |
| 112 | func addRepo(_ input: String) async { |
| 113 | watchListErrorMessage = nil |
| 114 | let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) |
| 115 | let parts = trimmed.split(separator: "/", omittingEmptySubsequences: true) |
| 116 | guard parts.count == 2 else { |
| 117 | watchListErrorMessage = "Enter a repo as \"owner/repo\"." |
| 118 | return |
| 119 | } |
| 120 | |
| 121 | var state = await persistenceStore.load() |
| 122 | guard !state.watchedRepos.contains(trimmed) else { |
| 123 | watchListErrorMessage = "\(trimmed) is already watched." |
| 124 | return |
| 125 | } |
| 126 | state.watchedRepos.append(trimmed) |
| 127 | await persistenceStore.save(state) |
| 128 | watchedRepos = state.watchedRepos |
| 129 | |
| 130 | await refresh() |
| 131 | } |
| 132 | |
| 133 | func removeRepo(_ repoFullName: String) async { |
| 134 | var state = await persistenceStore.load() |
| 135 | state.watchedRepos.removeAll { $0 == repoFullName } |
| 136 | state.lastFetchByRepo.removeValue(forKey: repoFullName) |
| 137 | await persistenceStore.save(state) |
| 138 | watchedRepos = state.watchedRepos |
| 139 | |
| 140 | await refresh() |
| 141 | } |
| 142 | |
| 143 | /// Keeps the feed reasonably fresh even while the popover is closed, |
| 144 | /// without hammering GitHub's rate limit (5000/hr authenticated). |
| 145 | /// Idempotent — safe to call every time the popover opens. |
| 146 | func startPolling(interval: Duration = .seconds(900)) { |
| 147 | guard pollingTask == nil else { return } |
| 148 | pollingTask = Task { |
| 149 | while !Task.isCancelled { |
| 150 | try? await Task.sleep(for: interval) |
| 151 | guard !Task.isCancelled else { return } |
| 152 | await refresh() |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | private func applyMinimumSeverityFilter() { |
| 158 | events = rawEvents |
| 159 | .filter { $0.severity >= minimumSeverity } |
| 160 | .sorted { lhs, rhs in |
| 161 | lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt |
| 162 | } |
| 72 | 163 | } |
| 73 | 164 | |
| 74 | 165 | private enum SourceOutcome { |