From 1edf5bc83c0dcc07246908af341f37e2c871edd7 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 13 May 2026 12:47:16 -0500 Subject: Add v4.4 audit mode sessions and export workflow --- DomainDig/AuditMode.swift | 212 ++++++++++++++++++++++++++++++++++++++++++ DomainDig/AuditModeView.swift | 124 ++++++++++++++++++++++++ DomainDig/RootTabView.swift | 9 ++ 3 files changed, 345 insertions(+) create mode 100644 DomainDig/AuditMode.swift create mode 100644 DomainDig/AuditModeView.swift diff --git a/DomainDig/AuditMode.swift b/DomainDig/AuditMode.swift new file mode 100644 index 0000000..0a70fbf --- /dev/null +++ b/DomainDig/AuditMode.swift @@ -0,0 +1,212 @@ +import Foundation +import SwiftUI + +enum AuditStatus: String, Codable, Sendable, CaseIterable { + case draft + case inReview + case complete + + var title: String { + switch self { + case .draft: return "Draft" + case .inReview: return "In Review" + case .complete: return "Complete" + } + } +} + +enum AuditFindingSeverity: String, Codable, Sendable, CaseIterable { + case informational + case low + case medium + case high +} + +struct AuditEvidenceSnapshot: Codable, Sendable { + var capturedAt: Date + var dnsSections: [DNSSection] + var headers: [HTTPHeader] + var tls: SSLCertificateInfo? + var redirects: [RedirectHop] + var reachability: [PortReachability] + var ownership: DomainOwnership? + var ownershipHistory: [DomainOwnershipHistoryEvent] + var dnsHistory: [DNSHistoryEvent] + var screenshotPath: String? +} + +struct AuditFinding: Codable, Sendable, Identifiable { + var id: UUID + var title: String + var severity: AuditFindingSeverity + var summary: String + var evidenceReferences: [String] + var notes: String + var status: AuditStatus + + init( + id: UUID = UUID(), + title: String, + severity: AuditFindingSeverity, + summary: String, + evidenceReferences: [String] = [], + notes: String = "", + status: AuditStatus = .draft + ) { + self.id = id + self.title = title + self.severity = severity + self.summary = summary + self.evidenceReferences = evidenceReferences + self.notes = notes + self.status = status + } +} + +struct AuditChecklistItem: Codable, Sendable, Identifiable { + var id: String + var title: String + var isComplete: Bool + + static let defaults: [AuditChecklistItem] = [ + .init(id: "dns_review", title: "DNS review", isComplete: false), + .init(id: "certificate_review", title: "Certificate review", isComplete: false), + .init(id: "redirect_review", title: "Redirect review", isComplete: false), + .init(id: "header_review", title: "Header review", isComplete: false), + .init(id: "ownership_review", title: "Ownership review", isComplete: false), + .init(id: "infrastructure_review", title: "Infrastructure review", isComplete: false), + .init(id: "monitoring_history_review", title: "Monitoring history review", isComplete: false) + ] +} + +struct AuditSession: Codable, Sendable, Identifiable { + var id: UUID + var domain: String + var createdAt: Date + var reviewer: String + var status: AuditStatus + var evidence: AuditEvidenceSnapshot + var findings: [AuditFinding] + var notes: String + var checklist: [AuditChecklistItem] +} + +enum AuditExportFormat: String, CaseIterable, Identifiable { + case json + case markdown + case pdf + + var id: String { rawValue } +} + +@MainActor +@Observable +final class AuditStore { + private(set) var sessions: [AuditSession] = [] + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private let fileURL: URL + + init() { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + decoder.dateDecodingStrategy = .iso8601 + + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + let directory = base.appendingPathComponent("DomainDig", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + self.fileURL = directory.appendingPathComponent("audits.json") + load() + } + + func startAudit(domain: String, reviewer: String, source: DomainViewModel) { + let snapshot = AuditEvidenceSnapshot( + capturedAt: Date(), + dnsSections: source.dnsSections, + headers: source.httpHeaders, + tls: source.sslInfo, + redirects: source.redirectChain, + reachability: source.reachabilityResults, + ownership: source.ownershipResult, + ownershipHistory: source.ownershipHistory, + dnsHistory: source.dnsHistory, + screenshotPath: nil + ) + let session = AuditSession( + id: UUID(), + domain: domain, + createdAt: Date(), + reviewer: reviewer, + status: .draft, + evidence: snapshot, + findings: [], + notes: "", + checklist: AuditChecklistItem.defaults + ) + sessions.insert(session, at: 0) + persist() + } + + func update(session: AuditSession) { + guard let index = sessions.firstIndex(where: { $0.id == session.id }) else { return } + sessions[index] = session + persist() + } + + func sessions(for domain: String) -> [AuditSession] { + sessions.filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + .sorted { $0.createdAt > $1.createdAt } + } + + func export(session: AuditSession, format: AuditExportFormat) async throws -> URL { + let exportDir = fileURL.deletingLastPathComponent().appendingPathComponent("exports", isDirectory: true) + try FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true) + let stamp = ISO8601DateFormatter().string(from: session.createdAt).replacingOccurrences(of: ":", with: "-") + let base = "audit-\(session.domain)-\(stamp)" + switch format { + case .json: + let url = exportDir.appendingPathComponent("\(base).json") + try encoder.encode(session).write(to: url) + return url + case .markdown: + let url = exportDir.appendingPathComponent("\(base).md") + try markdown(for: session).write(to: url, atomically: true, encoding: .utf8) + return url + case .pdf: + let url = exportDir.appendingPathComponent("\(base).pdf") + try markdown(for: session).write(to: url, atomically: true, encoding: .utf8) + return url + } + } + + private func markdown(for session: AuditSession) -> String { + var lines: [String] = [] + lines.append("# Audit Summary") + lines.append("- Domain: \(session.domain)") + lines.append("- Review date: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))") + lines.append("- Reviewer: \(session.reviewer)") + lines.append("- Status: \(session.status.title)") + lines.append("\n## Findings") + for finding in session.findings { + lines.append("### \(finding.title) [\(finding.severity.rawValue)]") + lines.append(finding.summary) + if !finding.evidenceReferences.isEmpty { + lines.append("Evidence: \(finding.evidenceReferences.joined(separator: ", "))") + } + } + lines.append("\n## Notes") + lines.append(session.notes) + return lines.joined(separator: "\n") + } + + private func load() { + guard let data = try? Data(contentsOf: fileURL), let decoded = try? decoder.decode([AuditSession].self, from: data) else { return } + sessions = decoded.sorted { $0.createdAt > $1.createdAt } + } + + private func persist() { + guard let data = try? encoder.encode(sessions) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} diff --git a/DomainDig/AuditModeView.swift b/DomainDig/AuditModeView.swift new file mode 100644 index 0000000..fcd4083 --- /dev/null +++ b/DomainDig/AuditModeView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +struct AuditModeView: View { + @Bindable var viewModel: DomainViewModel + @State private var auditStore = AuditStore() + @State private var reviewer = "Local Reviewer" + @State private var selectedSession: AuditSession? + @State private var exportMessage: String? + + var body: some View { + List { + Section("Start Audit") { + TextField("Domain", text: $viewModel.domain) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + TextField("Reviewer", text: $reviewer) + Button("Start Audit") { + let domain = viewModel.domain.trimmingCharacters(in: .whitespacesAndNewlines) + guard !domain.isEmpty else { return } + auditStore.startAudit(domain: domain, reviewer: reviewer, source: viewModel) + } + .disabled(viewModel.domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + Section("Audit Timeline") { + if auditStore.sessions.isEmpty { + Text("No audits yet.") + .foregroundStyle(.secondary) + } else { + ForEach(auditStore.sessions) { session in + Button { + selectedSession = session + } label: { + VStack(alignment: .leading) { + Text(session.domain).font(.headline) + Text("\(session.createdAt.formatted(date: .abbreviated, time: .shortened)) • \(session.status.title)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + .navigationTitle("Audit Mode") + .sheet(item: $selectedSession) { session in + AuditSessionDetailView(session: session) { updated in + auditStore.update(session: updated) + selectedSession = updated + } onExport: { updated, format in + do { + let url = try await auditStore.export(session: updated, format: format) + exportMessage = "Exported to \(url.lastPathComponent)" + } catch { + exportMessage = "Export failed: \(error.localizedDescription)" + } + } + } + .alert("Audit Export", isPresented: Binding(get: { exportMessage != nil }, set: { if !$0 { exportMessage = nil } })) { + Button("OK", role: .cancel) {} + } message: { + Text(exportMessage ?? "") + } + } +} + +private struct AuditSessionDetailView: View { + @Environment(\.dismiss) private var dismiss + @State var session: AuditSession + let onSave: (AuditSession) -> Void + let onExport: (AuditSession, AuditExportFormat) async -> Void + + var body: some View { + NavigationStack { + List { + Section("Session") { + Text(session.domain) + Picker("Status", selection: $session.status) { + ForEach(AuditStatus.allCases, id: \.self) { Text($0.title).tag($0) } + } + TextField("Notes", text: $session.notes, axis: .vertical) + } + Section("Checklist") { + ForEach($session.checklist) { $item in + Toggle(item.title, isOn: $item.isComplete) + } + } + Section("Findings") { + ForEach($session.findings) { $finding in + VStack(alignment: .leading) { + TextField("Title", text: $finding.title) + Picker("Severity", selection: $finding.severity) { + ForEach(AuditFindingSeverity.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) } + } + TextField("Summary", text: $finding.summary, axis: .vertical) + } + } + Button("Add Finding") { + session.findings.append(.init(title: "New finding", severity: .informational, summary: "")) + } + } + Section("Export") { + ForEach(AuditExportFormat.allCases) { format in + Button("Export \(format.rawValue.uppercased())") { + Task { await onExport(session, format) } + } + } + } + } + .navigationTitle("Audit Session") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { dismiss() } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Save") { + onSave(session) + dismiss() + } + } + } + } + } +} diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index cba1062..524e8e9 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -4,6 +4,7 @@ private enum RootTab: Hashable { case dashboard case history case inspect + case audit case settings } @@ -38,6 +39,14 @@ struct RootTabView: View { } .tag(RootTab.inspect) + NavigationStack { + AuditModeView(viewModel: viewModel) + } + .tabItem { + Label("Audit", systemImage: "checklist") + } + .tag(RootTab.audit) + NavigationStack { SettingsView(viewModel: viewModel) } -- cgit v1.2.3