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 @@
402 ENABLE_HARDENED_RUNTIME = YES; 402 ENABLE_HARDENED_RUNTIME = YES;
403 ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; 403 ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
404 ENABLE_PREVIEWS = YES; 404 ENABLE_PREVIEWS = YES;
405 ENABLE_USER_SELECTED_FILES = readonly; 405 ENABLE_USER_SELECTED_FILES = readwrite;
406 GENERATE_INFOPLIST_FILE = YES; 406 GENERATE_INFOPLIST_FILE = YES;
407 INFOPLIST_KEY_CFBundleDisplayName = OctoSentry; 407 INFOPLIST_KEY_CFBundleDisplayName = OctoSentry;
408 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; 408 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
@@ -438,7 +438,7 @@
438 ENABLE_HARDENED_RUNTIME = YES; 438 ENABLE_HARDENED_RUNTIME = YES;
439 ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; 439 ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
440 ENABLE_PREVIEWS = YES; 440 ENABLE_PREVIEWS = YES;
441 ENABLE_USER_SELECTED_FILES = readonly; 441 ENABLE_USER_SELECTED_FILES = readwrite;
442 GENERATE_INFOPLIST_FILE = YES; 442 GENERATE_INFOPLIST_FILE = YES;
443 INFOPLIST_KEY_CFBundleDisplayName = OctoSentry; 443 INFOPLIST_KEY_CFBundleDisplayName = OctoSentry;
444 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; 444 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 @@
5 5
6import AppKit 6import AppKit
7import SwiftUI 7import SwiftUI
8import UniformTypeIdentifiers
8 9
9struct SecurityEventListView: View { 10struct SecurityEventListView: View {
10 var store: SecurityEventStore 11 var store: SecurityEventStore
@@ -12,6 +13,7 @@ struct SecurityEventListView: View {
12 var updateStore: UpdateStore 13 var updateStore: UpdateStore
13 var isStandaloneWindow: Bool = false 14 var isStandaloneWindow: Bool = false
14 @State private var showingRepoManager = false 15 @State private var showingRepoManager = false
16 @State private var exportErrorMessage: String?
15 @Environment(\.openWindow) private var openWindow 17 @Environment(\.openWindow) private var openWindow
16 18
17 var body: some View { 19 var body: some View {
@@ -39,6 +41,38 @@ struct SecurityEventListView: View {
39 .task { 41 .task {
40 await updateStore.checkForUpdate() 42 await updateStore.checkForUpdate()
41 } 43 }
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 }
42 } 76 }
43 77
44 private var header: some View { 78 private var header: some View {
@@ -73,6 +107,19 @@ struct SecurityEventListView: View {
73 } 107 }
74 .buttonStyle(.plain) 108 .buttonStyle(.plain)
75 .disabled(store.isLoading) 109 .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")
76 } 123 }
77 124
78 if authStore.isSignedIn { 125 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}