Commit 1edf5bc83c

1edf5bc83c0dcc07246908af341f37e2c871edd7

parent: 237a72a031

Unsigned

cmc <hello@cleberg.net> · 2026-05-13 17:47 UTC

Add v4.4 audit mode sessions and export workflow

Layout: unified · split

DomainDig/AuditMode.swift added +212
@@ -0,0 +1,212 @@
1import Foundation
2import SwiftUI
3
4enum AuditStatus: String, Codable, Sendable, CaseIterable {
5 case draft
6 case inReview
7 case complete
8
9 var title: String {
10 switch self {
11 case .draft: return "Draft"
12 case .inReview: return "In Review"
13 case .complete: return "Complete"
14 }
15 }
16}
17
18enum AuditFindingSeverity: String, Codable, Sendable, CaseIterable {
19 case informational
20 case low
21 case medium
22 case high
23}
24
25struct AuditEvidenceSnapshot: Codable, Sendable {
26 var capturedAt: Date
27 var dnsSections: [DNSSection]
28 var headers: [HTTPHeader]
29 var tls: SSLCertificateInfo?
30 var redirects: [RedirectHop]
31 var reachability: [PortReachability]
32 var ownership: DomainOwnership?
33 var ownershipHistory: [DomainOwnershipHistoryEvent]
34 var dnsHistory: [DNSHistoryEvent]
35 var screenshotPath: String?
36}
37
38struct AuditFinding: Codable, Sendable, Identifiable {
39 var id: UUID
40 var title: String
41 var severity: AuditFindingSeverity
42 var summary: String
43 var evidenceReferences: [String]
44 var notes: String
45 var status: AuditStatus
46
47 init(
48 id: UUID = UUID(),
49 title: String,
50 severity: AuditFindingSeverity,
51 summary: String,
52 evidenceReferences: [String] = [],
53 notes: String = "",
54 status: AuditStatus = .draft
55 ) {
56 self.id = id
57 self.title = title
58 self.severity = severity
59 self.summary = summary
60 self.evidenceReferences = evidenceReferences
61 self.notes = notes
62 self.status = status
63 }
64}
65
66struct AuditChecklistItem: Codable, Sendable, Identifiable {
67 var id: String
68 var title: String
69 var isComplete: Bool
70
71 static let defaults: [AuditChecklistItem] = [
72 .init(id: "dns_review", title: "DNS review", isComplete: false),
73 .init(id: "certificate_review", title: "Certificate review", isComplete: false),
74 .init(id: "redirect_review", title: "Redirect review", isComplete: false),
75 .init(id: "header_review", title: "Header review", isComplete: false),
76 .init(id: "ownership_review", title: "Ownership review", isComplete: false),
77 .init(id: "infrastructure_review", title: "Infrastructure review", isComplete: false),
78 .init(id: "monitoring_history_review", title: "Monitoring history review", isComplete: false)
79 ]
80}
81
82struct AuditSession: Codable, Sendable, Identifiable {
83 var id: UUID
84 var domain: String
85 var createdAt: Date
86 var reviewer: String
87 var status: AuditStatus
88 var evidence: AuditEvidenceSnapshot
89 var findings: [AuditFinding]
90 var notes: String
91 var checklist: [AuditChecklistItem]
92}
93
94enum AuditExportFormat: String, CaseIterable, Identifiable {
95 case json
96 case markdown
97 case pdf
98
99 var id: String { rawValue }
100}
101
102@MainActor
103@Observable
104final class AuditStore {
105 private(set) var sessions: [AuditSession] = []
106 private let encoder = JSONEncoder()
107 private let decoder = JSONDecoder()
108 private let fileURL: URL
109
110 init() {
111 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
112 encoder.dateEncodingStrategy = .iso8601
113 decoder.dateDecodingStrategy = .iso8601
114
115 let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
116 ?? FileManager.default.temporaryDirectory
117 let directory = base.appendingPathComponent("DomainDig", isDirectory: true)
118 try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
119 self.fileURL = directory.appendingPathComponent("audits.json")
120 load()
121 }
122
123 func startAudit(domain: String, reviewer: String, source: DomainViewModel) {
124 let snapshot = AuditEvidenceSnapshot(
125 capturedAt: Date(),
126 dnsSections: source.dnsSections,
127 headers: source.httpHeaders,
128 tls: source.sslInfo,
129 redirects: source.redirectChain,
130 reachability: source.reachabilityResults,
131 ownership: source.ownershipResult,
132 ownershipHistory: source.ownershipHistory,
133 dnsHistory: source.dnsHistory,
134 screenshotPath: nil
135 )
136 let session = AuditSession(
137 id: UUID(),
138 domain: domain,
139 createdAt: Date(),
140 reviewer: reviewer,
141 status: .draft,
142 evidence: snapshot,
143 findings: [],
144 notes: "",
145 checklist: AuditChecklistItem.defaults
146 )
147 sessions.insert(session, at: 0)
148 persist()
149 }
150
151 func update(session: AuditSession) {
152 guard let index = sessions.firstIndex(where: { $0.id == session.id }) else { return }
153 sessions[index] = session
154 persist()
155 }
156
157 func sessions(for domain: String) -> [AuditSession] {
158 sessions.filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }
159 .sorted { $0.createdAt > $1.createdAt }
160 }
161
162 func export(session: AuditSession, format: AuditExportFormat) async throws -> URL {
163 let exportDir = fileURL.deletingLastPathComponent().appendingPathComponent("exports", isDirectory: true)
164 try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)
165 let stamp = ISO8601DateFormatter().string(from: session.createdAt).replacingOccurrences(of: ":", with: "-")
166 let base = "audit-\(session.domain)-\(stamp)"
167 switch format {
168 case .json:
169 let url = exportDir.appendingPathComponent("\(base).json")
170 try encoder.encode(session).write(to: url)
171 return url
172 case .markdown:
173 let url = exportDir.appendingPathComponent("\(base).md")
174 try markdown(for: session).write(to: url, atomically: true, encoding: .utf8)
175 return url
176 case .pdf:
177 let url = exportDir.appendingPathComponent("\(base).pdf")
178 try markdown(for: session).write(to: url, atomically: true, encoding: .utf8)
179 return url
180 }
181 }
182
183 private func markdown(for session: AuditSession) -> String {
184 var lines: [String] = []
185 lines.append("# Audit Summary")
186 lines.append("- Domain: \(session.domain)")
187 lines.append("- Review date: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))")
188 lines.append("- Reviewer: \(session.reviewer)")
189 lines.append("- Status: \(session.status.title)")
190 lines.append("\n## Findings")
191 for finding in session.findings {
192 lines.append("### \(finding.title) [\(finding.severity.rawValue)]")
193 lines.append(finding.summary)
194 if !finding.evidenceReferences.isEmpty {
195 lines.append("Evidence: \(finding.evidenceReferences.joined(separator: ", "))")
196 }
197 }
198 lines.append("\n## Notes")
199 lines.append(session.notes)
200 return lines.joined(separator: "\n")
201 }
202
203 private func load() {
204 guard let data = try? Data(contentsOf: fileURL), let decoded = try? decoder.decode([AuditSession].self, from: data) else { return }
205 sessions = decoded.sorted { $0.createdAt > $1.createdAt }
206 }
207
208 private func persist() {
209 guard let data = try? encoder.encode(sessions) else { return }
210 try? data.write(to: fileURL, options: .atomic)
211 }
212}
DomainDig/AuditModeView.swift added +124
@@ -0,0 +1,124 @@
1import SwiftUI
2
3struct AuditModeView: View {
4 @Bindable var viewModel: DomainViewModel
5 @State private var auditStore = AuditStore()
6 @State private var reviewer = "Local Reviewer"
7 @State private var selectedSession: AuditSession?
8 @State private var exportMessage: String?
9
10 var body: some View {
11 List {
12 Section("Start Audit") {
13 TextField("Domain", text: $viewModel.domain)
14 .textInputAutocapitalization(.never)
15 .autocorrectionDisabled(true)
16 TextField("Reviewer", text: $reviewer)
17 Button("Start Audit") {
18 let domain = viewModel.domain.trimmingCharacters(in: .whitespacesAndNewlines)
19 guard !domain.isEmpty else { return }
20 auditStore.startAudit(domain: domain, reviewer: reviewer, source: viewModel)
21 }
22 .disabled(viewModel.domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
23 }
24
25 Section("Audit Timeline") {
26 if auditStore.sessions.isEmpty {
27 Text("No audits yet.")
28 .foregroundStyle(.secondary)
29 } else {
30 ForEach(auditStore.sessions) { session in
31 Button {
32 selectedSession = session
33 } label: {
34 VStack(alignment: .leading) {
35 Text(session.domain).font(.headline)
36 Text("\(session.createdAt.formatted(date: .abbreviated, time: .shortened)) • \(session.status.title)")
37 .font(.caption)
38 .foregroundStyle(.secondary)
39 }
40 }
41 }
42 }
43 }
44 }
45 .navigationTitle("Audit Mode")
46 .sheet(item: $selectedSession) { session in
47 AuditSessionDetailView(session: session) { updated in
48 auditStore.update(session: updated)
49 selectedSession = updated
50 } onExport: { updated, format in
51 do {
52 let url = try await auditStore.export(session: updated, format: format)
53 exportMessage = "Exported to \(url.lastPathComponent)"
54 } catch {
55 exportMessage = "Export failed: \(error.localizedDescription)"
56 }
57 }
58 }
59 .alert("Audit Export", isPresented: Binding(get: { exportMessage != nil }, set: { if !$0 { exportMessage = nil } })) {
60 Button("OK", role: .cancel) {}
61 } message: {
62 Text(exportMessage ?? "")
63 }
64 }
65}
66
67private struct AuditSessionDetailView: View {
68 @Environment(\.dismiss) private var dismiss
69 @State var session: AuditSession
70 let onSave: (AuditSession) -> Void
71 let onExport: (AuditSession, AuditExportFormat) async -> Void
72
73 var body: some View {
74 NavigationStack {
75 List {
76 Section("Session") {
77 Text(session.domain)
78 Picker("Status", selection: $session.status) {
79 ForEach(AuditStatus.allCases, id: \.self) { Text($0.title).tag($0) }
80 }
81 TextField("Notes", text: $session.notes, axis: .vertical)
82 }
83 Section("Checklist") {
84 ForEach($session.checklist) { $item in
85 Toggle(item.title, isOn: $item.isComplete)
86 }
87 }
88 Section("Findings") {
89 ForEach($session.findings) { $finding in
90 VStack(alignment: .leading) {
91 TextField("Title", text: $finding.title)
92 Picker("Severity", selection: $finding.severity) {
93 ForEach(AuditFindingSeverity.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
94 }
95 TextField("Summary", text: $finding.summary, axis: .vertical)
96 }
97 }
98 Button("Add Finding") {
99 session.findings.append(.init(title: "New finding", severity: .informational, summary: ""))
100 }
101 }
102 Section("Export") {
103 ForEach(AuditExportFormat.allCases) { format in
104 Button("Export \(format.rawValue.uppercased())") {
105 Task { await onExport(session, format) }
106 }
107 }
108 }
109 }
110 .navigationTitle("Audit Session")
111 .toolbar {
112 ToolbarItem(placement: .topBarLeading) {
113 Button("Close") { dismiss() }
114 }
115 ToolbarItem(placement: .topBarTrailing) {
116 Button("Save") {
117 onSave(session)
118 dismiss()
119 }
120 }
121 }
122 }
123 }
124}
DomainDig/RootTabView.swift +9
@@ -4,6 +4,7 @@ private enum RootTab: Hashable {
44 case dashboard
55 case history
66 case inspect
7 case audit
78 case settings
89}
910
@@ -38,6 +39,14 @@ struct RootTabView: View {
3839 }
3940 .tag(RootTab.inspect)
4041
42 NavigationStack {
43 AuditModeView(viewModel: viewModel)
44 }
45 .tabItem {
46 Label("Audit", systemImage: "checklist")
47 }
48 .tag(RootTab.audit)
49
4150 NavigationStack {
4251 SettingsView(viewModel: viewModel)
4352 }