summaryrefslogtreecommitdiff
path: root/octosentry
diff options
context:
space:
mode:
Diffstat (limited to 'octosentry')
-rw-r--r--octosentry/Assets.xcassets/AccentColor.colorset/Contents.json11
-rw-r--r--octosentry/Assets.xcassets/AppIcon.appiconset/Contents.json58
-rw-r--r--octosentry/Assets.xcassets/Contents.json6
-rw-r--r--octosentry/GitHubAPIError.swift41
-rw-r--r--octosentry/GitHubAPIModels.swift88
-rw-r--r--octosentry/GitHubSecurityAPIClient.swift168
-rw-r--r--octosentry/SecurityEvent.swift19
-rw-r--r--octosentry/SecurityEventListView.swift143
-rw-r--r--octosentry/SecurityEventRow.swift73
-rw-r--r--octosentry/SecurityEventSeverity.swift35
-rw-r--r--octosentry/SecurityEventSource.swift20
-rw-r--r--octosentry/SecurityEventStore.swift93
-rw-r--r--octosentry/SeverityMapping.swift52
-rw-r--r--octosentry/octosentryApp.swift20
14 files changed, 827 insertions, 0 deletions
diff --git a/octosentry/Assets.xcassets/AccentColor.colorset/Contents.json b/octosentry/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/octosentry/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/octosentry/Assets.xcassets/AppIcon.appiconset/Contents.json b/octosentry/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..3f00db4
--- /dev/null
+++ b/octosentry/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,58 @@
+{
+ "images" : [
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/octosentry/Assets.xcassets/Contents.json b/octosentry/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/octosentry/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/octosentry/GitHubAPIError.swift b/octosentry/GitHubAPIError.swift
new file mode 100644
index 0000000..7baf918
--- /dev/null
+++ b/octosentry/GitHubAPIError.swift
@@ -0,0 +1,41 @@
+//
+// GitHubAPIError.swift
+// octosentry
+//
+
+import Foundation
+
+enum GitHubAPIError: Error, LocalizedError, Sendable {
+ case missingToken
+ case network(String)
+ case invalidResponse
+ case unauthorized
+ case forbidden
+ case notFound
+ case rateLimited
+ case httpError(status: Int)
+ case decodingFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .missingToken:
+ "No GitHub token found in the GITHUB_TOKEN environment variable."
+ case .network(let message):
+ "Network error: \(message)"
+ case .invalidResponse:
+ "Received an unexpected response from GitHub."
+ case .unauthorized:
+ "GitHub rejected the token (401 Unauthorized)."
+ case .forbidden:
+ "Token lacks permission for this alert type (403 Forbidden)."
+ case .notFound:
+ "Repository or endpoint not found (404)."
+ case .rateLimited:
+ "GitHub API rate limit exceeded (429)."
+ case .httpError(let status):
+ "GitHub API returned HTTP \(status)."
+ case .decodingFailed(let message):
+ "Failed to parse GitHub API response: \(message)"
+ }
+ }
+}
diff --git a/octosentry/GitHubAPIModels.swift b/octosentry/GitHubAPIModels.swift
new file mode 100644
index 0000000..3a84aa8
--- /dev/null
+++ b/octosentry/GitHubAPIModels.swift
@@ -0,0 +1,88 @@
+//
+// GitHubAPIModels.swift
+// octosentry
+//
+// Decodable wire types for the three GitHub REST alert endpoints. Kept
+// private to this file — GitHubSecurityAPIClient maps them into SecurityEvent.
+//
+
+import Foundation
+
+nonisolated struct DependabotAlertDTO: Decodable {
+ let number: Int
+ let htmlUrl: URL
+ let createdAt: Date
+ let updatedAt: Date
+ let securityAdvisory: SecurityAdvisory
+
+ struct SecurityAdvisory: Decodable {
+ let summary: String
+ let severity: String
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case number
+ case htmlUrl = "html_url"
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ case securityAdvisory = "security_advisory"
+ }
+}
+
+nonisolated struct CodeScanningAlertDTO: Decodable {
+ let number: Int
+ let htmlUrl: URL
+ let createdAt: Date
+ let updatedAt: Date
+ let rule: Rule
+ let mostRecentInstance: MostRecentInstance?
+
+ struct Rule: Decodable {
+ let id: String?
+ let description: String?
+ let severity: String?
+ let securitySeverityLevel: String?
+
+ enum CodingKeys: String, CodingKey {
+ case id
+ case description
+ case severity
+ case securitySeverityLevel = "security_severity_level"
+ }
+ }
+
+ struct MostRecentInstance: Decodable {
+ let message: Message?
+
+ struct Message: Decodable {
+ let text: String?
+ }
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case number
+ case htmlUrl = "html_url"
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ case rule
+ case mostRecentInstance = "most_recent_instance"
+ }
+}
+
+nonisolated struct SecretScanningAlertDTO: Decodable {
+ let number: Int
+ let htmlUrl: URL
+ let createdAt: Date
+ let updatedAt: Date
+ let secretTypeDisplayName: String
+ let validity: String?
+
+ enum CodingKeys: String, CodingKey {
+ case number
+ case htmlUrl = "html_url"
+ case createdAt = "created_at"
+ case updatedAt = "updated_at"
+ case secretTypeDisplayName = "secret_type_display_name"
+ case validity
+ }
+}
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)
+ }
+ }
+}
diff --git a/octosentry/SecurityEvent.swift b/octosentry/SecurityEvent.swift
new file mode 100644
index 0000000..ca5de0d
--- /dev/null
+++ b/octosentry/SecurityEvent.swift
@@ -0,0 +1,19 @@
+//
+// SecurityEvent.swift
+// octosentry
+//
+
+import Foundation
+
+struct SecurityEvent: Identifiable, Codable, Sendable {
+ let id: String
+ let source: SecurityEventSource
+ let repoFullName: String
+ let severity: SecurityEventSeverity
+ let nativeSeverityLabel: String
+ let summary: String
+ let detailURL: URL
+ let createdAt: Date
+ let updatedAt: Date
+ var seenLocally: Bool
+}
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
new file mode 100644
index 0000000..4542275
--- /dev/null
+++ b/octosentry/SecurityEventListView.swift
@@ -0,0 +1,143 @@
+//
+// SecurityEventListView.swift
+// octosentry
+//
+
+import AppKit
+import SwiftUI
+
+struct SecurityEventListView: View {
+ var store: SecurityEventStore
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ header
+ Divider()
+ content
+ }
+ .frame(width: 380, height: 420)
+ .task {
+ await store.refresh()
+ }
+ }
+
+ private var header: some View {
+ HStack {
+ Text("Security Events")
+ .font(.headline)
+
+ if store.isLoading {
+ ProgressView()
+ .controlSize(.small)
+ }
+
+ Spacer()
+
+ Button {
+ Task { await store.refresh() }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(.plain)
+ .disabled(store.isLoading)
+
+ Button("Quit") {
+ NSApplication.shared.terminate(nil)
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ }
+ .padding(12)
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ if store.events.isEmpty && !store.errorMessages.isEmpty {
+ StatusView(systemImage: "exclamationmark.triangle", tint: .orange, message: store.errorMessages.joined(separator: "\n\n"))
+ } else if store.events.isEmpty && !store.isLoading {
+ VStack(spacing: 8) {
+ StatusView(systemImage: "checkmark.shield", tint: .green, message: "No open security alerts")
+ if !store.unavailableNotices.isEmpty {
+ NoticeBanner(messages: store.unavailableNotices)
+ .padding(.horizontal)
+ .padding(.bottom)
+ }
+ }
+ } else {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ if !store.errorMessages.isEmpty {
+ ErrorBanner(messages: store.errorMessages)
+ Divider()
+ }
+ if !store.unavailableNotices.isEmpty {
+ NoticeBanner(messages: store.unavailableNotices)
+ Divider()
+ }
+ ForEach(store.events) { event in
+ SecurityEventRow(event: event)
+ Divider()
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct ErrorBanner: View {
+ let messages: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(messages, id: \.self) { message in
+ Label(message, systemImage: "exclamationmark.triangle")
+ .font(.caption)
+ .foregroundStyle(.orange)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .background(.orange.opacity(0.1))
+ }
+}
+
+private struct NoticeBanner: View {
+ let messages: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(messages, id: \.self) { message in
+ Label(message, systemImage: "info.circle")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .background(.secondary.opacity(0.08))
+ }
+}
+
+private struct StatusView: View {
+ let systemImage: String
+ let tint: Color
+ let message: String
+
+ var body: some View {
+ VStack(spacing: 8) {
+ Image(systemName: systemImage)
+ .font(.title2)
+ .foregroundStyle(tint)
+ Text(message)
+ .font(.callout)
+ .multilineTextAlignment(.center)
+ .foregroundStyle(.secondary)
+ }
+ .padding()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+#Preview {
+ SecurityEventListView(store: SecurityEventStore())
+}
diff --git a/octosentry/SecurityEventRow.swift b/octosentry/SecurityEventRow.swift
new file mode 100644
index 0000000..9a73037
--- /dev/null
+++ b/octosentry/SecurityEventRow.swift
@@ -0,0 +1,73 @@
+//
+// SecurityEventRow.swift
+// octosentry
+//
+
+import AppKit
+import SwiftUI
+
+struct SecurityEventRow: View {
+ let event: SecurityEvent
+
+ private static let relativeFormatter: RelativeDateTimeFormatter = {
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .abbreviated
+ return formatter
+ }()
+
+ var body: some View {
+ Button {
+ NSWorkspace.shared.open(event.detailURL)
+ } label: {
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text(event.nativeSeverityLabel.uppercased())
+ .font(.caption2.weight(.bold))
+ .foregroundStyle(event.severity.color)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(event.severity.color.opacity(0.18), in: Capsule())
+
+ Text(event.source.displayName)
+ .font(.caption2.weight(.semibold))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(.secondary.opacity(0.15), in: Capsule())
+
+ Text(event.repoFullName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Spacer()
+
+ Text(Self.relativeFormatter.localizedString(for: event.createdAt, relativeTo: .now))
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+
+ Text(event.summary)
+ .font(.callout)
+ .lineLimit(1)
+ .foregroundStyle(.primary)
+ }
+ .padding(10)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+#Preview {
+ SecurityEventRow(event: SecurityEvent(
+ id: "preview-1",
+ source: .dependabot,
+ repoFullName: "zerolabsco/octosentry",
+ severity: .critical,
+ nativeSeverityLabel: "Critical",
+ summary: "Denial of service in some-package",
+ detailURL: URL(string: "https://github.com")!,
+ createdAt: .now.addingTimeInterval(-3600 * 26),
+ updatedAt: .now,
+ seenLocally: false
+ ))
+}
diff --git a/octosentry/SecurityEventSeverity.swift b/octosentry/SecurityEventSeverity.swift
new file mode 100644
index 0000000..f975639
--- /dev/null
+++ b/octosentry/SecurityEventSeverity.swift
@@ -0,0 +1,35 @@
+//
+// SecurityEventSeverity.swift
+// octosentry
+//
+
+import SwiftUI
+
+enum SecurityEventSeverity: Int, Codable, Comparable, CaseIterable {
+ case low
+ case medium
+ case high
+ case critical
+
+ static func < (lhs: SecurityEventSeverity, rhs: SecurityEventSeverity) -> Bool {
+ lhs.rawValue < rhs.rawValue
+ }
+
+ var displayName: String {
+ switch self {
+ case .low: "Low"
+ case .medium: "Medium"
+ case .high: "High"
+ case .critical: "Critical"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .low: .blue
+ case .medium: .yellow
+ case .high: .orange
+ case .critical: .red
+ }
+ }
+}
diff --git a/octosentry/SecurityEventSource.swift b/octosentry/SecurityEventSource.swift
new file mode 100644
index 0000000..9c68296
--- /dev/null
+++ b/octosentry/SecurityEventSource.swift
@@ -0,0 +1,20 @@
+//
+// SecurityEventSource.swift
+// octosentry
+//
+
+import Foundation
+
+enum SecurityEventSource: String, Codable, CaseIterable {
+ case dependabot
+ case codeScanning
+ case secretScanning
+
+ var displayName: String {
+ switch self {
+ case .dependabot: "Dependabot"
+ case .codeScanning: "CodeQL"
+ case .secretScanning: "Secret Scanning"
+ }
+ }
+}
diff --git a/octosentry/SecurityEventStore.swift b/octosentry/SecurityEventStore.swift
new file mode 100644
index 0000000..a45d5c7
--- /dev/null
+++ b/octosentry/SecurityEventStore.swift
@@ -0,0 +1,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)
+ }
+ }
+}
diff --git a/octosentry/SeverityMapping.swift b/octosentry/SeverityMapping.swift
new file mode 100644
index 0000000..5befb46
--- /dev/null
+++ b/octosentry/SeverityMapping.swift
@@ -0,0 +1,52 @@
+//
+// SeverityMapping.swift
+// octosentry
+//
+// Normalizes each GitHub alert source's native severity vocabulary into
+// the shared SecurityEventSeverity scale. Kept as a single auditable
+// source file per the spec (§4) rather than scattered across the client.
+//
+
+import Foundation
+
+nonisolated enum SeverityMapping {
+ /// Dependabot alerts report CVSS-derived severity on the vulnerability object.
+ static func dependabot(_ nativeSeverity: String) -> SecurityEventSeverity {
+ switch nativeSeverity.lowercased() {
+ case "critical": .critical
+ case "high": .high
+ case "moderate", "medium": .medium
+ case "low": .low
+ default: .medium
+ }
+ }
+
+ /// Code scanning alerts expose `rule.security_severity_level` (CVSS-derived) when
+ /// present, falling back to `rule.severity` (note/warning/error) otherwise.
+ static func codeScanning(securitySeverityLevel: String?, ruleSeverity: String?) -> SecurityEventSeverity {
+ if let securitySeverityLevel {
+ switch securitySeverityLevel.lowercased() {
+ case "critical": return .critical
+ case "high": return .high
+ case "medium": return .medium
+ case "low": return .low
+ default: break
+ }
+ }
+ switch ruleSeverity?.lowercased() {
+ case "error": return .high
+ case "warning": return .medium
+ case "note": return .low
+ default: return .medium
+ }
+ }
+
+ /// Secret scanning has no native severity field. Per spec: validated/active
+ /// secrets are treated as critical, unvalidated ones as high.
+ static func secretScanning(validity: String?) -> SecurityEventSeverity {
+ switch validity?.lowercased() {
+ case "active": .critical
+ default: .high
+ }
+ }
+}
diff --git a/octosentry/octosentryApp.swift b/octosentry/octosentryApp.swift
new file mode 100644
index 0000000..4d5a859
--- /dev/null
+++ b/octosentry/octosentryApp.swift
@@ -0,0 +1,20 @@
+//
+// octosentryApp.swift
+// octosentry
+//
+// Created by cmc on 2026-07-17.
+//
+
+import SwiftUI
+
+@main
+struct octosentryApp: App {
+ @State private var store = SecurityEventStore()
+
+ var body: some Scene {
+ MenuBarExtra("OctoSentry", systemImage: "shield.lefthalf.filled") {
+ SecurityEventListView(store: store)
+ }
+ .menuBarExtraStyle(.window)
+ }
+}