krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.0: DomainDig/AuditMode.swift · raw

  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}