krz/octosentry
macOS menu bar app to monitor GitHub security alerts
clone: git clone https://gitbay.org/krz/octosentry.git
77dd0fa59a5900ef9031f7c3483fffa3003f0595
signed_unknown_key
author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T20:45:39Z
committer: <noreply@github.com>
octosentry.xcodeproj/project.pbxproj | 4 +- octosentry/AlertExport.swift | 106 +++++++++++++++++++++++ octosentry/SecurityEventListView.swift | 47 +++++++++++ octosentryTests/AlertExportTests.swift | 150 +++++++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+), 2 deletions(-) @@ -402,7 +402,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = OctoSentry; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -438,7 +438,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = OctoSentry; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; new file mode 100644 @@ -0,0 +1,106 @@ +// +// AlertExport.swift +// octosentry +// +// Serializes the feed for export. Takes whatever list it's given, which is +// the already-filtered, already-sorted `events` from SecurityEventStore — +// so an export matches what's on screen. +// +// Both formats carry the same fields as SecurityEventRow, plus octosentry's +// normalized severity: the native GitHub labels aren't comparable across +// the three sources, so a spreadsheet can't rank on them alone. +// + +import Foundation + +nonisolated enum AlertExportFormat: String, CaseIterable, Hashable { + case csv + case json + + var fileExtension: String { rawValue } + + var displayName: String { + switch self { + case .csv: "CSV" + case .json: "JSON" + } + } +} + +nonisolated enum AlertExport { + static let columns = [ + "Source", "Repository", "Severity", "GitHub Severity", "Summary", "First Seen", "URL", + ] + + static func data(_ events: [SecurityEvent], format: AlertExportFormat) throws -> Data { + switch format { + case .csv: Data(csv(events).utf8) + case .json: try json(events) + } + } + + static func filename(format: AlertExportFormat, date: Date = .now) -> String { + let day = date.formatted(.iso8601.year().month().day().dateSeparator(.dash)) + return "octosentry-alerts-\(day).\(format.fileExtension)" + } + + // MARK: - CSV + + /// RFC 4180: CRLF line endings, fields quoted when they contain a + /// separator, a quote, or a line break, and embedded quotes doubled. + static func csv(_ events: [SecurityEvent]) -> String { + let rows = [columns] + events.map { event in + [ + event.source.displayName, + event.repoFullName, + event.severity.displayName, + event.nativeSeverityLabel, + event.summary, + event.createdAt.formatted(.iso8601), + event.detailURL.absoluteString, + ] + } + return rows.map { $0.map(escapeCSVField).joined(separator: ",") }.joined(separator: "\r\n") + } + + private static func escapeCSVField(_ field: String) -> String { + let needsQuoting = field.contains(",") + || field.contains("\"") + || field.contains("\n") + || field.contains("\r") + guard needsQuoting else { return field } + return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + // MARK: - JSON + + static func json(_ events: [SecurityEvent]) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(events.map(ExportedAlert.init)) + } + + /// A deliberate projection rather than encoding SecurityEvent directly: + /// the wire type carries local triage state and a synthetic id that mean + /// nothing outside the app. + private struct ExportedAlert: Encodable { + let source: String + let repository: String + let severity: String + let githubSeverity: String + let summary: String + let firstSeen: Date + let url: URL + + init(_ event: SecurityEvent) { + source = event.source.displayName + repository = event.repoFullName + severity = event.severity.displayName + githubSeverity = event.nativeSeverityLabel + summary = event.summary + firstSeen = event.createdAt + url = event.detailURL + } + } +} @@ -5,6 +5,7 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers struct SecurityEventListView: View { var store: SecurityEventStore @@ -12,6 +13,7 @@ struct SecurityEventListView: View { var updateStore: UpdateStore var isStandaloneWindow: Bool = false @State private var showingRepoManager = false + @State private var exportErrorMessage: String? @Environment(\.openWindow) private var openWindow var body: some View { @@ -39,6 +41,38 @@ struct SecurityEventListView: View { .task { await updateStore.checkForUpdate() } + .alert( + "Export failed", + isPresented: Binding( + get: { exportErrorMessage != nil }, + set: { if !$0 { exportErrorMessage = nil } } + ), + presenting: exportErrorMessage + ) { _ in + Button("OK", role: .cancel) { exportErrorMessage = nil } + } message: { message in + Text(message) + } + } + + /// Writes exactly what the feed is showing — store.events is already + /// filtered and sorted. + private func export(_ format: AlertExportFormat) { + let panel = NSSavePanel() + panel.nameFieldStringValue = AlertExport.filename(format: format) + panel.canCreateDirectories = true + panel.allowedContentTypes = [format == .csv ? .commaSeparatedText : .json] + + // The app is an accessory (LSUIElement), so it has to come forward or + // the panel opens behind whatever is frontmost. + NSApp.activate(ignoringOtherApps: true) + guard panel.runModal() == .OK, let url = panel.url else { return } + + do { + try AlertExport.data(store.events, format: format).write(to: url, options: .atomic) + } catch { + exportErrorMessage = error.localizedDescription + } } private var header: some View { @@ -73,6 +107,19 @@ struct SecurityEventListView: View { } .buttonStyle(.plain) .disabled(store.isLoading) + + Menu { + ForEach(AlertExportFormat.allCases, id: \.self) { format in + Button("Export as \(format.displayName)…") { export(format) } + } + } label: { + Image(systemName: "square.and.arrow.up") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .disabled(store.events.isEmpty) + .help("Export the alerts currently shown") } if authStore.isSignedIn { new file mode 100644 @@ -0,0 +1,150 @@ +// +// AlertExportTests.swift +// octosentryTests +// + +import Foundation +import Testing +@testable import octosentry + +struct AlertExportTests { + + private let events = [ + TestEvents.event( + id: "a", + source: .dependabot, + repo: "octocat/hello-world", + severity: .critical, + summary: "Denial of service in some-package" + ), + TestEvents.event( + id: "b", + source: .secretScanning, + repo: "octocat/spoon-knife", + severity: .high, + summary: "GitHub Personal Access Token" + ), + ] + + // MARK: - CSV + + @Test func csvStartsWithTheHeaderRow() { + let lines = AlertExport.csv(events).components(separatedBy: "\r\n") + + #expect(lines.first == "Source,Repository,Severity,GitHub Severity,Summary,First Seen,URL") + } + + @Test func csvWritesOneRowPerEventInOrder() throws { + let lines = AlertExport.csv(events).components(separatedBy: "\r\n") + + try #require(lines.count == 3) + #expect(lines[1].hasPrefix("Dependabot,octocat/hello-world,Critical,Critical,Denial of service in some-package,")) + #expect(lines[2].hasPrefix("Secret Scanning,octocat/spoon-knife,High,High,GitHub Personal Access Token,")) + #expect(lines[1].hasSuffix("https://github.com/octocat/hello-world/security/a")) + } + + // The case worth being explicit about: a summary carrying every + // character CSV cares about. + @Test func csvQuotesAndEscapesAwkwardSummaries() throws { + let awkward = [ + TestEvents.event( + id: "x", + summary: #"Contains a comma, a "quote", and a"# + "\nnewline" + ) + ] + + // Records are CRLF-separated, so the field's embedded LF stays inside + // the record rather than starting a new one. + let records = AlertExport.csv(awkward).components(separatedBy: "\r\n") + try #require(records.count == 2) + + let record = records[1] + #expect(record.contains(#""Contains a comma, a ""quote"", and a"#)) + #expect(record.contains("\nnewline\"")) + // The quoted field opens right after the GitHub severity column. + #expect(record.contains(#",High,"Contains"#)) + } + + @Test func csvLeavesOrdinaryFieldsUnquoted() { + let plain = [TestEvents.event(id: "x", summary: "Nothing special here")] + + #expect(AlertExport.csv(plain).contains(",Nothing special here,")) + } + + @Test func csvOfAnEmptyFeedIsJustTheHeader() { + #expect(AlertExport.csv([]) == "Source,Repository,Severity,GitHub Severity,Summary,First Seen,URL") + } + + @Test func csvUsesCRLFLineEndings() { + #expect(AlertExport.csv(events).contains("\r\n")) + } + + // MARK: - JSON + + @Test func jsonEncodesTheDocumentedFields() throws { + let data = try AlertExport.json(events) + let objects = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + + try #require(objects.count == 2) + + let first = try #require(objects.first) + #expect(Set(first.keys) == [ + "source", "repository", "severity", "githubSeverity", "summary", "firstSeen", "url", + ]) + #expect(first["source"] as? String == "Dependabot") + #expect(first["repository"] as? String == "octocat/hello-world") + #expect(first["severity"] as? String == "Critical") + #expect(first["summary"] as? String == "Denial of service in some-package") + #expect(first["url"] as? String == "https://github.com/octocat/hello-world/security/a") + } + + // Local triage state and the synthetic id are app internals. + @Test func jsonOmitsInternalFields() throws { + let json = try #require(String(data: try AlertExport.json(events), encoding: .utf8)) + + #expect(!json.contains("seenLocally")) + #expect(!json.contains("\"id\"")) + #expect(!json.contains("detailURL")) + } + + @Test func jsonEncodesDatesAsISO8601() throws { + let data = try AlertExport.json([TestEvents.event(id: "x")]) + let objects = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + let firstSeen = try #require(objects.first?["firstSeen"] as? String) + + #expect(ISO8601DateFormatter().date(from: firstSeen) == TestEvents.referenceDate) + } + + @Test func jsonOfAnEmptyFeedIsAnEmptyArray() throws { + let objects = try JSONSerialization.jsonObject(with: try AlertExport.json([])) as? [Any] + + #expect(objects?.isEmpty == true) + } + + // MARK: - Format plumbing + + @Test func dataMatchesTheFormatSpecificEncoders() throws { + #expect(try AlertExport.data(events, format: .csv) == Data(AlertExport.csv(events).utf8)) + #expect(try AlertExport.data(events, format: .json) == (try AlertExport.json(events))) + } + + @Test func filenameCarriesTheDateAndExtension() { + let date = Date(timeIntervalSince1970: 1_785_000_000) + + #expect(AlertExport.filename(format: .csv, date: date).hasSuffix(".csv")) + #expect(AlertExport.filename(format: .json, date: date).hasSuffix(".json")) + #expect(AlertExport.filename(format: .csv, date: date).hasPrefix("octosentry-alerts-")) + } + + // Export follows the feed, so filter and sort decide its contents. + @Test func exportReflectsFilterAndSortApplied() throws { + var filter = AlertFilter() + filter.sources = [.secretScanning] + let shown = AlertSortOrder.severity.sorted(filter.apply(to: events)) + + let lines = AlertExport.csv(shown).components(separatedBy: "\r\n") + + try #require(lines.count == 2) + #expect(lines[1].hasPrefix("Secret Scanning,")) + } +}