summaryrefslogtreecommitdiff
path: root/octosentry/GitHubSecurityAPIClient.swift
blob: 2e480bd1d80e88b23c739c7b75bbe3973a5861bf (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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
//
//  GitHubSecurityAPIClient.swift
//  octosentry
//
//  Fetches Dependabot, code scanning, and secret scanning alerts for a
//  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

actor GitHubSecurityAPIClient {
    private let token: String
    private let session: URLSession
    private let baseURL = URL(string: "https://api.github.com")!

    private static let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }()

    init(token: String, session: URLSession = .shared) {
        self.token = token
        self.session = session
    }

    func fetchDependabotAlerts(owner: String, repo: String) async throws -> [SecurityEvent] {
        let url = alertsURL(owner: owner, repo: repo, path: "dependabot/alerts")
        let dtos: [DependabotAlertDTO] = try await fetchAllPages(url: url)
        let repoFullName = "\(owner)/\(repo)"
        return dtos.map { dto in
            SecurityEvent(
                id: "dependabot-\(repoFullName)-\(dto.number)",
                source: .dependabot,
                repoFullName: repoFullName,
                severity: SeverityMapping.dependabot(dto.securityAdvisory.severity),
                nativeSeverityLabel: dto.securityAdvisory.severity.capitalized,
                summary: dto.securityAdvisory.summary,
                detailURL: dto.htmlUrl,
                createdAt: dto.createdAt,
                updatedAt: dto.updatedAt,
                seenLocally: false
            )
        }
    }

    func fetchCodeScanningAlerts(owner: String, repo: String) async throws -> [SecurityEvent] {
        let url = alertsURL(owner: owner, repo: repo, path: "code-scanning/alerts")
        let dtos: [CodeScanningAlertDTO] = try await fetchAllPages(url: url)
        let repoFullName = "\(owner)/\(repo)"
        return dtos.map { dto in
            SecurityEvent(
                id: "codeScanning-\(repoFullName)-\(dto.number)",
                source: .codeScanning,
                repoFullName: repoFullName,
                severity: SeverityMapping.codeScanning(
                    securitySeverityLevel: dto.rule.securitySeverityLevel,
                    ruleSeverity: dto.rule.severity
                ),
                nativeSeverityLabel: (dto.rule.securitySeverityLevel ?? dto.rule.severity ?? "unknown").capitalized,
                summary: dto.mostRecentInstance?.message?.text ?? dto.rule.description ?? dto.rule.id ?? "Code scanning alert",
                detailURL: dto.htmlUrl,
                createdAt: dto.createdAt,
                updatedAt: dto.updatedAt,
                seenLocally: false
            )
        }
    }

    func fetchSecretScanningAlerts(owner: String, repo: String) async throws -> [SecurityEvent] {
        let url = alertsURL(owner: owner, repo: repo, path: "secret-scanning/alerts")
        let dtos: [SecretScanningAlertDTO] = try await fetchAllPages(url: url)
        let repoFullName = "\(owner)/\(repo)"
        return dtos.map { dto in
            SecurityEvent(
                id: "secretScanning-\(repoFullName)-\(dto.number)",
                source: .secretScanning,
                repoFullName: repoFullName,
                severity: SeverityMapping.secretScanning(validity: dto.validity),
                nativeSeverityLabel: (dto.validity ?? "unknown").capitalized,
                summary: dto.secretTypeDisplayName,
                detailURL: dto.htmlUrl,
                createdAt: dto.createdAt,
                updatedAt: dto.updatedAt,
                seenLocally: false
            )
        }
    }

    /// 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)"
        components.queryItems = [
            URLQueryItem(name: "state", value: "open"),
            URLQueryItem(name: "per_page", value: "100"),
        ]
        return components.url!
    }

    /// Follows the `Link: rel="next"` header until GitHub stops returning one,
    /// since these endpoints paginate (default 30, up to 100 per page) rather
    /// than returning every open alert in one response.
    private func fetchAllPages<T: Decodable>(url: URL) async throws -> [T] {
        var results: [T] = []
        var nextURL: URL? = url
        while let currentURL = nextURL {
            let (data, response) = try await fetchData(url: currentURL)
            results += try decode(data)
            nextURL = nextPageURL(from: response)
        }
        return results
    }

    private func nextPageURL(from response: HTTPURLResponse) -> URL? {
        guard let linkHeader = response.value(forHTTPHeaderField: "Link") else { return nil }
        for part in linkHeader.components(separatedBy: ",") {
            let segments = part.components(separatedBy: ";").map { $0.trimmingCharacters(in: .whitespaces) }
            guard segments.count >= 2, segments[1] == "rel=\"next\"" else { continue }
            let urlString = segments[0].trimmingCharacters(in: CharacterSet(charactersIn: "<>"))
            return URL(string: urlString)
        }
        return nil
    }

    private func fetchData(url: URL) async throws -> (data: Data, response: HTTPURLResponse) {
        var request = URLRequest(url: url)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        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 GitHubAPIError.network(error.localizedDescription)
        }

        guard let httpResponse = response as? HTTPURLResponse else {
            throw GitHubAPIError.invalidResponse
        }

        switch httpResponse.statusCode {
        case 200:
            return (data, httpResponse)
        case 401:
            throw GitHubAPIError.unauthorized
        case 403:
            throw GitHubAPIError.forbidden
        case 404:
            throw GitHubAPIError.notFound
        case 429:
            throw GitHubAPIError.rateLimited
        default:
            throw GitHubAPIError.httpError(status: httpResponse.statusCode)
        }
    }

    private func decode<T: Decodable>(_ data: Data) throws -> T {
        do {
            return try Self.decoder.decode(T.self, from: data)
        } catch {
            throw GitHubAPIError.decodingFailed(error.localizedDescription)
        }
    }
}