krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

1edf5bc83c0dcc07246908af341f37e2c871edd7

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-05-13T17:47:16Z

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(+)

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)
             }