| @@ -0,0 +1,212 @@ |
| |
1 | import Foundation |
| |
2 | import SwiftUI |
| |
3 | |
| |
4 | enum 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 | |
| |
18 | enum AuditFindingSeverity: String, Codable, Sendable, CaseIterable { |
| |
19 | case informational |
| |
20 | case low |
| |
21 | case medium |
| |
22 | case high |
| |
23 | } |
| |
24 | |
| |
25 | struct 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 | |
| |
38 | struct 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 | |
| |
66 | struct 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 | |
| |
82 | struct 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 | |
| |
94 | enum 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 |
| |
104 | final 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 | } |