summaryrefslogtreecommitdiff
path: root/octosentry/SecurityEventStore.swift
blob: a45d5c7bff2b52485f8526b0a32eb94bf1833cc8 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
//
//  SecurityEventStore.swift
//  octosentry
//
//  Holds the fetched event stream for the popover. MVP scope: one
//  hardcoded repo, in-memory only, PAT read from GITHUB_TOKEN (spec §13).
//
//  Each alert source is fetched independently so a problem with one
//  endpoint doesn't blank out the other two. A 403/404 on a single source
//  usually just means that alert type is disabled for the repo (or the
//  token lacks that one permission) — not a real failure — so those are
//  reported as quiet "unavailable" notices rather than alarming errors.
//

import Foundation
import Observation

@Observable
final class SecurityEventStore {
    private(set) var events: [SecurityEvent] = []
    private(set) var isLoading = false
    private(set) var errorMessages: [String] = []
    private(set) var unavailableNotices: [String] = []

    private let owner = "ccleberg"
    private let repo = "cleberg.net"

    func refresh() async {
        isLoading = true
        errorMessages = []
        unavailableNotices = []
        defer { isLoading = false }

        guard let token = ProcessInfo.processInfo.environment["GITHUB_TOKEN"], !token.isEmpty else {
            errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Missing GITHUB_TOKEN."]
            return
        }

        let client = GitHubSecurityAPIClient(token: token)

        async let dependabot = fetchSource(label: "Dependabot") {
            try await client.fetchDependabotAlerts(owner: self.owner, repo: self.repo)
        }
        async let codeScanning = fetchSource(label: "Code scanning") {
            try await client.fetchCodeScanningAlerts(owner: self.owner, repo: self.repo)
        }
        async let secretScanning = fetchSource(label: "Secret scanning") {
            try await client.fetchSecretScanningAlerts(owner: self.owner, repo: self.repo)
        }

        let outcomes = await [dependabot, codeScanning, secretScanning]

        var fetchedEvents: [SecurityEvent] = []
        var errors: [String] = []
        var notices: [String] = []
        for outcome in outcomes {
            switch outcome {
            case .events(let sourceEvents):
                fetchedEvents += sourceEvents
            case .unavailable(let label):
                notices.append("\(label) alerts aren't available for this repo (disabled, or token lacks that permission).")
            case .failed(let label, let message):
                errors.append("\(label): \(message)")
            }
        }

        events = fetchedEvents.sorted { lhs, rhs in
            lhs.severity != rhs.severity ? lhs.severity > rhs.severity : lhs.createdAt > rhs.createdAt
        }
        errorMessages = errors
        unavailableNotices = notices
    }

    private enum SourceOutcome {
        case events([SecurityEvent])
        case unavailable(label: String)
        case failed(label: String, message: String)
    }

    private func fetchSource(
        label: String,
        _ operation: () async throws -> [SecurityEvent]
    ) async -> SourceOutcome {
        do {
            return .events(try await operation())
        } catch GitHubAPIError.forbidden, GitHubAPIError.notFound {
            return .unavailable(label: label)
        } catch {
            let description = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
            return .failed(label: label, message: description)
        }
    }
}