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