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>

Add CSV and JSON export of the current feed (#28)

Export menu in the header writes what the feed is showing — store.events
is already filtered and sorted, so an export matches the screen.

CSV follows RFC 4180: CRLF records, fields quoted when they contain a
comma, quote, or line break, embedded quotes doubled. JSON is a
projection rather than SecurityEvent itself, which carries local triage
state and a synthetic id that mean nothing outside the app. Both carry
octosentry's normalized severity alongside the GitHub label, since the
native labels aren't comparable across the three sources.

Sandbox file access goes from readonly to readwrite so the save panel
can write.

Closes #22
 octosentry.xcodeproj/project.pbxproj   |   4 +-
 octosentry/AlertExport.swift           | 106 +++++++++++++++++++++++
 octosentry/SecurityEventListView.swift |  47 +++++++++++
 octosentryTests/AlertExportTests.swift | 150 +++++++++++++++++++++++++++++++++
 4 files changed, 305 insertions(+), 2 deletions(-)

diff --git a/octosentry.xcodeproj/project.pbxproj b/octosentry.xcodeproj/project.pbxproj
index c19ebc7..1f2e862 100644
--- a/octosentry.xcodeproj/project.pbxproj
+++ b/octosentry.xcodeproj/project.pbxproj
@@ -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";
diff --git a/octosentry/AlertExport.swift b/octosentry/AlertExport.swift
new file mode 100644
index 0000000..5cf5419
--- /dev/null
+++ b/octosentry/AlertExport.swift
@@ -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
+        }
+    }
+}
diff --git a/octosentry/SecurityEventListView.swift b/octosentry/SecurityEventListView.swift
index 37f433c..0d53a76 100644
--- a/octosentry/SecurityEventListView.swift
+++ b/octosentry/SecurityEventListView.swift
@@ -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 {
diff --git a/octosentryTests/AlertExportTests.swift b/octosentryTests/AlertExportTests.swift
new file mode 100644
index 0000000..33ecf25
--- /dev/null
+++ b/octosentryTests/AlertExportTests.swift
@@ -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,"))
+    }
+}