krz/octosentry
macOS menu bar app to monitor GitHub security alerts
clone: git clone https://gitbay.org/krz/octosentry.git
51f436468ac124cf79c93da4bcf531dd9aad7e82
signed_unknown_key
author: Christian Cleberg <hello@cleberg.net> · 2026-08-22T20:33:00Z
committer: <noreply@github.com>
octosentry/PersistenceStore.swift | 41 ++++++- octosentry/SecurityEventStore.swift | 6 +- octosentryTests/Fixtures.swift | 135 +++++++++++++++++++++++ octosentryTests/GitHubAPIModelsTests.swift | 103 +++++++++++++++++ octosentryTests/PersistedStateTests.swift | 95 ++++++++++++++++ octosentryTests/PersistenceStoreTests.swift | 100 +++++++++++++++++ octosentryTests/SecurityEventSeverityTests.swift | 53 +++++++++ octosentryTests/SeverityMappingTests.swift | 62 +++++++++++ octosentryTests/octosentryTests.swift | 19 ---- 9 files changed, 587 insertions(+), 27 deletions(-) @@ -12,6 +12,11 @@ import Foundation actor PersistenceStore { private let fileURL: URL + /// Set when state.json existed but could not be decoded. The unreadable + /// file is moved aside rather than overwritten, so nothing is lost + /// silently; the message names where it went. + private(set) var loadFailureMessage: String? + private static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -24,23 +29,47 @@ actor PersistenceStore { return encoder }() - init() { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - let directory = appSupport.appendingPathComponent("octosentry", isDirectory: true) + init(directory: URL? = nil) { + let directory = directory ?? FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("octosentry", isDirectory: true) try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) fileURL = directory.appendingPathComponent("state.json") } func load() -> PersistedState { - guard let data = try? Data(contentsOf: fileURL), - let state = try? Self.decoder.decode(PersistedState.self, from: data) else { + // No file at all is first launch, not a failure. + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return .placeholder + } + + do { + let data = try Data(contentsOf: fileURL) + let state = try Self.decoder.decode(PersistedState.self, from: data) + loadFailureMessage = nil + return state + } catch { + // Falling back to .placeholder here means the next save writes a + // default watch list over the user's. Preserve the file first. + loadFailureMessage = preserveUnreadableState(error: error) return .placeholder } - return state } func save(_ state: PersistedState) { guard let data = try? Self.encoder.encode(state) else { return } try? data.write(to: fileURL, options: .atomic) } + + private func preserveUnreadableState(error: Error) -> String { + let backupURL = fileURL + .deletingLastPathComponent() + .appendingPathComponent("state-unreadable-\(Int(Date().timeIntervalSince1970)).json") + + guard (try? FileManager.default.moveItem(at: fileURL, to: backupURL)) != nil else { + return "Saved settings couldn't be read (\(error.localizedDescription))." + } + return "Saved settings couldn't be read (\(error.localizedDescription)). " + + "The previous file was kept as \(backupURL.lastPathComponent); octosentry started from defaults." + } } @@ -43,18 +43,20 @@ final class SecurityEventStore { defer { isLoading = false } var state = await persistenceStore.load() + let stateLoadFailure = await persistenceStore.loadFailureMessage minimumSeverity = state.minimumSeverity watchedRepos = state.watchedRepos guard let token = KeychainTokenStore.load() else { - errorMessages = [GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] + errorMessages = [stateLoadFailure, GitHubAPIError.missingToken.errorDescription ?? "Not signed in."] + .compactMap { $0 } return } let client = GitHubSecurityAPIClient(token: token) var fetchedEvents: [SecurityEvent] = [] - var errors: [String] = [] + var errors: [String] = [stateLoadFailure].compactMap { $0 } var notices: [String] = [] for repoFullName in state.watchedRepos { new file mode 100644 @@ -0,0 +1,135 @@ +// +// Fixtures.swift +// octosentryTests +// +// Response bodies recorded from the GitHub REST alert endpoints, trimmed +// to a couple of entries each but otherwise left as the API returns them. +// + +enum Fixtures { + + static let dependabotAlerts = """ + [ + { + "number": 4, + "state": "open", + "html_url": "https://github.com/octocat/hello-world/security/dependabot/4", + "created_at": "2026-06-21T22:12:22Z", + "updated_at": "2026-06-22T13:10:00Z", + "dependency": { + "package": { "ecosystem": "npm", "name": "some-package" }, + "manifest_path": "package-lock.json", + "scope": "runtime" + }, + "security_advisory": { + "ghsa_id": "GHSA-rf4j-j272-fj86", + "cve_id": "CVE-2026-11111", + "summary": "Denial of service in some-package", + "severity": "high" + }, + "security_vulnerability": { + "severity": "high", + "vulnerable_version_range": "< 1.2.3" + } + }, + { + "number": 3, + "state": "open", + "html_url": "https://github.com/octocat/hello-world/security/dependabot/3", + "created_at": "2026-06-19T09:01:00Z", + "updated_at": "2026-06-19T09:01:00Z", + "dependency": { + "package": { "ecosystem": "npm", "name": "other-package" }, + "manifest_path": "package-lock.json", + "scope": "development" + }, + "security_advisory": { + "ghsa_id": "GHSA-aaaa-bbbb-cccc", + "cve_id": null, + "summary": "Prototype pollution in other-package", + "severity": "moderate" + }, + "security_vulnerability": { + "severity": "moderate", + "vulnerable_version_range": "< 4.0.0" + } + } + ] + """ + + static let codeScanningAlerts = """ + [ + { + "number": 12, + "state": "open", + "html_url": "https://github.com/octocat/hello-world/security/code-scanning/12", + "created_at": "2026-06-20T08:00:00Z", + "updated_at": "2026-06-20T08:30:00Z", + "rule": { + "id": "js/sql-injection", + "name": "js/sql-injection", + "severity": "error", + "security_severity_level": "high", + "description": "Query built from user-controlled sources", + "tags": ["security", "external/cwe/cwe-089"] + }, + "tool": { "name": "CodeQL", "version": "2.16.0" }, + "most_recent_instance": { + "ref": "refs/heads/main", + "state": "open", + "message": { "text": "This query depends on a user-provided value." }, + "location": { "path": "src/db.js", "start_line": 42 } + } + }, + { + "number": 11, + "state": "open", + "html_url": "https://github.com/octocat/hello-world/security/code-scanning/11", + "created_at": "2026-06-18T11:00:00Z", + "updated_at": "2026-06-18T11:00:00Z", + "rule": { + "id": "js/unused-local-variable", + "name": "js/unused-local-variable", + "severity": "warning", + "security_severity_level": null, + "description": "Unused variable", + "tags": ["maintainability"] + }, + "tool": { "name": "CodeQL", "version": "2.16.0" } + } + ] + """ + + static let secretScanningAlerts = """ + [ + { + "number": 2, + "state": "open", + "html_url": "https://github.com/octocat/hello-world/security/secret-scanning/2", + "created_at": "2026-06-22T17:45:00Z", + "updated_at": "2026-06-22T17:45:00Z", + "secret_type": "github_personal_access_token", + "secret_type_display_name": "GitHub Personal Access Token", + "validity": "active", + "push_protection_bypassed": false + }, + { + "number": 1, + "state": "open", + "html_url": "https://github.com/octocat/hello-world/security/secret-scanning/1", + "created_at": "2026-06-15T10:00:00Z", + "updated_at": "2026-06-15T10:00:00Z", + "secret_type": "generic_api_key", + "secret_type_display_name": "Generic API Key", + "push_protection_bypassed": false + } + ] + """ + + static let userRepos = """ + [ + { "id": 1296269, "name": "hello-world", "full_name": "octocat/hello-world", "private": false }, + { "id": 1296270, "name": "spoon-knife", "full_name": "octocat/spoon-knife", "private": true } + ] + """ +} new file mode 100644 @@ -0,0 +1,103 @@ +// +// GitHubAPIModelsTests.swift +// octosentryTests +// +// Decoding against recorded response bodies from the three alert +// endpoints. Fields octosentry doesn't read are left in the fixtures on +// purpose — decoding must tolerate them. +// + +import Foundation +import Testing +@testable import octosentry + +struct GitHubAPIModelsTests { + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + private static func iso8601(_ string: String) -> Date { + ISO8601DateFormatter().date(from: string)! + } + + // MARK: - Dependabot + + @Test func decodesDependabotAlerts() throws { + let alerts = try Self.decoder.decode([DependabotAlertDTO].self, from: Data(Fixtures.dependabotAlerts.utf8)) + + #expect(alerts.count == 2) + + let first = try #require(alerts.first) + #expect(first.number == 4) + #expect(first.htmlUrl.absoluteString == "https://github.com/octocat/hello-world/security/dependabot/4") + #expect(first.securityAdvisory.summary == "Denial of service in some-package") + #expect(first.securityAdvisory.severity == "high") + #expect(first.createdAt == Self.iso8601("2026-06-21T22:12:22Z")) + #expect(first.updatedAt == Self.iso8601("2026-06-22T13:10:00Z")) + + #expect(alerts[1].securityAdvisory.severity == "moderate") + } + + // MARK: - Code scanning + + @Test func decodesCodeScanningAlertWithSecuritySeverity() throws { + let alerts = try Self.decoder.decode([CodeScanningAlertDTO].self, from: Data(Fixtures.codeScanningAlerts.utf8)) + + let first = try #require(alerts.first) + #expect(first.number == 12) + #expect(first.rule.id == "js/sql-injection") + #expect(first.rule.severity == "error") + #expect(first.rule.securitySeverityLevel == "high") + #expect(first.mostRecentInstance?.message?.text == "This query depends on a user-provided value.") + } + + // A rule with no security_severity_level and no instance message is the + // case that drives the summary and severity fallbacks in the client. + @Test func decodesCodeScanningAlertWithNullsAndNoInstance() throws { + let alerts = try Self.decoder.decode([CodeScanningAlertDTO].self, from: Data(Fixtures.codeScanningAlerts.utf8)) + + let second = try #require(alerts.dropFirst().first) + #expect(second.rule.securitySeverityLevel == nil) + #expect(second.rule.severity == "warning") + #expect(second.rule.description == "Unused variable") + #expect(second.mostRecentInstance == nil) + } + + // MARK: - Secret scanning + + @Test func decodesSecretScanningAlerts() throws { + let alerts = try Self.decoder.decode([SecretScanningAlertDTO].self, from: Data(Fixtures.secretScanningAlerts.utf8)) + + #expect(alerts.count == 2) + + let first = try #require(alerts.first) + #expect(first.number == 2) + #expect(first.secretTypeDisplayName == "GitHub Personal Access Token") + #expect(first.validity == "active") + + // validity is absent on providers GitHub can't check. + #expect(alerts[1].validity == nil) + #expect(alerts[1].secretTypeDisplayName == "Generic API Key") + } + + // MARK: - Repos + + @Test func decodesAccessibleRepos() throws { + let repos = try Self.decoder.decode([GitHubRepoDTO].self, from: Data(Fixtures.userRepos.utf8)) + + #expect(repos.map(\.fullName) == ["octocat/hello-world", "octocat/spoon-knife"]) + } + + // MARK: - Empty responses + + @Test func decodesAnEmptyAlertList() throws { + let empty = Data("[]".utf8) + + #expect(try Self.decoder.decode([DependabotAlertDTO].self, from: empty).isEmpty) + #expect(try Self.decoder.decode([CodeScanningAlertDTO].self, from: empty).isEmpty) + #expect(try Self.decoder.decode([SecretScanningAlertDTO].self, from: empty).isEmpty) + } +} new file mode 100644 @@ -0,0 +1,95 @@ +// +// PersistedStateTests.swift +// octosentryTests +// +// PersistedState is a file format: state.json written by one version has +// to load in the next. These pin the current shape. +// + +import Foundation +import Testing +@testable import octosentry + +struct PersistedStateTests { + + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + @Test func roundTripsEveryField() throws { + let fetchedAt = Date(timeIntervalSince1970: 1_785_000_000) + let original = PersistedState( + watchedRepos: ["octocat/hello-world", "octocat/spoon-knife"], + seenEventIDs: ["dependabot-octocat/hello-world-1", "codeScanning-octocat/spoon-knife-7"], + lastFetchByRepo: ["octocat/hello-world": fetchedAt], + minimumSeverity: .high, + hasRepoScope: true + ) + + let decoded = try Self.decoder.decode( + PersistedState.self, + from: Self.encoder.encode(original) + ) + + #expect(decoded.watchedRepos == original.watchedRepos) + #expect(decoded.seenEventIDs == original.seenEventIDs) + #expect(decoded.lastFetchByRepo == original.lastFetchByRepo) + #expect(decoded.minimumSeverity == original.minimumSeverity) + #expect(decoded.hasRepoScope == original.hasRepoScope) + } + + @Test func encodesTheKeysOnDiskReadersDependOn() throws { + let data = try Self.encoder.encode(PersistedState.placeholder) + let object = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + #expect(Set(object.keys) == [ + "watchedRepos", "seenEventIDs", "lastFetchByRepo", "minimumSeverity", "hasRepoScope", + ]) + } + + // A state.json written before hasRepoScope existed must still load. + @Test func decodesLegacyStateWithoutRepoScope() throws { + let legacy = """ + { + "watchedRepos": ["octocat/hello-world"], + "seenEventIDs": ["dependabot-octocat/hello-world-1"], + "lastFetchByRepo": {"octocat/hello-world": "2026-08-01T12:00:00Z"}, + "minimumSeverity": 1 + } + """ + + let state = try Self.decoder.decode(PersistedState.self, from: Data(legacy.utf8)) + + #expect(state.watchedRepos == ["octocat/hello-world"]) + #expect(state.seenEventIDs == ["dependabot-octocat/hello-world-1"]) + #expect(state.minimumSeverity == .medium) + #expect(state.hasRepoScope == false) + } + + @Test func rejectsStateMissingARequiredField() { + let missingWatchList = """ + {"seenEventIDs": [], "lastFetchByRepo": {}, "minimumSeverity": 0} + """ + + #expect(throws: (any Error).self) { + try Self.decoder.decode(PersistedState.self, from: Data(missingWatchList.utf8)) + } + } + + @Test func placeholderStartsWithNoSeenStateAndNoRepoScope() { + #expect(PersistedState.placeholder.seenEventIDs.isEmpty) + #expect(PersistedState.placeholder.lastFetchByRepo.isEmpty) + #expect(PersistedState.placeholder.minimumSeverity == .low) + #expect(PersistedState.placeholder.hasRepoScope == false) + } +} new file mode 100644 @@ -0,0 +1,100 @@ +// +// PersistenceStoreTests.swift +// octosentryTests +// + +import Foundation +import Testing +@testable import octosentry + +struct PersistenceStoreTests { + + /// Each test gets its own directory so none of them touch the real + /// Application Support container. + private func makeTemporaryDirectory() throws -> URL { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("octosentry-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + @Test func loadReturnsPlaceholderOnFirstLaunch() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let store = PersistenceStore(directory: directory) + let state = await store.load() + + #expect(state.watchedRepos == PersistedState.placeholder.watchedRepos) + #expect(await store.loadFailureMessage == nil) + } + + @Test func savedStateSurvivesAReload() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let saved = PersistedState( + watchedRepos: ["octocat/hello-world"], + seenEventIDs: ["dependabot-octocat/hello-world-1"], + lastFetchByRepo: ["octocat/hello-world": Date(timeIntervalSince1970: 1_785_000_000)], + minimumSeverity: .high, + hasRepoScope: true + ) + await PersistenceStore(directory: directory).save(saved) + + let reloaded = await PersistenceStore(directory: directory).load() + + #expect(reloaded.watchedRepos == saved.watchedRepos) + #expect(reloaded.seenEventIDs == saved.seenEventIDs) + #expect(reloaded.lastFetchByRepo == saved.lastFetchByRepo) + #expect(reloaded.minimumSeverity == saved.minimumSeverity) + #expect(reloaded.hasRepoScope == saved.hasRepoScope) + } + + // The failure that matters: state.json exists but won't decode. Falling + // back to .placeholder is fine, overwriting the user's watch list on the + // next save is not. + @Test func unreadableStateIsKeptAndReported() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let stateURL = directory.appendingPathComponent("state.json") + let original = Data(#"{"watchedRepos":["octocat/hello-world"],"#.utf8) + try original.write(to: stateURL) + + let store = PersistenceStore(directory: directory) + let state = await store.load() + #expect(state.watchedRepos == PersistedState.placeholder.watchedRepos) + + let message = await store.loadFailureMessage + #expect(message != nil) + + // The original bytes are still on disk under a different name... + let preserved = try FileManager.default + .contentsOfDirectory(atPath: directory.path) + .filter { $0.hasPrefix("state-unreadable-") } + #expect(preserved.count == 1) + let preservedName = try #require(preserved.first) + #expect(message?.contains(preservedName) == true) + #expect(try Data(contentsOf: directory.appendingPathComponent(preservedName)) == original) + + // ...and a later save doesn't clobber the preserved copy. + await store.save(state) + #expect(try Data(contentsOf: directory.appendingPathComponent(preservedName)) == original) + } + + @Test func loadFailureIsClearedByASubsequentGoodLoad() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + try Data("not json".utf8).write(to: directory.appendingPathComponent("state.json")) + + let store = PersistenceStore(directory: directory) + _ = await store.load() + #expect(await store.loadFailureMessage != nil) + + await store.save(PersistedState.placeholder) + _ = await store.load() + #expect(await store.loadFailureMessage == nil) + } +} new file mode 100644 @@ -0,0 +1,53 @@ +// +// SecurityEventSeverityTests.swift +// octosentryTests +// + +import Foundation +import Testing +@testable import octosentry + +struct SecurityEventSeverityTests { + + @Test func ordersLowToCritical() { + #expect(SecurityEventSeverity.low < .medium) + #expect(SecurityEventSeverity.medium < .high) + #expect(SecurityEventSeverity.high < .critical) + #expect(SecurityEventSeverity.critical > .low) + } + + @Test func sortsAscendingByRawValue() { + let sorted = SecurityEventSeverity.allCases.shuffled().sorted() + #expect(sorted == [.low, .medium, .high, .critical]) + } + + // The minimum-severity filter is a `>=` comparison, so a threshold of + // .high must admit exactly high and critical. + @Test func thresholdComparisonAdmitsAtOrAboveOnly() { + let admitted = SecurityEventSeverity.allCases.filter { $0 >= .high } + #expect(admitted == [.high, .critical]) + } + + @Test func allCasesIsDeclarationOrder() { + #expect(SecurityEventSeverity.allCases == [.low, .medium, .high, .critical]) + } + + // Raw values are written into state.json, so they are a file format. + @Test func rawValuesAreStable() { + #expect(SecurityEventSeverity.low.rawValue == 0) + #expect(SecurityEventSeverity.medium.rawValue == 1) + #expect(SecurityEventSeverity.high.rawValue == 2) + #expect(SecurityEventSeverity.critical.rawValue == 3) + } + + @Test func roundTripsThroughCodable() throws { + for severity in SecurityEventSeverity.allCases { + let data = try JSONEncoder().encode(severity) + #expect(try JSONDecoder().decode(SecurityEventSeverity.self, from: data) == severity) + } + } + + @Test func hasADisplayNameForEveryCase() { + #expect(SecurityEventSeverity.allCases.allSatisfy { !$0.displayName.isEmpty }) + } +} new file mode 100644 @@ -0,0 +1,62 @@ +// +// SeverityMappingTests.swift +// octosentryTests +// + +import Testing +@testable import octosentry + +struct SeverityMappingTests { + + @Test func dependabotMapsGitHubVocabulary() { + #expect(SeverityMapping.dependabot("critical") == .critical) + #expect(SeverityMapping.dependabot("high") == .high) + #expect(SeverityMapping.dependabot("moderate") == .medium) + #expect(SeverityMapping.dependabot("medium") == .medium) + #expect(SeverityMapping.dependabot("low") == .low) + } + + @Test func dependabotIsCaseInsensitive() { + #expect(SeverityMapping.dependabot("CRITICAL") == .critical) + #expect(SeverityMapping.dependabot("Moderate") == .medium) + } + + @Test func dependabotFallsBackToMediumOnUnknownSeverity() { + #expect(SeverityMapping.dependabot("") == .medium) + #expect(SeverityMapping.dependabot("catastrophic") == .medium) + } + + @Test func codeScanningPrefersSecuritySeverityLevel() { + // security_severity_level wins even when rule.severity disagrees. + #expect(SeverityMapping.codeScanning(securitySeverityLevel: "critical", ruleSeverity: "note") == .critical) + #expect(SeverityMapping.codeScanning(securitySeverityLevel: "high", ruleSeverity: "note") == .high) + #expect(SeverityMapping.codeScanning(securitySeverityLevel: "medium", ruleSeverity: "error") == .medium) + #expect(SeverityMapping.codeScanning(securitySeverityLevel: "low", ruleSeverity: "error") == .low) + } + + @Test func codeScanningFallsBackToRuleSeverity() { + #expect(SeverityMapping.codeScanning(securitySeverityLevel: nil, ruleSeverity: "error") == .high) + #expect(SeverityMapping.codeScanning(securitySeverityLevel: nil, ruleSeverity: "warning") == .medium) + #expect(SeverityMapping.codeScanning(securitySeverityLevel: nil, ruleSeverity: "note") == .low) + } + + @Test func codeScanningFallsBackToRuleSeverityWhenLevelIsUnrecognized() { + #expect(SeverityMapping.codeScanning(securitySeverityLevel: "unknown", ruleSeverity: "error") == .high) + } + + @Test func codeScanningDefaultsToMediumWithNothingUsable() { + #expect(SeverityMapping.codeScanning(securitySeverityLevel: nil, ruleSeverity: nil) == .medium) + #expect(SeverityMapping.codeScanning(securitySeverityLevel: "unknown", ruleSeverity: "unknown") == .medium) + } + + @Test func secretScanningTreatsActiveSecretsAsCritical() { + #expect(SeverityMapping.secretScanning(validity: "active") == .critical) + #expect(SeverityMapping.secretScanning(validity: "ACTIVE") == .critical) + } + + @Test func secretScanningTreatsEverythingElseAsHigh() { + #expect(SeverityMapping.secretScanning(validity: nil) == .high) + #expect(SeverityMapping.secretScanning(validity: "inactive") == .high) + #expect(SeverityMapping.secretScanning(validity: "unknown") == .high) + } +} deleted file mode 100644 @@ -1,19 +0,0 @@ -// -// octosentryTests.swift -// octosentryTests -// -// Created by cmc on 2026-07-17. -// - -import Testing -@testable import octosentry - -struct octosentryTests { - - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing - } - -}