Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Sources/Clavis/Views/History/HistoryEventRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ struct HistoryEventRow: View {
}
}

private var secondaryText: String { "\(operation) · \(result.title)" }
private var secondaryText: String {
[operation, HistoryViewModel.callerTitle(sens), result.title].compactMap { $0 }.joined(separator: " · ")
}
private var primaryTitle: String { keyName ?? operation }

private var dateText: String {
Expand Down Expand Up @@ -160,6 +162,7 @@ struct HistoryEventRow: View {
"Key: \(keyName ?? "—")",
"Host: \(host ?? "—")",
"Result: \(reasonLine)",
"Caller: \(HistoryViewModel.callerTitle(sens) ?? "Not recorded")",
"Process: \(processLine)",
"Launched by: \(parentsLine)",
"Timestamp: \(Self.isoFormatter.string(from: event.time))",
Expand Down Expand Up @@ -233,6 +236,8 @@ struct HistoryEventRow: View {
if let fp = event.keyFingerprint { lines.append("Key Fingerprint: \(fp)") }
if case .plaintext(let s) = unseal {
if let label = s.keyLabel { lines.append("Key Label: \(label)") }
if let operation = s.requestOperation { lines.append("Operation: \(operation.rawValue)") }
if let caller = HistoryViewModel.callerTitle(s) { lines.append("Caller: \(caller)") }
if let host = s.host { lines.append("Host: \(host)") }
if !s.processChain.isEmpty {
lines.append("Processes:")
Expand Down
9 changes: 9 additions & 0 deletions Sources/Clavis/Views/History/HistoryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -413,13 +413,22 @@ public final class HistoryViewModel: ObservableObject {
return record.event.sensitive
}

public nonisolated static func callerTitle(_ sensitive: AuditSensitive) -> String? {
if sensitive.callerUncertain == true { return "Caller identity uncertain" }
guard let name = sensitive.callerName, !name.isEmpty else { return nil }
return "Verified caller: \(name)"
}

public nonisolated static func operationTitle(
for record: AuditRecord,
sensitive: AuditSensitive? = nil
) -> String {
let sens = sensitive ?? record.event.sensitive
switch record.event.type {
case .signature:
if let operation = sens.requestOperation {
return operation == .ssh ? "SSH authentication" : "Signature"
}
if record.event.keyKind == .agent || record.event.reason == .viaAgentSession {
return "Agent signature"
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Clavis/Views/History/Model/HistoryQuery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ public struct HistoryFilter: Equatable, Sendable {
|| has(e.result.rawValue)
|| has(e.keyFingerprint) || has(e.sessionID) { return true }
guard let s = sensitive else { return false }
if has(s.keyLabel) || has(s.host) { return true }
if has(s.keyLabel) || has(s.host) || has(s.callerName) { return true }
return s.processChain.contains {
has($0.executablePath) || has(($0.executablePath as NSString).lastPathComponent) || String($0.pid).contains(q)
}
Expand Down
11 changes: 10 additions & 1 deletion Sources/ClavisCore/Audit/AuditEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,24 @@ public struct AuditSensitive: Codable, Equatable, Sendable {
public var keyLabel: String?
public var processChain: [AuditProcess] // [0] = direct peer, then parents
public var host: String? // filled by story 006
public var requestOperation: KeyOperation?
public var callerName: String?
public var callerUncertain: Bool?

public init(
keyLabel: String? = nil,
processChain: [AuditProcess] = [],
host: String? = nil
host: String? = nil,
requestOperation: KeyOperation? = nil,
callerName: String? = nil,
callerUncertain: Bool? = nil
) {
self.keyLabel = keyLabel
self.processChain = processChain
self.host = host
self.requestOperation = requestOperation
self.callerName = callerName
self.callerUncertain = callerUncertain
}
}

Expand Down
8 changes: 7 additions & 1 deletion Sources/ClavisCore/Audit/AuditExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public enum AuditExporter {
let keyLabel: String?
let processChain: [ExportProcess]
let host: String?
let requestOperation: KeyOperation?
let callerName: String?
let callerUncertain: Bool?
}

/// Exports records in JSON Lines format, one object per record, newest first.
Expand Down Expand Up @@ -94,7 +97,10 @@ public enum AuditExporter {
count: event.count,
keyLabel: redactedLabel,
processChain: redactedChain,
host: resolvedSensitive?.host
host: resolvedSensitive?.host,
requestOperation: resolvedSensitive?.requestOperation,
callerName: resolvedSensitive?.callerName.map { hashString($0, salt: salt) },
callerUncertain: resolvedSensitive?.callerUncertain
)

if let lineData = try? encoder.encode(exportRec),
Expand Down
15 changes: 15 additions & 0 deletions Sources/ClavisCore/Audit/AuditRequestContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

public extension AuditEvent {
/// Request identity is sealed with other sensitive data. The original event
/// identifier and timestamp remain stable for linked History records.
func withRequestContext(operation: KeyOperation?, callerName: String?, uncertain: Bool?) -> AuditEvent {
var details = sensitive
details.requestOperation = operation
details.callerName = callerName
details.callerUncertain = uncertain
return AuditEvent(id: id, time: time, type: type, result: result, reason: reason,
keyFingerprint: keyFingerprint, keyKind: keyKind, sessionID: sessionID,
count: count, sensitive: details)
}
}
4 changes: 4 additions & 0 deletions Sources/ClavisCore/Crypto/GitSigningGraceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ public enum GitSigningPrompt {
return .singleShot
}

#if DEBUG
// Tests must inject a presenter instead of displaying an interactive alert.
guard !ClavisEnvironment.isTesting else { return .cancel }
#endif
var responseFlags: CFOptionFlags = 0
let strings = GitSigningPromptStrings.current
let header = strings.header as CFString
Expand Down
43 changes: 28 additions & 15 deletions Sources/ClavisCore/Protocol/SigningPolicyEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,37 +115,38 @@ public final class SigningPolicyEvaluator {
guard flags == 0, let key = try keyManager.listKeys().first(where: { $0.publicKeyBlob == keyBlob }) else {
return try evaluateAuthorizedSigningRequest(keyBlob: keyBlob, dataToSign: dataToSign, flags: flags, role: role, clientPid: clientPid, connection: connection, clientExecutablePath: clientExecutablePath, clientStartTime: clientStartTime, clientSocket: clientSocket)
}
let operation: KeyOperation = dataToSign.starts(with: SSHSIGPayload.magic) ? .signature : .ssh
guard let path = clientExecutablePath ?? ProcessInspector.getProcessPath(pid: clientPid),
let start = clientStartTime ?? ProcessInspector.processStartTime(pid: clientPid) else {
recordApplicationDecision(key: key, result: .denied, reason: .noPeerAttribution, pid: clientPid, path: clientExecutablePath ?? "")
recordApplicationDecision(key: key, result: .denied, reason: .noPeerAttribution, pid: clientPid, path: clientExecutablePath ?? "", operation: operation)
return .denied
}
let operation: KeyOperation = dataToSign.starts(with: SSHSIGPayload.magic) ? .signature : .ssh
if key.purpose == .gitSigningOnly {
guard SSHSIGPayload.parse(from: dataToSign) != nil else {
recordApplicationDecision(key: key, result: .denied, reason: .gitOnlyKeyNonGitPayload, pid: clientPid, path: path)
recordApplicationDecision(key: key, result: .denied, reason: .gitOnlyKeyNonGitPayload, pid: clientPid, path: path, operation: operation)
return .denied
}
guard ProcessInspector.isTrustedGitExecutable(path: path) || ProcessInspector.isSignerHelper(path: path) else {
recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path)
recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path, operation: operation)
return .denied
}
}
guard role.permits(key) else {
recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path)
recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path, operation: operation)
return .denied
}
let lease: ApplicationRequestLease
do {
lease = try applicationAccess.authorize(key: key, operation: operation, role: role, pid: clientPid, path: path, start: start)
} catch {
recordApplicationDecision(key: key, result: .denied, reason: .policyUnavailable, pid: clientPid, path: path)
recordApplicationDecision(key: key, result: .denied, reason: .policyUnavailable, pid: clientPid, path: path, operation: operation)
return .denied
}
let decision: SigningDecision
var pendingAudit: AuditEvent?
switch lease.eligibility {
case .denied:
recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path)
recordApplicationDecision(key: key, result: .denied, reason: .wrongKeyKind, pid: clientPid, path: path, operation: operation, lease: lease)
return .denied
case .authenticatedOnceOnly:
// No cache lookup, history heuristic, or remembered classification.
Expand All @@ -163,14 +164,15 @@ public final class SigningPolicyEvaluator {
}
decision = .approved(signature: signature)
} catch {
recordApplicationDecision(key: key, result: AuditEvent.isUserCancellation(error) ? .cancelled : .denied, reason: .authenticationFailed, pid: clientPid, path: path)
recordApplicationDecision(key: key, result: AuditEvent.isUserCancellation(error) ? .cancelled : .denied, reason: .authenticationFailed, pid: clientPid, path: path, operation: operation, lease: lease)
return .denied
}
case .eligible:
decision = try evaluateAuthorizedSigningRequest(
keyBlob: keyBlob, dataToSign: dataToSign, flags: flags, role: role, clientPid: clientPid,
connection: connection, clientExecutablePath: path, clientStartTime: start, clientSocket: clientSocket,
approvalIdentity: lease.approvalIdentity, callerName: lease.caller?.application.name
approvalIdentity: lease.approvalIdentity, callerName: lease.caller?.application.name,
auditSink: { pendingAudit = $0 }
)
}
if case .approved = decision {
Expand All @@ -180,20 +182,27 @@ public final class SigningPolicyEvaluator {
(try? applicationAccess.validate(lease, pid: clientPid, path: path, start: start)) == true else {
gitSigningGraceManager.invalidateAll(broadcast: false)
agentSessions.endAll(reason: .keyChanged)
recordApplicationDecision(key: key, result: .denied, reason: .peerChanged, pid: clientPid, path: path)
recordApplicationDecision(key: key, result: .denied, reason: .peerChanged, pid: clientPid, path: path, operation: operation, lease: lease)
return .dropConnection
}
if lease.eligibility == .authenticatedOnceOnly {
recordApplicationDecision(key: key, result: .allowed, reason: .viaPrompt, pid: clientPid, path: path)
recordApplicationDecision(key: key, result: .allowed, reason: .viaPrompt, pid: clientPid, path: path, operation: operation, lease: lease)
}
}
if let pendingAudit {
auditRecorder.record(pendingAudit.withRequestContext(operation: operation,
callerName: lease.caller?.application.name, uncertain: false))
}
return decision
}

private func recordApplicationDecision(key: SSHKeyInfo, result: AuditResult, reason: AuditReason, pid: pid_t, path: String) {
private func recordApplicationDecision(key: SSHKeyInfo, result: AuditResult, reason: AuditReason, pid: pid_t, path: String,
operation: KeyOperation? = nil, lease: ApplicationRequestLease? = nil) {
auditRecorder.record(AuditEvent(type: .signature, result: result, reason: reason,
keyFingerprint: key.fingerprint, keyKind: key.effectiveAccessProfile.type == .bot ? .agent : .personal, count: 1,
sensitive: .init(keyLabel: key.label, processChain: AuditProcessChain.build(pid: pid, executablePath: path))))
sensitive: .init(keyLabel: key.label, processChain: AuditProcessChain.build(pid: pid, executablePath: path)))
.withRequestContext(operation: operation, callerName: lease?.caller?.application.name,
uncertain: lease.map { $0.caller == nil }))
}

private func evaluateAuthorizedSigningRequest(
Expand All @@ -207,10 +216,14 @@ public final class SigningPolicyEvaluator {
clientStartTime: UInt64? = nil,
clientSocket: Int32? = nil,
approvalIdentity: String? = nil,
callerName: String? = nil
callerName: String? = nil,
auditSink: ((AuditEvent) -> Void)? = nil
) throws -> SigningDecision {
var audit = PendingSignAudit()
defer { auditRecorder.record(audit.makeEvent()) }
defer {
if let auditSink { auditSink(audit.makeEvent()) }
else { auditRecorder.record(audit.makeEvent()) }
}

guard flags == 0 else {
audit.result = .denied
Expand Down
47 changes: 47 additions & 0 deletions Tests/ClavisAppTests/HistoryRequestContextTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation
import Testing
@testable import Clavis
@testable import ClavisCore

@Suite struct HistoryRequestContextTests {
@Test func explicitOperationWinsOverProcessAndLegacyKeyKind() {
for operation in KeyOperation.allCases {
let details = AuditSensitive(processChain: [.init(executablePath: "/usr/bin/git", pid: 1)], requestOperation: operation)
let record = AuditRecord(seq: 1, event: .init(type: .signature, result: .allowed, keyKind: .agent, sensitive: details))
#expect(HistoryViewModel.operationTitle(for: record) == (operation == .ssh ? "SSH authentication" : "Signature"))
}
}

@Test func uncertaintyDoesNotShowAClaimedVerifiedName() {
#expect(HistoryViewModel.callerTitle(.init(callerName: "Claimed caller", callerUncertain: true)) == "Caller identity uncertain")
#expect(HistoryViewModel.callerTitle(.init(callerName: "Signed caller", callerUncertain: false)) == "Verified caller: Signed caller")
#expect(HistoryViewModel.callerTitle(.init()) == nil)
}

@Test func existingTextSearchFindsSealedCallerAfterDecryption() {
let event = AuditEvent(type: .signature, result: .allowed)
let record = AuditRecord(seq: 1, event: event)
var filter = HistoryFilter()
filter.add(.text("Antigravity"))
#expect(filter.matches(record, sensitive: .init(callerName: "Antigravity"), calendar: .current))
#expect(!filter.matches(record, sensitive: nil, calendar: .current))
}
@Test func contextDoesNotRelabelNonSignatureEvents() {
let details = AuditSensitive(requestOperation: .ssh)
let record = AuditRecord(seq: 1, event: .init(type: .policyChange, result: .info, sensitive: details))
#expect(HistoryViewModel.operationTitle(for: record) == "Signing policy updated")
}

@Test func oldRowsKeepTheirExistingOperationInterpretation() {
let details = AuditSensitive(processChain: [.init(executablePath: "/usr/bin/git", pid: 1)])
let record = AuditRecord(seq: 1, event: .init(type: .signature, result: .allowed, sensitive: details))
#expect(HistoryViewModel.operationTitle(for: record) == "Git commit signature")
#expect(HistoryViewModel.callerTitle(details) == nil)
}

@Test func missingCallerNameCannotBeDisplayedAsVerified() {
#expect(HistoryViewModel.callerTitle(.init(callerName: "", callerUncertain: false)) == nil)
#expect(HistoryViewModel.callerTitle(.init(callerUncertain: false)) == nil)
}

}
5 changes: 4 additions & 1 deletion Tests/ClavisCoreTests/AuditEventTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ import LocalAuthentication
let expectedSensitiveProperties: Set<String> = [
"keyLabel",
"processChain",
"host"
"host",
"requestOperation",
"callerName",
"callerUncertain"
]
#expect(sensitiveProperties == expectedSensitiveProperties)
}
Expand Down
Loading
Loading