diff options
| author | Christian Cleberg <[email protected]> | 2026-07-17 16:06:47 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-17 16:06:47 -0500 |
| commit | d8862a9b78b0d6cf4e11cf537be7678794e08811 (patch) | |
| tree | 4af615370bb122e6706bbefc6deaa044e02b477b /octosentry | |
| download | octosentry-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')
| -rw-r--r-- | octosentry/Assets.xcassets/AccentColor.colorset/Contents.json | 11 | ||||
| -rw-r--r-- | octosentry/Assets.xcassets/AppIcon.appiconset/Contents.json | 58 | ||||
| -rw-r--r-- | octosentry/Assets.xcassets/Contents.json | 6 | ||||
| -rw-r--r-- | octosentry/GitHubAPIError.swift | 41 | ||||
| -rw-r--r-- | octosentry/GitHubAPIModels.swift | 88 | ||||
| -rw-r--r-- | octosentry/GitHubSecurityAPIClient.swift | 168 | ||||
| -rw-r--r-- | octosentry/SecurityEvent.swift | 19 | ||||
| -rw-r--r-- | octosentry/SecurityEventListView.swift | 143 | ||||
| -rw-r--r-- | octosentry/SecurityEventRow.swift | 73 | ||||
| -rw-r--r-- | octosentry/SecurityEventSeverity.swift | 35 | ||||
| -rw-r--r-- | octosentry/SecurityEventSource.swift | 20 | ||||
| -rw-r--r-- | octosentry/SecurityEventStore.swift | 93 | ||||
| -rw-r--r-- | octosentry/SeverityMapping.swift | 52 | ||||
| -rw-r--r-- | octosentry/octosentryApp.swift | 20 |
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) + } +} |
