diff options
Diffstat (limited to 'DomainDig/AuditMode.swift')
| -rw-r--r-- | DomainDig/AuditMode.swift | 212 |
1 files changed, 212 insertions, 0 deletions
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) + } +} |
