summaryrefslogtreecommitdiff
path: root/DomainDig/AuditMode.swift
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig/AuditMode.swift')
-rw-r--r--DomainDig/AuditMode.swift212
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)
+ }
+}