| @@ -2,14 +2,16 @@ |
| 2 | // SecurityEventStore.swift |
2 | // SecurityEventStore.swift |
| 3 | // octosentry |
3 | // octosentry |
| 4 | // |
4 | // |
| 5 | // Holds the fetched event stream for the popover. MVP scope: one |
5 | // Holds the fetched event stream for the popover. Watch list, seen-state, |
| 6 | // hardcoded repo, in-memory only, PAT read from GITHUB_TOKEN (spec §13). |
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 | // Each alert source is fetched independently, per repo, so a problem |
| 9 | // endpoint doesn't blank out the other two. A 403/404 on a single source |
10 | // with one endpoint (or one repo) doesn't blank out the rest. A 403/404 |
| 10 | // usually just means that alert type is disabled for the repo (or the |
11 | // on a single source usually just means that alert type is disabled for |
| 11 | // token lacks that one permission) — not a real failure — so those are |
12 | // the repo (or the token lacks that one permission) — not a real |
| 12 | // reported as quiet "unavailable" notices rather than alarming errors. |
13 | // failure — so those are reported as quiet "unavailable" notices rather |
| |
14 | // than alarming errors. |
| 13 | // |
15 | // |
| 14 | |
16 | |
| 15 | import Foundation |
17 | import Foundation |
| @@ -21,9 +23,14 @@ final class SecurityEventStore { |
| 21 | private(set) var isLoading = false |
23 | private(set) var isLoading = false |
| 22 | private(set) var errorMessages: [String] = [] |
24 | private(set) var errorMessages: [String] = [] |
| 23 | private(set) var unavailableNotices: [String] = [] |
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" |
31 | private let persistenceStore = PersistenceStore() |
| 26 | private let repo = "cleberg.net" |
32 | private var rawEvents: [SecurityEvent] = [] |
| |
33 | private var pollingTask: Task<Void, Never>? |
| 27 | |
34 | |
| 28 | func refresh() async { |
35 | func refresh() async { |
| 29 | isLoading = true |
36 | isLoading = true |
| @@ -31,6 +38,10 @@ final class SecurityEventStore { |
| 31 | unavailableNotices = [] |
38 | unavailableNotices = [] |
| 32 | defer { isLoading = false } |
39 | defer { isLoading = false } |
| 33 | |
40 | |
| |
41 | var state = await persistenceStore.load() |
| |
42 | minimumSeverity = state.minimumSeverity |
| |
43 | watchedRepos = state.watchedRepos |
| |
44 | |
| 34 | guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { |
45 | guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else { |
| 35 | errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] |
46 | errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."] |
| 36 | return |
47 | return |
| @@ -38,37 +49,117 @@ final class SecurityEventStore { |
| 38 | |
49 | |
| 39 | let client = GitHubSecurityAPIClient(token: token) |
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 | var fetchedEvents: [SecurityEvent] = [] |
52 | var fetchedEvents: [SecurityEvent] = [] |
| 54 | var errors: [String] = [] |
53 | var errors: [String] = [] |
| 55 | var notices: [String] = [] |
54 | var notices: [String] = [] |
| 56 | for outcome in outcomes { |
55 | |
| 57 | switch outcome { |
56 | for repoFullName in state.watchedRepos { |
| 58 | case .events(let sourceEvents): |
57 | let parts = repoFullName.split(separator: "/", maxSplits: 1) |
| 59 | fetchedEvents += sourceEvents |
58 | guard parts.count == 2 else { continue } |
| 60 | case .unavailable(let label): |
59 | let owner = String(parts[0]) |
| 61 | notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).") |
60 | let repo = String(parts[1]) |
| 62 | case .failed(let label, let message): |
61 | |
| 63 | errors.append("\(label): \(message)") |
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 |
90 | rawEvents = fetchedEvents.map { event in |
| 68 | lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt |
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 | errorMessages = errors |
98 | errorMessages = errors |
| 71 | unavailableNotices = notices |
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 | private enum SourceOutcome { |
165 | private enum SourceOutcome { |