krz/octosentry

macOS menu bar app to monitor GitHub security alerts github macos menubar security

Commit fdd328cc74

fdd328cc747cd2dd8d79b797cb2f36b0184dee92

parent: 08428003cb

Unsigned

cmc <hello@cleberg.net> · 2026-08-22 20:45 UTC
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

Layout: unified · split

octosentry.xcodeproj/project.pbxproj +2 −2
@@ -402,7 +402,7 @@
402402 ENABLE_HARDENED_RUNTIME = YES;
403403 ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
404404 ENABLE_PREVIEWS = YES;
405 ENABLE_USER_SELECTED_FILES = readonly;
405 ENABLE_USER_SELECTED_FILES = readwrite;
406406 GENERATE_INFOPLIST_FILE = YES;
407407 INFOPLIST_KEY_CFBundleDisplayName = OctoSentry;
408408 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
@@ -438,7 +438,7 @@
438438 ENABLE_HARDENED_RUNTIME = YES;
439439 ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
440440 ENABLE_PREVIEWS = YES;
441 ENABLE_USER_SELECTED_FILES = readonly;
441 ENABLE_USER_SELECTED_FILES = readwrite;
442442 GENERATE_INFOPLIST_FILE = YES;
443443 INFOPLIST_KEY_CFBundleDisplayName = OctoSentry;
444444 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
octosentry/AlertExport.swift added +106
@@ -0,0 +1,106 @@
1//
2// AlertExport.swift
3// octosentry
4//
5// Serializes the feed for export. Takes whatever list it's given, which is
6// the already-filtered, already-sorted `events` from SecurityEventStore —
7// so an export matches what's on screen.
8//
9// Both formats carry the same fields as SecurityEventRow, plus octosentry's
10// normalized severity: the native GitHub labels aren't comparable across
11// the three sources, so a spreadsheet can't rank on them alone.
12//
13
14import Foundation
15
16nonisolated enum AlertExportFormat: String, CaseIterable, Hashable {
17 case csv
18 case json
19
20 var fileExtension: String { rawValue }
21
22 var displayName: String {
23 switch self {
24 case .csv: "CSV"
25 case .json: "JSON"
26 }
27 }
28}
29
30nonisolated enum AlertExport {
31 static let columns = [
32 "Source", "Repository", "Severity", "GitHub Severity", "Summary", "First Seen", "URL",
33 ]
34
35 static func data(_ events: [SecurityEvent], format: AlertExportFormat) throws -> Data {
36 switch format {
37 case .csv: Data(csv(events).utf8)
38 case .json: try json(events)
39 }
40 }
41
42 static func filename(format: AlertExportFormat, date: Date = .now) -> String {
43 let day = date.formatted(.iso8601.year().month().day().dateSeparator(.dash))
44 return "octosentry-alerts-\(day).\(format.fileExtension)"
45 }
46
47 // MARK: - CSV
48
49 /// RFC 4180: CRLF line endings, fields quoted when they contain a
50 /// separator, a quote, or a line break, and embedded quotes doubled.
51 static func csv(_ events: [SecurityEvent]) -> String {
52 let rows = [columns] + events.map { event in
53 [
54 event.source.displayName,
55 event.repoFullName,
56 event.severity.displayName,
57 event.nativeSeverityLabel,
58 event.summary,
59 event.createdAt.formatted(.iso8601),
60 event.detailURL.absoluteString,
61 ]
62 }
63 return rows.map { $0.map(escapeCSVField).joined(separator: ",") }.joined(separator: "\r\n")
64 }
65
66 private static func escapeCSVField(_ field: String) -> String {
67 let needsQuoting = field.contains(",")
68 || field.contains("\"")
69 || field.contains("\n")
70 || field.contains("\r")
71 guard needsQuoting else { return field }
72 return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\""
73 }
74
75 // MARK: - JSON
76
77 static func json(_ events: [SecurityEvent]) throws -> Data {
78 let encoder = JSONEncoder()
79 encoder.dateEncodingStrategy = .iso8601
80 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
81 return try encoder.encode(events.map(ExportedAlert.init))
82 }
83
84 /// A deliberate projection rather than encoding SecurityEvent directly:
85 /// the wire type carries local triage state and a synthetic id that mean
86 /// nothing outside the app.
87 private struct ExportedAlert: Encodable {
88 let source: String
89 let repository: String
90 let severity: String
91 let githubSeverity: String
92 let summary: String
93 let firstSeen: Date
94 let url: URL
95
96 init(_ event: SecurityEvent) {
97 source = event.source.displayName
98 repository = event.repoFullName
99 severity = event.severity.displayName
100 githubSeverity = event.nativeSeverityLabel
101 summary = event.summary
102 firstSeen = event.createdAt
103 url = event.detailURL
104 }
105 }
106}
octosentry/SecurityEventListView.swift +47
@@ -5,6 +5,7 @@
55
66import AppKit
77import SwiftUI
8import UniformTypeIdentifiers
89
910struct SecurityEventListView: View {
1011 var store: SecurityEventStore
@@ -12,6 +13,7 @@ struct SecurityEventListView: View {
1213 var updateStore: UpdateStore
1314 var isStandaloneWindow: Bool = false
1415 @State private var showingRepoManager = false
16 @State private var exportErrorMessage: String?
1517 @Environment(\.openWindow) private var openWindow
1618
1719 var body: some View {
@@ -39,6 +41,38 @@ struct SecurityEventListView: View {
3941 .task {
4042 await updateStore.checkForUpdate()
4143 }
44 .alert(
45 "Export failed",
46 isPresented: Binding(
47 get: { exportErrorMessage != nil },
48 set: { if !$0 { exportErrorMessage = nil } }
49 ),
50 presenting: exportErrorMessage
51 ) { _ in
52 Button("OK", role: .cancel) { exportErrorMessage = nil }
53 } message: { message in
54 Text(message)
55 }
56 }
57
58 /// Writes exactly what the feed is showing — store.events is already
59 /// filtered and sorted.
60 private func export(_ format: AlertExportFormat) {
61 let panel = NSSavePanel()
62 panel.nameFieldStringValue = AlertExport.filename(format: format)
63 panel.canCreateDirectories = true
64 panel.allowedContentTypes = [format == .csv ? .commaSeparatedText : .json]
65
66 // The app is an accessory (LSUIElement), so it has to come forward or
67 // the panel opens behind whatever is frontmost.
68 NSApp.activate(ignoringOtherApps: true)
69 guard panel.runModal() == .OK, let url = panel.url else { return }
70
71 do {
72 try AlertExport.data(store.events, format: format).write(to: url, options: .atomic)
73 } catch {
74 exportErrorMessage = error.localizedDescription
75 }
4276 }
4377
4478 private var header: some View {
@@ -73,6 +107,19 @@ struct SecurityEventListView: View {
73107 }
74108 .buttonStyle(.plain)
75109 .disabled(store.isLoading)
110
111 Menu {
112 ForEach(AlertExportFormat.allCases, id: \.self) { format in
113 Button("Export as \(format.displayName)…") { export(format) }
114 }
115 } label: {
116 Image(systemName: "square.and.arrow.up")
117 }
118 .menuStyle(.borderlessButton)
119 .menuIndicator(.hidden)
120 .fixedSize()
121 .disabled(store.events.isEmpty)
122 .help("Export the alerts currently shown")
76123 }
77124
78125 if authStore.isSignedIn {
octosentryTests/AlertExportTests.swift added +150
@@ -0,0 +1,150 @@
1//
2// AlertExportTests.swift
3// octosentryTests
4//
5
6import Foundation
7import Testing
8@testable import octosentry
9
10struct AlertExportTests {
11
12 private let events = [
13 TestEvents.event(
14 id: "a",
15 source: .dependabot,
16 repo: "octocat/hello-world",
17 severity: .critical,
18 summary: "Denial of service in some-package"
19 ),
20 TestEvents.event(
21 id: "b",
22 source: .secretScanning,
23 repo: "octocat/spoon-knife",
24 severity: .high,
25 summary: "GitHub Personal Access Token"
26 ),
27 ]
28
29 // MARK: - CSV
30
31 @Test func csvStartsWithTheHeaderRow() {
32 let lines = AlertExport.csv(events).components(separatedBy: "\r\n")
33
34 #expect(lines.first == "Source,Repository,Severity,GitHub Severity,Summary,First Seen,URL")
35 }
36
37 @Test func csvWritesOneRowPerEventInOrder() throws {
38 let lines = AlertExport.csv(events).components(separatedBy: "\r\n")
39
40 try #require(lines.count == 3)
41 #expect(lines[1].hasPrefix("Dependabot,octocat/hello-world,Critical,Critical,Denial of service in some-package,"))
42 #expect(lines[2].hasPrefix("Secret Scanning,octocat/spoon-knife,High,High,GitHub Personal Access Token,"))
43 #expect(lines[1].hasSuffix("https://github.com/octocat/hello-world/security/a"))
44 }
45
46 // The case worth being explicit about: a summary carrying every
47 // character CSV cares about.
48 @Test func csvQuotesAndEscapesAwkwardSummaries() throws {
49 let awkward = [
50 TestEvents.event(
51 id: "x",
52 summary: #"Contains a comma, a "quote", and a"# + "\nnewline"
53 )
54 ]
55
56 // Records are CRLF-separated, so the field's embedded LF stays inside
57 // the record rather than starting a new one.
58 let records = AlertExport.csv(awkward).components(separatedBy: "\r\n")
59 try #require(records.count == 2)
60
61 let record = records[1]
62 #expect(record.contains(#""Contains a comma, a ""quote"", and a"#))
63 #expect(record.contains("\nnewline\""))
64 // The quoted field opens right after the GitHub severity column.
65 #expect(record.contains(#",High,"Contains"#))
66 }
67
68 @Test func csvLeavesOrdinaryFieldsUnquoted() {
69 let plain = [TestEvents.event(id: "x", summary: "Nothing special here")]
70
71 #expect(AlertExport.csv(plain).contains(",Nothing special here,"))
72 }
73
74 @Test func csvOfAnEmptyFeedIsJustTheHeader() {
75 #expect(AlertExport.csv([]) == "Source,Repository,Severity,GitHub Severity,Summary,First Seen,URL")
76 }
77
78 @Test func csvUsesCRLFLineEndings() {
79 #expect(AlertExport.csv(events).contains("\r\n"))
80 }
81
82 // MARK: - JSON
83
84 @Test func jsonEncodesTheDocumentedFields() throws {
85 let data = try AlertExport.json(events)
86 let objects = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]])
87
88 try #require(objects.count == 2)
89
90 let first = try #require(objects.first)
91 #expect(Set(first.keys) == [
92 "source", "repository", "severity", "githubSeverity", "summary", "firstSeen", "url",
93 ])
94 #expect(first["source"] as? String == "Dependabot")
95 #expect(first["repository"] as? String == "octocat/hello-world")
96 #expect(first["severity"] as? String == "Critical")
97 #expect(first["summary"] as? String == "Denial of service in some-package")
98 #expect(first["url"] as? String == "https://github.com/octocat/hello-world/security/a")
99 }
100
101 // Local triage state and the synthetic id are app internals.
102 @Test func jsonOmitsInternalFields() throws {
103 let json = try #require(String(data: try AlertExport.json(events), encoding: .utf8))
104
105 #expect(!json.contains("seenLocally"))
106 #expect(!json.contains("\"id\""))
107 #expect(!json.contains("detailURL"))
108 }
109
110 @Test func jsonEncodesDatesAsISO8601() throws {
111 let data = try AlertExport.json([TestEvents.event(id: "x")])
112 let objects = try #require(try JSONSerialization.jsonObject(with: data) as? [[String: Any]])
113 let firstSeen = try #require(objects.first?["firstSeen"] as? String)
114
115 #expect(ISO8601DateFormatter().date(from: firstSeen) == TestEvents.referenceDate)
116 }
117
118 @Test func jsonOfAnEmptyFeedIsAnEmptyArray() throws {
119 let objects = try JSONSerialization.jsonObject(with: try AlertExport.json([])) as? [Any]
120
121 #expect(objects?.isEmpty == true)
122 }
123
124 // MARK: - Format plumbing
125
126 @Test func dataMatchesTheFormatSpecificEncoders() throws {
127 #expect(try AlertExport.data(events, format: .csv) == Data(AlertExport.csv(events).utf8))
128 #expect(try AlertExport.data(events, format: .json) == (try AlertExport.json(events)))
129 }
130
131 @Test func filenameCarriesTheDateAndExtension() {
132 let date = Date(timeIntervalSince1970: 1_785_000_000)
133
134 #expect(AlertExport.filename(format: .csv, date: date).hasSuffix(".csv"))
135 #expect(AlertExport.filename(format: .json, date: date).hasSuffix(".json"))
136 #expect(AlertExport.filename(format: .csv, date: date).hasPrefix("octosentry-alerts-"))
137 }
138
139 // Export follows the feed, so filter and sort decide its contents.
140 @Test func exportReflectsFilterAndSortApplied() throws {
141 var filter = AlertFilter()
142 filter.sources = [.secretScanning]
143 let shown = AlertSortOrder.severity.sorted(filter.apply(to: events))
144
145 let lines = AlertExport.csv(shown).components(separatedBy: "\r\n")
146
147 try #require(lines.count == 2)
148 #expect(lines[1].hasPrefix("Secret Scanning,"))
149 }
150}