summaryrefslogtreecommitdiff
path: root/octosentry/GitHubSecurityAPIClient.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 16:06:47 -0500
committerChristian Cleberg <[email protected]>2026-07-17 16:06:47 -0500
commitd8862a9b78b0d6cf4e11cf537be7678794e08811 (patch)
tree4af615370bb122e6706bbefc6deaa044e02b477b /octosentry/GitHubSecurityAPIClient.swift
downloadoctosentry-d8862a9b78b0d6cf4e11cf537be7678794e08811.tar.gz
octosentry-d8862a9b78b0d6cf4e11cf537be7678794e08811.tar.bz2
octosentry-d8862a9b78b0d6cf4e11cf537be7678794e08811.zip
Scaffold octosentry MVP: unified GitHub security alert feed0.1.0
MenuBarExtra popover aggregating Dependabot, code scanning, and secret scanning alerts for a single repo into one severity-ranked stream. Swift 6 strict concurrency, zero third-party dependencies, PAT-via-env-var auth as a dev-only shortcut ahead of the device authorization flow.
Diffstat (limited to 'octosentry/GitHubSecurityAPIClient.swift')
-rw-r--r--octosentry/GitHubSecurityAPIClient.swift168
1 files changed, 168 insertions, 0 deletions
diff --git a/octosentry/GitHubSecurityAPIClient.swift b/octosentry/GitHubSecurityAPIClient.swift
new file mode 100644
index 0000000..629699a
--- /dev/null
+++ b/octosentry/GitHubSecurityAPIClient.swift
@@ -0,0 +1,168 @@
+//
+// GitHubSecurityAPIClient.swift
+// octosentry
+//
+// Fetches Dependabot, code scanning, and secret scanning alerts for a
+// single repo and normalizes them into SecurityEvent. Auth is a PAT read
+// by the caller from the GITHUB_TOKEN environment variable — a dev-only
+// shortcut ahead of the device authorization flow (spec §6, §13).
+//
+
+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
+ )
+ }
+ }
+
+ 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)
+ }
+ }
+}