aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-05-13 12:47:16 -0500
committerChristian Cleberg <[email protected]>2026-05-13 12:47:16 -0500
commit1edf5bc83c0dcc07246908af341f37e2c871edd7 (patch)
tree685256ef0a8e727349e21dc640b811f5bf9f454f
parent237a72a03102319638c5b0572ca3ab543238b821 (diff)
downloaddomain-dig-1edf5bc83c0dcc07246908af341f37e2c871edd7.tar.gz
domain-dig-1edf5bc83c0dcc07246908af341f37e2c871edd7.tar.bz2
domain-dig-1edf5bc83c0dcc07246908af341f37e2c871edd7.zip
Add v4.4 audit mode sessions and export workflow
-rw-r--r--DomainDig/AuditMode.swift212
-rw-r--r--DomainDig/AuditModeView.swift124
-rw-r--r--DomainDig/RootTabView.swift9
3 files changed, 345 insertions, 0 deletions
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
}
@@ -39,6 +40,14 @@ struct RootTabView: View {
.tag(RootTab.inspect)
NavigationStack {
+ AuditModeView(viewModel: viewModel)
+ }
+ .tabItem {
+ Label("Audit", systemImage: "checklist")
+ }
+ .tag(RootTab.audit)
+
+ NavigationStack {
SettingsView(viewModel: viewModel)
}
.tabItem {