From d8195992e519343d6d8ff7fb17aa98358800d913 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 23:48:10 -0500 Subject: DomainDig v4.4.1: Consolidate Audit Mode and remove the CLI target - Make DomainDig/DomainDig/Audit* the single active Audit Mode implementation (models, views, exporter) with an Audit tab and session/export UI - Include audit sessions in backup/restore lifecycle counts, summaries, and merge behavior via DomainDataPortabilityService - Remove the DomainDigCLI target, source file, scheme, and all project references; keep the shared inspection/report pipeline for the app - Align AppVersion.current to 4.4.1 and refresh README/architecture docs - Add RELEASE_ROADMAP.md --- DomainDig/DomainViewModel.swift | 193 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) (limited to 'DomainDig/DomainViewModel.swift') diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 88e2ec6..f0c4411 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -297,6 +297,7 @@ final class DomainViewModel { private static let historyKey = "lookupHistory" private static let maxHistory = 250 var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries() + var auditSessions: [AuditSession] = DomainDataPortabilityService.loadAuditSessions() private static let workflowsKey = "domainWorkflows" var workflows: [DomainWorkflow] = DomainViewModel.loadWorkflows() var historySearchText = "" @@ -952,6 +953,7 @@ final class DomainViewModel { savedDomains = DomainDataPortabilityService.loadSavedDomains() trackedDomains = Self.loadTrackedDomains() history = Self.loadHistoryEntries() + auditSessions = DomainDataPortabilityService.loadAuditSessions() workflows = Self.loadWorkflows() monitoringSettings = MonitoringStorage.sanitizeSettings( MonitoringStorage.loadSettings(), @@ -970,6 +972,7 @@ final class DomainViewModel { savedDomains = [] trackedDomains = [] history = [] + auditSessions = [] workflows = [] historySearchText = "" historyDateFilter = .all @@ -1464,6 +1467,154 @@ final class DomainViewModel { return String(data: data, encoding: .utf8) } + func audits(for domain: String) -> [AuditSession] { + auditSessions + .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + .sorted { $0.createdAt > $1.createdAt } + } + + func auditSession(withID id: UUID) -> AuditSession? { + auditSessions.first(where: { $0.id == id }) + } + + func auditTimeline(for domain: String) -> [AuditTimelinePoint] { + let sessions = audits(for: domain).sorted { $0.createdAt > $1.createdAt } + return sessions.map { session in + let repeatedIssues = sessions + .filter { $0.id != session.id } + .flatMap(\.findings) + .map { $0.title.lowercased() } + let repeatedIssueCount = session.findings.filter { + repeatedIssues.contains($0.title.lowercased()) + }.count + + return AuditTimelinePoint( + id: session.id, + sessionID: session.id, + domain: session.domain, + createdAt: session.createdAt, + status: session.status, + findingCount: session.findings.count, + openHighSeverityCount: session.findings.filter { $0.severity == .high && $0.status != .resolved }.count, + repeatedIssueCount: repeatedIssueCount + ) + } + } + + @discardableResult + func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? { + let normalizedDomain = domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/").first? + .lowercased() ?? domain.lowercased() + guard !normalizedDomain.isEmpty else { return nil } + + let previous = historyEntries(for: normalizedDomain).first?.snapshot + let snapshot = await inspectionService.inspectSnapshot(domain: normalizedDomain, previousSnapshot: previous) + guard let entry = saveHistoryEntry( + from: snapshot, + replaceLatest: false, + updateCurrentState: searchedDomain.caseInsensitiveCompare(normalizedDomain) == .orderedSame + ) else { + return nil + } + + let report = report(for: entry) + let historicalContext = Array(historyEntries(for: normalizedDomain).dropFirst().prefix(6)).map(\.snapshotSummary) + let screenshots = snapshotEvidenceAssets(from: report) + let session = AuditSession( + domain: normalizedDomain, + createdAt: entry.timestamp, + reviewer: (reviewer?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty) ?? Self.defaultAuditReviewer, + status: .draft, + evidence: AuditEvidenceSnapshot( + capturedAt: entry.timestamp, + lookup: entry, + report: report, + historicalContext: historicalContext, + screenshots: screenshots + ), + findings: [], + notes: "", + checklist: AuditChecklistArea.defaultItems + ) + + auditSessions.insert(session, at: 0) + persistAuditSessions() + return session + } + + func updateAuditStatus(_ status: AuditStatus, sessionID: UUID) { + guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } + auditSessions[index].status = status + persistAuditSessions() + } + + func updateAuditNotes(_ notes: String, sessionID: UUID) { + guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } + auditSessions[index].notes = notes.trimmingCharacters(in: .whitespacesAndNewlines) + persistAuditSessions() + } + + func toggleAuditChecklistItem(sessionID: UUID, itemID: UUID) { + guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }), + let itemIndex = auditSessions[sessionIndex].checklist.firstIndex(where: { $0.id == itemID }) else { + return + } + + auditSessions[sessionIndex].checklist[itemIndex].isComplete.toggle() + auditSessions[sessionIndex].checklist[itemIndex].completedAt = auditSessions[sessionIndex].checklist[itemIndex].isComplete ? Date() : nil + persistAuditSessions() + } + + func addAuditFinding( + sessionID: UUID, + title: String, + severity: AuditFindingSeverity, + summary: String, + evidenceReferences: [String], + notes: String, + checklistAreas: [AuditChecklistArea] + ) { + guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } + let finding = AuditFinding( + title: title, + severity: severity, + summary: summary, + evidenceReferences: evidenceReferences, + notes: notes, + status: .open, + checklistAreas: checklistAreas + ) + auditSessions[index].findings.insert(finding, at: 0) + persistAuditSessions() + } + + func updateAuditFinding(_ finding: AuditFinding, sessionID: UUID) { + guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }), + let findingIndex = auditSessions[sessionIndex].findings.firstIndex(where: { $0.id == finding.id }) else { + return + } + + var updatedFinding = finding + updatedFinding.updatedAt = Date() + auditSessions[sessionIndex].findings[findingIndex] = updatedFinding + persistAuditSessions() + } + + func removeAuditFindings(at offsets: IndexSet, sessionID: UUID) { + guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } + auditSessions[sessionIndex].findings.remove(atOffsets: offsets) + persistAuditSessions() + } + + func exportAuditData(sessionID: UUID, format: AuditExportFormat) -> Data? { + guard let session = auditSession(withID: sessionID) else { return nil } + return try? AuditExporter.data(for: session, format: format) + } + func loadOwnershipHistory() async { guard !searchedDomain.isEmpty else { return } guard DataAccessService.hasAccess(to: .ownershipHistory) else { @@ -2405,6 +2556,11 @@ final class DomainViewModel { DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)") } + private func persistAuditSessions() { + DomainDataPortabilityService.saveAuditSessions(auditSessions) + refreshDataLifecycleSummary() + } + func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) { historyAutoPruneOption = option UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey) @@ -3565,6 +3721,43 @@ final class DomainViewModel { ) } + private static var defaultAuditReviewer: String { + let reviewer = NSFullUserName().trimmingCharacters(in: .whitespacesAndNewlines) + return reviewer.isEmpty ? "Local Reviewer" : reviewer + } + + private func snapshotEvidenceAssets(from report: DomainReport) -> [AuditEvidenceAsset] { + var assets: [AuditEvidenceAsset] = [] + if let finalURL = report.web.finalURL { + assets.append(AuditEvidenceAsset(title: "Final URL", kind: .document, reference: finalURL)) + } + if let registrar = report.ownership?.registrar { + assets.append(AuditEvidenceAsset(title: "Registrar", kind: .document, reference: registrar)) + } + if let primaryIP = report.dns.primaryIP { + assets.append(AuditEvidenceAsset(title: "Primary IP", kind: .document, reference: primaryIP)) + } + if !report.web.redirectChain.isEmpty { + assets.append( + AuditEvidenceAsset( + title: "Redirect Chain", + kind: .document, + reference: report.web.redirectChain.map { "\($0.statusCode) \($0.url)" }.joined(separator: " | ") + ) + ) + } + if !report.web.headers.isEmpty { + assets.append( + AuditEvidenceAsset( + title: "Observed Headers", + kind: .document, + reference: report.web.headers.prefix(6).map { "\($0.name): \($0.value)" }.joined(separator: " | ") + ) + ) + } + return assets + } + private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { LookupSnapshot( historyEntryID: trackedDomain.lastSnapshotID, -- cgit v1.2.3