Commit fdd328cc74
Unsigned
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 | |||
| 14 | import Foundation | ||
| 15 | |||
| 16 | nonisolated 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 | |||
| 30 | nonisolated 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 | ||
| 6 | import AppKit | 6 | import AppKit |
| 7 | import SwiftUI | 7 | import SwiftUI |
| 8 | import UniformTypeIdentifiers | ||
| 8 | 9 | ||
| 9 | struct SecurityEventListView: View { | 10 | struct 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 | |||
| 6 | import Foundation | ||
| 7 | import Testing | ||
| 8 | @testable import octosentry | ||
| 9 | |||
| 10 | struct 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 | } | ||