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/AppVersion.swift | 2 +- DomainDig/ContentView.swift | 24 ++ DomainDig/DomainDig/AuditExporter.swift | 157 +++++++++ DomainDig/DomainDig/AuditModels.swift | 279 ++++++++++++++++ DomainDig/DomainDig/AuditViews.swift | 571 ++++++++++++++++++++++++++++++++ DomainDig/DomainViewModel.swift | 193 +++++++++++ DomainDig/RootTabView.swift | 18 +- DomainDig/WatchlistView.swift | 28 ++ 8 files changed, 1262 insertions(+), 10 deletions(-) create mode 100644 DomainDig/DomainDig/AuditExporter.swift create mode 100644 DomainDig/DomainDig/AuditModels.swift create mode 100644 DomainDig/DomainDig/AuditViews.swift (limited to 'DomainDig') diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index cc675b0..07d3664 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { nonisolated static var current: String { - "3.5.0" + "4.4.1" } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 5a469e1..30d693e 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -44,6 +44,8 @@ struct ContentView: View { @State private var showingCurrentDomainWorkflowSheet = false @State private var showingBatchWorkflowSheet = false @State private var showingTimeline = false + @State private var showingAuditTimeline = false + @State private var auditStartInFlight = false var body: some View { let _ = purchaseService.currentTier @@ -274,6 +276,11 @@ struct ContentView: View { TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain) } } + .sheet(isPresented: $showingAuditTimeline) { + NavigationStack { + AuditDomainTimelineView(viewModel: viewModel, domain: viewModel.searchedDomain) + } + } } private var manualBatchSummaryBinding: Binding { @@ -508,6 +515,21 @@ struct ContentView: View { Button("Add to workflow") { showingCurrentDomainWorkflowSheet = true } + Button(auditStartInFlight ? "Starting audit…" : "Start audit") { + Task { + auditStartInFlight = true + if await viewModel.startAudit(for: viewModel.searchedDomain) != nil { + showingAuditTimeline = true + } + auditStartInFlight = false + } + } + .disabled(auditStartInFlight) + if !viewModel.audits(for: viewModel.searchedDomain).isEmpty { + Button("View audits") { + showingAuditTimeline = true + } + } if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty { Button("Open timeline") { showingTimeline = true @@ -3301,6 +3323,7 @@ private struct DataPortabilitySettingsView: View { Section("Local Data") { LabeledContent("Tracked Domains", value: "\(viewModel.dataLifecycleSummary.trackedDomains)") LabeledContent("History Snapshots", value: "\(viewModel.dataLifecycleSummary.historySnapshots)") + LabeledContent("Audit Sessions", value: "\(viewModel.dataLifecycleSummary.auditSessions)") LabeledContent("Workflows", value: "\(viewModel.dataLifecycleSummary.workflows)") LabeledContent("Cached Items", value: "\(viewModel.dataLifecycleSummary.cachedItems)") LabeledContent("Monitoring Logs", value: "\(viewModel.dataLifecycleSummary.monitoringLogs)") @@ -3702,6 +3725,7 @@ private struct DataImportPreviewSheet: View { Section("Projected Counts") { LabeledContent("Tracked Domains", value: "\(preview.projectedCounts.trackedDomains)") LabeledContent("History Snapshots", value: "\(preview.projectedCounts.historySnapshots)") + LabeledContent("Audit Sessions", value: "\(preview.projectedCounts.auditSessions)") LabeledContent("Workflows", value: "\(preview.projectedCounts.workflows)") LabeledContent("Cached Items", value: "\(preview.projectedCounts.cachedItems)") LabeledContent("Monitoring Logs", value: "\(preview.projectedCounts.monitoringLogs)") diff --git a/DomainDig/DomainDig/AuditExporter.swift b/DomainDig/DomainDig/AuditExporter.swift new file mode 100644 index 0000000..0d9a098 --- /dev/null +++ b/DomainDig/DomainDig/AuditExporter.swift @@ -0,0 +1,157 @@ +import Foundation + +#if canImport(UIKit) +import UIKit +#endif + +enum AuditExportFormat: String, CaseIterable, Identifiable { + case pdf + case json + case markdown + + var id: String { rawValue } + + var fileExtension: String { rawValue == "markdown" ? "md" : rawValue } +} + +enum AuditExporter { + static func data(for session: AuditSession, format: AuditExportFormat) throws -> Data { + switch format { + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(session) + case .markdown: + return Data(markdown(for: session).utf8) + case .pdf: + return try pdfData(for: session) + } + } + + static func markdown(for session: AuditSession) -> String { + var lines: [String] = [ + "# DomainDig Audit", + "", + "- Domain: \(session.domain)", + "- Review Date: \(session.createdAt.formatted(date: .abbreviated, time: .shortened))", + "- Reviewer: \(session.reviewer)", + "- Status: \(session.status.title)", + "- Evidence Captured: \(session.evidence.capturedAt.formatted(date: .abbreviated, time: .shortened))", + "- Checklist Progress: \(session.completedChecklistCount)/\(session.checklist.count)", + "" + ] + + if !session.notes.isEmpty { + lines.append("## Summary Notes") + lines.append(session.notes) + lines.append("") + } + + lines.append("## Findings Summary") + if session.findings.isEmpty { + lines.append("No findings recorded.") + } else { + for finding in session.findings { + lines.append("- [\(finding.severity.title)] \(finding.title) (\(finding.status.title))") + } + } + lines.append("") + + lines.append("## Key Risks") + if session.keyRisks.isEmpty { + lines.append("No medium or high-severity risks recorded.") + } else { + for finding in session.keyRisks { + lines.append("- \(finding.title): \(finding.summary)") + } + } + lines.append("") + + lines.append("## Checklist") + for item in session.checklist { + lines.append("- [\(item.isComplete ? "x" : " ")] \(item.title): \(item.detail)") + } + lines.append("") + + lines.append("## Findings") + if session.findings.isEmpty { + lines.append("No findings recorded.") + } else { + for finding in session.findings { + lines.append("### \(finding.title)") + lines.append("- Severity: \(finding.severity.title)") + lines.append("- Status: \(finding.status.title)") + if !finding.checklistAreas.isEmpty { + lines.append("- Checklist Areas: \(finding.checklistAreas.map(\.title).joined(separator: ", "))") + } + lines.append("- Summary: \(finding.summary)") + if !finding.evidenceReferences.isEmpty { + lines.append("- Evidence: \(finding.evidenceReferences.joined(separator: " | "))") + } + if !finding.notes.isEmpty { + lines.append("- Notes: \(finding.notes)") + } + lines.append("") + } + } + + lines.append("## Evidence Snapshot") + lines.append("- Availability: \(availabilityTitle(session.evidence.report.availability))") + lines.append("- Risk Score: \(session.evidence.report.riskAssessment.score) (\(session.evidence.report.riskAssessment.level.title))") + lines.append("- TLS Status: \(session.evidence.report.web.tlsStatus)") + lines.append("- Final URL: \(session.evidence.report.web.finalURL ?? "Unavailable")") + lines.append("- Primary IP: \(session.evidence.report.dns.primaryIP ?? "Unavailable")") + lines.append("- Reachability: \(session.evidence.report.network.reachabilitySummary)") + lines.append("- Ownership: \(session.evidence.report.ownership?.registrar ?? "Unavailable")") + lines.append("- Historical Context Snapshots: \(session.evidence.historicalContext.count)") + + return lines.joined(separator: "\n") + } + + private static func availabilityTitle(_ status: DomainAvailabilityStatus) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown: + return "Unknown" + } + } + + private static func pdfData(for session: AuditSession) throws -> Data { + let markdown = markdown(for: session) + #if canImport(UIKit) + let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792)) + return renderer.pdfData { context in + let lines = markdown.components(separatedBy: .newlines) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedSystemFont(ofSize: 11, weight: .regular), + .paragraphStyle: paragraphStyle + ] + + var yOffset: CGFloat = 36 + context.beginPage() + + for line in lines { + if yOffset > 744 { + context.beginPage() + yOffset = 36 + } + + let renderedLine = NSString(string: line.isEmpty ? " " : line) + renderedLine.draw( + in: CGRect(x: 36, y: yOffset, width: 540, height: 22), + withAttributes: attributes + ) + yOffset += 16 + } + } + #else + return Data(markdown.utf8) + #endif + } +} diff --git a/DomainDig/DomainDig/AuditModels.swift b/DomainDig/DomainDig/AuditModels.swift new file mode 100644 index 0000000..28eb9c9 --- /dev/null +++ b/DomainDig/DomainDig/AuditModels.swift @@ -0,0 +1,279 @@ +import Foundation + +enum AuditStatus: String, Codable, Sendable, CaseIterable, Identifiable { + case draft + case inReview = "in_review" + case complete + + var id: String { rawValue } + + var title: String { + switch self { + case .draft: + return "Draft" + case .inReview: + return "In Review" + case .complete: + return "Complete" + } + } +} + +enum AuditFindingSeverity: String, Codable, Sendable, CaseIterable, Identifiable { + case informational + case low + case medium + case high + + var id: String { rawValue } + + var title: String { rawValue.capitalized } +} + +enum AuditFindingStatus: String, Codable, Sendable, CaseIterable, Identifiable { + case open + case reviewing + case resolved + + var id: String { rawValue } + + var title: String { rawValue.capitalized } +} + +enum AuditChecklistArea: String, Codable, Sendable, CaseIterable, Identifiable { + case dnsReview + case certificateReview + case redirectReview + case headerReview + case ownershipReview + case infrastructureReview + case monitoringHistoryReview + + var id: String { rawValue } + + var title: String { + switch self { + case .dnsReview: + return "DNS Review" + case .certificateReview: + return "Certificate Review" + case .redirectReview: + return "Redirect Review" + case .headerReview: + return "Header Review" + case .ownershipReview: + return "Ownership Review" + case .infrastructureReview: + return "Infrastructure Review" + case .monitoringHistoryReview: + return "Monitoring History Review" + } + } + + var prompt: String { + switch self { + case .dnsReview: + return "Validate authoritative records, nameservers, and DNSSEC posture." + case .certificateReview: + return "Review certificate validity, expiry, issuer, and transport security." + case .redirectReview: + return "Confirm redirect targets, hop count, and protocol transitions." + case .headerReview: + return "Check security headers and HTTP response posture." + case .ownershipReview: + return "Review registration, registrar, and ownership evidence." + case .infrastructureReview: + return "Assess reachability, exposed services, and network context." + case .monitoringHistoryReview: + return "Compare this audit with prior snapshots and repeated issues." + } + } +} + +struct AuditChecklistItem: Identifiable, Codable, Sendable, Equatable { + let id: UUID + let area: AuditChecklistArea + var title: String + var detail: String + var isComplete: Bool + var completedAt: Date? + + init( + id: UUID = UUID(), + area: AuditChecklistArea, + title: String, + detail: String, + isComplete: Bool = false, + completedAt: Date? = nil + ) { + self.id = id + self.area = area + self.title = title + self.detail = detail + self.isComplete = isComplete + self.completedAt = completedAt + } +} + +enum AuditEvidenceAssetKind: String, Codable, Sendable { + case screenshot + case document + case note +} + +struct AuditEvidenceAsset: Identifiable, Codable, Sendable, Equatable { + let id: UUID + var title: String + var kind: AuditEvidenceAssetKind + var reference: String + + init(id: UUID = UUID(), title: String, kind: AuditEvidenceAssetKind, reference: String) { + self.id = id + self.title = title + self.kind = kind + self.reference = reference + } +} + +struct AuditEvidenceSnapshot: Codable { + var capturedAt: Date + var lookup: HistoryEntry + var report: DomainReport + var historicalContext: [SnapshotSummary] + var screenshots: [AuditEvidenceAsset] +} + +struct AuditFinding: Identifiable, Codable, Sendable, Equatable { + var id: UUID + var title: String + var severity: AuditFindingSeverity + var summary: String + var evidenceReferences: [String] + var notes: String + var status: AuditFindingStatus + var checklistAreas: [AuditChecklistArea] + var createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + title: String, + severity: AuditFindingSeverity, + summary: String, + evidenceReferences: [String], + notes: String, + status: AuditFindingStatus = .open, + checklistAreas: [AuditChecklistArea] = [], + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.title = title + self.severity = severity + self.summary = summary + self.evidenceReferences = evidenceReferences + self.notes = notes + self.status = status + self.checklistAreas = checklistAreas + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +struct AuditSession: Identifiable, Codable { + 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] + + init( + id: UUID = UUID(), + domain: String, + createdAt: Date = Date(), + reviewer: String, + status: AuditStatus, + evidence: AuditEvidenceSnapshot, + findings: [AuditFinding] = [], + notes: String = "", + checklist: [AuditChecklistItem] = AuditChecklistArea.defaultItems + ) { + self.id = id + self.domain = domain + self.createdAt = createdAt + self.reviewer = reviewer + self.status = status + self.evidence = evidence + self.findings = findings + self.notes = notes + self.checklist = checklist + } + + var completedChecklistCount: Int { + checklist.filter(\.isComplete).count + } + + var checklistProgress: Double { + guard !checklist.isEmpty else { return 0 } + return Double(completedChecklistCount) / Double(checklist.count) + } + + var highestSeverity: AuditFindingSeverity? { + findings.max { lhs, rhs in + lhs.severity.sortOrder < rhs.severity.sortOrder + }?.severity + } + + var keyRisks: [AuditFinding] { + findings + .filter { $0.severity == .high || $0.severity == .medium } + .sorted { lhs, rhs in + if lhs.severity.sortOrder == rhs.severity.sortOrder { + return lhs.updatedAt > rhs.updatedAt + } + return lhs.severity.sortOrder > rhs.severity.sortOrder + } + } +} + +extension AuditFindingSeverity { + var sortOrder: Int { + switch self { + case .informational: + return 0 + case .low: + return 1 + case .medium: + return 2 + case .high: + return 3 + } + } +} + +extension AuditChecklistArea { + static var defaultItems: [AuditChecklistItem] { + allCases.map { area in + AuditChecklistItem( + area: area, + title: area.title, + detail: area.prompt + ) + } + } +} + +struct AuditTimelinePoint: Identifiable, Equatable { + let id: UUID + let sessionID: UUID + let domain: String + let createdAt: Date + let status: AuditStatus + let findingCount: Int + let openHighSeverityCount: Int + let repeatedIssueCount: Int +} diff --git a/DomainDig/DomainDig/AuditViews.swift b/DomainDig/DomainDig/AuditViews.swift new file mode 100644 index 0000000..8602bb3 --- /dev/null +++ b/DomainDig/DomainDig/AuditViews.swift @@ -0,0 +1,571 @@ +import SwiftUI + +struct AuditListView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + @State private var auditStartInFlight = false + + private var groupedSessions: [(domain: String, sessions: [AuditSession])] { + Dictionary(grouping: viewModel.auditSessions) { $0.domain.lowercased() } + .values + .map { sessions in + let sorted = sessions.sorted { $0.createdAt > $1.createdAt } + return (domain: sorted.first?.domain ?? "unknown", sessions: sorted) + } + .sorted { $0.domain < $1.domain } + } + + var body: some View { + List { + if groupedSessions.isEmpty { + Section { + EmptyStateCardView( + title: "No Audits Yet", + message: "Audit Mode captures evidence, findings, checklist progress, and point-in-time review output for each domain assessment.", + suggestion: "Open Inspect for a domain and start an audit to create your first review session.", + systemImage: "checklist.unchecked", + showsCardBackground: false + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + Section("Audit Domains") { + ForEach(groupedSessions, id: \.domain) { group in + NavigationLink { + AuditDomainTimelineView(viewModel: viewModel, domain: group.domain) + } label: { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 2) { + HStack { + Text(group.domain) + .font(appDensity.font(.callout, design: .default, weight: .semibold)) + Spacer() + Text("\(group.sessions.count) audit\(group.sessions.count == 1 ? "" : "s")") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + + if let latest = group.sessions.first { + Text(latest.findings.isEmpty ? "No findings recorded yet" : latest.findings.map(\.title).prefix(2).joined(separator: " • ")) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack(spacing: 8) { + auditStatusBadge(latest.status) + if let severity = latest.highestSeverity { + findingSeverityBadge(severity) + } + Text("Checklist \(latest.completedChecklistCount)/\(latest.checklist.count)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + .padding(.vertical, 4) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Audit Mode") + .toolbar { + if !viewModel.searchedDomain.isEmpty { + ToolbarItem(placement: .topBarTrailing) { + Button(auditStartInFlight ? "Auditing…" : "Start Audit") { + Task { + auditStartInFlight = true + _ = await viewModel.startAudit(for: viewModel.searchedDomain) + auditStartInFlight = false + } + } + .disabled(auditStartInFlight) + } + } + } + .preferredColorScheme(.dark) + } +} + +struct AuditDomainTimelineView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + let domain: String + @State private var auditStartInFlight = false + + private var sessions: [AuditSession] { + viewModel.audits(for: domain) + } + + var body: some View { + List { + Section("Sessions") { + ForEach(sessions) { session in + NavigationLink { + AuditSessionDetailView(viewModel: viewModel, sessionID: session.id) + } label: { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 2) { + HStack { + Text(session.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.callout)) + Spacer() + auditStatusBadge(session.status) + } + + Text("Reviewer: \(session.reviewer)") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + Text("\(session.findings.count) findings") + Text("Checklist \(session.completedChecklistCount)/\(session.checklist.count)") + if let severity = session.highestSeverity { + Text("Top \(severity.title)") + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Trend") { + ForEach(viewModel.auditTimeline(for: domain)) { point in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(point.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption, weight: .semibold)) + Spacer() + Text("\(point.findingCount) findings") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + Text("Open high severity: \(point.openHighSeverityCount) • Repeated issues: \(point.repeatedIssueCount)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle(domain) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(auditStartInFlight ? "Auditing…" : "New Audit") { + Task { + auditStartInFlight = true + _ = await viewModel.startAudit(for: domain) + auditStartInFlight = false + } + } + .disabled(auditStartInFlight) + } + } + } +} + +struct AuditSessionDetailView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + let sessionID: UUID + + @State private var notesDraft = "" + @State private var showingFindingEditor = false + @State private var editingFinding: AuditFinding? + + private var session: AuditSession? { + viewModel.auditSession(withID: sessionID) + } + + var body: some View { + Group { + if let session { + List { + Section { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(session.domain) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) + Text("Reviewer: \(session.reviewer)") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + Spacer() + auditStatusBadge(session.status) + } + + Picker("Status", selection: Binding( + get: { session.status }, + set: { viewModel.updateAuditStatus($0, sessionID: session.id) } + )) { + ForEach(AuditStatus.allCases) { status in + Text(status.title).tag(status) + } + } + .pickerStyle(.segmented) + + Text("Captured \(session.evidence.capturedAt.formatted(date: .abbreviated, time: .shortened))") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Audit Summary") { + LabeledContent("Findings", value: "\(session.findings.count)") + LabeledContent("Checklist", value: "\(session.completedChecklistCount)/\(session.checklist.count)") + LabeledContent("Risk Score", value: "\(session.evidence.report.riskAssessment.score)") + if let severity = session.highestSeverity { + LabeledContent("Highest Severity", value: severity.title) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Reviewer Notes") { + TextEditor(text: $notesDraft) + .frame(minHeight: 120) + .scrollContentBackground(.hidden) + .background(Color.clear) + + Button("Save Notes") { + viewModel.updateAuditNotes(notesDraft, sessionID: session.id) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Checklist") { + ForEach(session.checklist) { item in + Button { + viewModel.toggleAuditChecklistItem(sessionID: session.id, itemID: item.id) + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: item.isComplete ? "checkmark.circle.fill" : "circle") + .foregroundStyle(item.isComplete ? Color.green : .secondary) + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Text(item.detail) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + .buttonStyle(.plain) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Findings") { + if session.findings.isEmpty { + Text("No findings recorded.") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } else { + ForEach(session.findings) { finding in + Button { + editingFinding = finding + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(finding.title) + .font(appDensity.font(.callout, design: .default, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + findingSeverityBadge(finding.severity) + } + Text(finding.summary) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + .lineLimit(2) + Text("\(finding.status.title) • \(finding.evidenceReferences.count) evidence refs") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + .buttonStyle(.plain) + } + .onDelete { offsets in + viewModel.removeAuditFindings(at: offsets, sessionID: session.id) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + Section("Evidence Snapshot") { + LabeledContent("Availability", value: availabilityTitle(session.evidence.report.availability)) + LabeledContent("Primary IP", value: session.evidence.report.dns.primaryIP ?? "Unavailable") + LabeledContent("TLS", value: session.evidence.report.web.tlsStatus) + LabeledContent("Final URL", value: session.evidence.report.web.finalURL ?? "Unavailable") + LabeledContent("Reachability", value: session.evidence.report.network.reachabilitySummary) + LabeledContent("Registrar", value: session.evidence.report.ownership?.registrar ?? "Unavailable") + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + + if !session.evidence.historicalContext.isEmpty { + Section("Historical Context") { + ForEach(session.evidence.historicalContext) { entry in + VStack(alignment: .leading, spacing: 4) { + Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption, weight: .semibold)) + Text(entry.changeSummaryMessage ?? "No change summary") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + + Section("Audit Timeline") { + ForEach(viewModel.auditTimeline(for: session.domain)) { point in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(point.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.caption, weight: .semibold)) + Spacer() + Text(point.status.title) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + Text("Findings \(point.findingCount) • Open high \(point.openHighSeverityCount) • Repeated \(point.repeatedIssueCount)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Audit Session") + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Add Finding") { + editingFinding = nil + showingFindingEditor = true + } + Menu("Export") { + ForEach(AuditExportFormat.allCases) { format in + Button("Export \(format.fileExtension.uppercased())") { + guard let data = viewModel.exportAuditData(sessionID: session.id, format: format) else { return } + ExportPresenter.share( + filename: "\(session.domain)-audit-\(session.createdAt.ISO8601Format()).\(format.fileExtension)", + data: data + ) + } + } + } + } + } + .onAppear { + notesDraft = session.notes + } + .onChange(of: session.notes) { _, newValue in + notesDraft = newValue + } + .sheet(isPresented: $showingFindingEditor) { + AuditFindingEditorView( + existingFinding: nil, + session: session + ) { draft in + viewModel.addAuditFinding( + sessionID: session.id, + title: draft.title, + severity: draft.severity, + summary: draft.summary, + evidenceReferences: draft.evidenceReferences, + notes: draft.notes, + checklistAreas: draft.checklistAreas + ) + } + } + .sheet(item: $editingFinding) { finding in + AuditFindingEditorView(existingFinding: finding, session: session) { updated in + viewModel.updateAuditFinding(updated, sessionID: session.id) + } + } + } else { + ContentUnavailableView("Audit Session Missing", systemImage: "exclamationmark.triangle") + .background(Color.black) + } + } + .preferredColorScheme(.dark) + } +} + +private struct AuditFindingEditorView: View { + @Environment(\.dismiss) private var dismiss + + let existingFinding: AuditFinding? + let session: AuditSession + let onSave: (AuditFinding) -> Void + + @State private var title: String + @State private var severity: AuditFindingSeverity + @State private var summary: String + @State private var evidenceReferencesText: String + @State private var notes: String + @State private var status: AuditFindingStatus + @State private var selectedAreas: Set + + init(existingFinding: AuditFinding?, session: AuditSession, onSave: @escaping (AuditFinding) -> Void) { + self.existingFinding = existingFinding + self.session = session + self.onSave = onSave + _title = State(initialValue: existingFinding?.title ?? "") + _severity = State(initialValue: existingFinding?.severity ?? .medium) + _summary = State(initialValue: existingFinding?.summary ?? "") + _evidenceReferencesText = State(initialValue: existingFinding?.evidenceReferences.joined(separator: "\n") ?? "") + _notes = State(initialValue: existingFinding?.notes ?? "") + _status = State(initialValue: existingFinding?.status ?? .open) + _selectedAreas = State(initialValue: Set(existingFinding?.checklistAreas ?? [])) + } + + var body: some View { + NavigationStack { + Form { + Section("Finding") { + TextField("Title", text: $title) + Picker("Severity", selection: $severity) { + ForEach(AuditFindingSeverity.allCases) { severity in + Text(severity.title).tag(severity) + } + } + Picker("Status", selection: $status) { + ForEach(AuditFindingStatus.allCases) { status in + Text(status.title).tag(status) + } + } + TextField("Summary", text: $summary, axis: .vertical) + .lineLimit(3...6) + } + + Section("Evidence References") { + TextField("One reference per line", text: $evidenceReferencesText, axis: .vertical) + .lineLimit(4...8) + if !session.evidence.screenshots.isEmpty { + ForEach(session.evidence.screenshots) { asset in + Text("\(asset.title): \(asset.reference)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + Section("Checklist Areas") { + ForEach(AuditChecklistArea.allCases) { area in + Button { + if selectedAreas.contains(area) { + selectedAreas.remove(area) + } else { + selectedAreas.insert(area) + } + } label: { + HStack { + Text(area.title) + Spacer() + Image(systemName: selectedAreas.contains(area) ? "checkmark.circle.fill" : "circle") + .foregroundStyle(selectedAreas.contains(area) ? Color.green : .secondary) + } + } + .buttonStyle(.plain) + } + } + + Section("Notes") { + TextField("Optional notes", text: $notes, axis: .vertical) + .lineLimit(4...8) + } + } + .navigationTitle(existingFinding == nil ? "New Finding" : "Edit Finding") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + onSave( + AuditFinding( + id: existingFinding?.id ?? UUID(), + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + severity: severity, + summary: summary.trimmingCharacters(in: .whitespacesAndNewlines), + evidenceReferences: evidenceReferencesText + .split(separator: "\n") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty }, + notes: notes.trimmingCharacters(in: .whitespacesAndNewlines), + status: status, + checklistAreas: Array(selectedAreas).sorted { $0.title < $1.title }, + createdAt: existingFinding?.createdAt ?? Date(), + updatedAt: Date() + ) + ) + dismiss() + } + .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + +private func auditStatusBadge(_ status: AuditStatus) -> some View { + let model: AppStatusBadgeModel + switch status { + case .draft: + model = .init(title: "Draft", systemImage: "square.and.pencil", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) + case .inReview: + model = .init(title: "In Review", systemImage: "doc.text.magnifyingglass", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16)) + case .complete: + model = .init(title: "Complete", systemImage: "checkmark.seal.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16)) + } + return AppStatusBadgeView(model: model) +} + +private func findingSeverityBadge(_ severity: AuditFindingSeverity) -> some View { + let color: Color + switch severity { + case .informational: + color = .secondary + case .low: + color = .green + case .medium: + color = .yellow + case .high: + color = .red + } + return AppStatusBadgeView( + model: .init( + title: severity.title, + systemImage: "exclamationmark.circle.fill", + foregroundColor: color, + backgroundColor: color.opacity(0.16) + ) + ) +} + +private func availabilityTitle(_ status: DomainAvailabilityStatus) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown: + return "Unknown" + } +} 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, diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index 524e8e9..f2c9f6c 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -2,9 +2,9 @@ import SwiftUI private enum RootTab: Hashable { case dashboard + case audit case history case inspect - case audit case settings } @@ -25,6 +25,14 @@ struct RootTabView: View { } .tag(RootTab.dashboard) + NavigationStack { + AuditListView(viewModel: viewModel) + } + .tabItem { + Label("Audit", systemImage: "checklist") + } + .tag(RootTab.audit) + NavigationStack { HistoryView(viewModel: viewModel) } @@ -39,14 +47,6 @@ struct RootTabView: View { } .tag(RootTab.inspect) - NavigationStack { - AuditModeView(viewModel: viewModel) - } - .tabItem { - Label("Audit", systemImage: "checklist") - } - .tag(RootTab.audit) - NavigationStack { SettingsView(viewModel: viewModel) } diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index d092f65..d91dd5d 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -476,6 +476,8 @@ struct TrackedDomainDetailView: View { @State private var isEditingNote = false @State private var showRerunOptions = false @State private var shareEntity: ShareableEntity? + @State private var showingAuditTimeline = false + @State private var auditStartInFlight = false private var liveTrackedDomain: TrackedDomain { viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain @@ -512,6 +514,27 @@ struct TrackedDomainDetailView: View { Label("Re-run Inspection", systemImage: "magnifyingglass") } + Button { + Task { + auditStartInFlight = true + if await viewModel.startAudit(for: liveTrackedDomain.domain) != nil { + showingAuditTimeline = true + } + auditStartInFlight = false + } + } label: { + Label(auditStartInFlight ? "Starting Audit…" : "Start Audit", systemImage: "checklist") + } + .disabled(auditStartInFlight) + + if !viewModel.audits(for: liveTrackedDomain.domain).isEmpty { + Button { + showingAuditTimeline = true + } label: { + Label("View Audits", systemImage: "clock.badge.checkmark") + } + } + Button { viewModel.togglePinned(for: liveTrackedDomain) } label: { @@ -651,5 +674,10 @@ struct TrackedDomainDetailView: View { .sheet(item: $shareEntity) { entity in CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain) } + .sheet(isPresented: $showingAuditTimeline) { + NavigationStack { + AuditDomainTimelineView(viewModel: viewModel, domain: liveTrackedDomain.domain) + } + } } } -- cgit v1.2.3