krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.3: 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}