diff options
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 8 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist | 4 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 49 | ||||
| -rw-r--r-- | DomainDig/DiffService.swift | 563 | ||||
| -rw-r--r-- | DomainDig/DomainDiffService.swift | 555 | ||||
| -rw-r--r-- | DomainDig/DomainDig/DomainDebugLog.swift | 37 | ||||
| -rw-r--r-- | DomainDig/DomainMonitoringService.swift | 4 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 321 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 85 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 114 | ||||
| -rw-r--r-- | DomainDig/PortScanService.swift | 2 | ||||
| -rw-r--r-- | DomainDig/RDAPService.swift | 10 | ||||
| -rw-r--r-- | DomainDig/ReachabilityService.swift | 2 | ||||
| -rw-r--r-- | DomainDig/SubdomainDiscoveryService.swift | 11 | ||||
| -rw-r--r-- | DomainDig/TimelineView.swift | 224 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 3 | ||||
| -rw-r--r-- | DomainDigCLI.swift | 117 | ||||
| -rw-r--r-- | DomainInspectionService.swift | 103 | ||||
| -rw-r--r-- | DomainReportBuilder.swift | 119 | ||||
| -rw-r--r-- | DomainReportExporter.swift | 56 | ||||
| -rw-r--r-- | LookupSnapshot.swift | 8 |
21 files changed, 1701 insertions, 694 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 1ccc6bc..3b1e311 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -366,7 +366,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -383,7 +383,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.6.0; + MARKETING_VERSION = 3.7.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -403,7 +403,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -420,7 +420,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.6.0; + MARKETING_VERSION = 3.7.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist index 9e08675..06d0111 100644 --- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ <key>DomainDig.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> - <integer>0</integer> + <integer>1</integer> </dict> <key>DomainDigCLI.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> - <integer>1</integer> + <integer>0</integer> </dict> </dict> </dict> diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 0386211..76817cd 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -37,6 +37,7 @@ struct ContentView: View { @State private var collapsedSections: Set<ResultSection> = [.network] @State private var showingCurrentDomainWorkflowSheet = false @State private var showingBatchWorkflowSheet = false + @State private var showingTimeline = false var body: some View { let _ = purchaseService.currentTier @@ -83,7 +84,8 @@ struct ContentView: View { title: "Latest Changes", sections: viewModel.currentDiffSections, contextNote: viewModel.currentChangeSummary?.contextNote, - showsUnchanged: false + showsUnchanged: false, + highlightedSectionID: nil ) .padding(.top, appDensity.metrics.sectionSpacing) } @@ -230,6 +232,11 @@ struct ContentView: View { availableDomains: viewModel.batchResults.map(\.domain) ) } + .sheet(isPresented: $showingTimeline) { + NavigationStack { + TimelineView(viewModel: viewModel, domain: viewModel.searchedDomain) + } + } } private var inputSection: some View { @@ -439,6 +446,11 @@ struct ContentView: View { Button("Add to workflow") { showingCurrentDomainWorkflowSheet = true } + if !viewModel.historyEntries(for: viewModel.searchedDomain).isEmpty { + Button("Open timeline") { + showingTimeline = true + } + } if FeatureAccessService.hasAccess(to: .advancedExports) { Button("Copy report JSON") { guard let json = viewModel.exportJSONString() else { return } @@ -1048,8 +1060,9 @@ struct DomainDiffView: View { let sections: [DomainDiffSection] let contextNote: String? let showsUnchanged: Bool + let highlightedSectionID: String? - @State private var collapsedSections = Set<UUID>() + @State private var collapsedSections = Set<String>() @State private var showsLowSeverity = false private var filteredSections: [DomainDiffSection] { @@ -1064,7 +1077,7 @@ struct DomainDiffView: View { } return item.severity >= .medium || (showsUnchanged && item.changeType == .unchanged) } - return DomainDiffSection(title: section.title, items: items) + return DomainDiffSection(id: section.id, title: section.title, items: items) } .filter { !$0.items.isEmpty } } @@ -1104,7 +1117,7 @@ struct DomainDiffView: View { .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) Spacer() - Text("\(item.severity.title) • \(changeLabel(for: item.changeType))") + Text("\(item.changeType.marker) \(item.severity.title) • \(changeLabel(for: item.changeType))") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(changeColor(for: item)) .padding(.horizontal, 8) @@ -1154,9 +1167,19 @@ struct DomainDiffView: View { } } } + .id(section.id) + .overlay { + if highlightedSectionID == section.id { + RoundedRectangle(cornerRadius: 12) + .stroke(Color.cyan.opacity(0.55), lineWidth: 1) + } + } } } } + .onAppear { + collapsedSections = Set(sections.filter { !showsUnchanged && !$0.hasChanges }.map(\.id)) + } } private func changeLabel(for changeType: DiffChangeType) -> String { @@ -2460,6 +2483,24 @@ struct SettingsView: View { } } + Section("History") { + Picker( + "Auto-prune", + selection: Binding( + get: { viewModel.historyAutoPruneOption }, + set: { viewModel.setHistoryAutoPruneOption($0) } + ) + ) { + ForEach(HistoryAutoPruneOption.allCases) { option in + Text(option.title).tag(option) + } + } + + Text("History remains local-first. Auto-prune only trims older local snapshots on this device and defaults to unlimited.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + Section("Network") { Picker("Resolver", selection: $resolverOption) { ForEach(DNSResolverOption.allCases) { option in diff --git a/DomainDig/DiffService.swift b/DomainDig/DiffService.swift new file mode 100644 index 0000000..948f470 --- /dev/null +++ b/DomainDig/DiffService.swift @@ -0,0 +1,563 @@ +import Foundation + +enum DiffChangeType: String, Codable { + case added + case removed + case changed + case unchanged + + var marker: String { + switch self { + case .added: + return "+" + case .removed: + return "-" + case .changed: + return "~" + case .unchanged: + return "=" + } + } + + var title: String { + switch self { + case .added: + return "Added" + case .removed: + return "Removed" + case .changed: + return "Changed" + case .unchanged: + return "Unchanged" + } + } +} + +struct DiffItem: Identifiable, Equatable, Codable { + let id: String + let label: String + let changeType: DiffChangeType + let oldValue: String? + let newValue: String? + let severity: ChangeSeverity + + init( + id: String, + label: String, + changeType: DiffChangeType, + oldValue: String?, + newValue: String?, + severity: ChangeSeverity + ) { + self.id = id + self.label = label + self.changeType = changeType + self.oldValue = oldValue + self.newValue = newValue + self.severity = severity + } + + var hasChanges: Bool { + changeType != .unchanged + } +} + +struct DiffSection: Identifiable, Equatable, Codable { + let id: String + let title: String + let items: [DiffItem] + + var hasChanges: Bool { + items.contains(where: \.hasChanges) + } + + var severity: ChangeSeverity { + items.map(\.severity).max() ?? .low + } + + var changeCount: Int { + items.filter(\.hasChanges).count + } +} + +struct DomainDiff: Identifiable, Equatable, Codable { + let domain: String + let fromTimestamp: Date + let toTimestamp: Date + let sections: [DiffSection] + let changedSectionIDs: [String] + let changedSectionTitles: [String] + let contextNote: String? + + var id: String { + "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)" + } + + var changeCount: Int { + sections.reduce(0) { $0 + $1.changeCount } + } + + var severity: ChangeSeverity { + sections.map(\.severity).max() ?? .low + } +} + +typealias DomainDiffItem = DiffItem +typealias DomainDiffSection = DiffSection + +enum DiffService { + static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff { + let sections = [ + availabilitySection(from: oldReport, to: newReport), + ownershipSection(from: oldReport, to: newReport), + dnsSection(from: oldReport, to: newReport), + webSection(from: oldReport, to: newReport), + emailSection(from: oldReport, to: newReport), + networkSection(from: oldReport, to: newReport), + subdomainsSection(from: oldReport, to: newReport), + riskSection(from: oldReport, to: newReport) + ] + + let changedSections = sections.filter(\.hasChanges) + return DomainDiff( + domain: newReport.domain, + fromTimestamp: oldReport.timestamp, + toTimestamp: newReport.timestamp, + sections: sections, + changedSectionIDs: changedSections.map(\.id), + changedSectionTitles: changedSections.map(\.title), + contextNote: comparisonContextNote(from: oldReport, to: newReport) + ) + } + + static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff { + let builder = DomainReportBuilder() + let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false) + let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false) + return compare(from: oldReport, to: newReport) + } + + static func summary( + from oldSnapshot: LookupSnapshot, + to newSnapshot: LookupSnapshot, + generatedAt: Date = Date(), + riskAssessment: DomainRiskAssessment? = nil, + insights: [String]? = nil + ) -> DomainChangeSummary { + let diff = compare(from: oldSnapshot, to: newSnapshot) + let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges) + let highlights = diff.changedSectionTitles + let severity = changedItems.map(\.severity).max() ?? .low + let message = summaryMessage(from: highlights, changeCount: changedItems.count) + let observedFacts = changedItems.prefix(4).map { item in + "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")" + } + + let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot) + let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment + let currentInsights = insights ?? analysis.insights + let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score + let riskScoreDelta = currentRiskAssessment.score - previousRiskScore + let impactClassification = DomainInsightEngine.impactClassification( + severity: severity, + riskDelta: riskScoreDelta, + changedSections: highlights + ) + + return DomainChangeSummary( + hasChanges: !changedItems.isEmpty, + changedSections: highlights, + message: message, + severity: severity, + impactClassification: impactClassification, + generatedAt: generatedAt, + observedFacts: observedFacts, + inferredConclusions: highlights.isEmpty ? [] : [message], + contextNote: diff.contextNote, + riskAssessment: currentRiskAssessment, + insights: currentInsights, + riskScoreDelta: riskScoreDelta + ) + } + + static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? { + var notes: [String] = [] + if oldReport.resolverURLString != newReport.resolverURLString { + notes.append("Compared snapshots used different DNS resolvers.") + } + if oldReport.resultSource != newReport.resultSource { + notes.append("Compared snapshots came from different collection modes.") + } + return notes.isEmpty ? nil : notes.joined(separator: " ") + } + + static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { + comparisonContextNote( + from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false), + to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false) + ) + } + + static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { + guard let days = snapshot.sslInfo?.daysUntilExpiry else { + return .none + } + if days < 14 { + return .critical + } + if days < 30 { + return .warning + } + return .none + } + + private static func availabilitySection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "availability", + title: "Domain / Availability", + items: [ + compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low), + compare( + id: "availability", + label: "Availability", + oldValue: availabilityLabel(oldReport.availability), + newValue: availabilityLabel(newReport.availability), + severity: .high + ), + compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high), + compare( + id: "tls-status", + label: "TLS Status", + oldValue: oldReport.web.tlsStatus, + newValue: newReport.web.tlsStatus, + severity: .medium + ) + ].compactMap { $0 } + ) + } + + private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "ownership", + title: "Ownership", + items: [ + compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high), + compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium), + compare( + id: "ownership-created", + label: "Registration Date", + oldValue: ownershipDateLabel(oldReport.ownership?.createdDate), + newValue: ownershipDateLabel(newReport.ownership?.createdDate), + severity: .low + ), + compare( + id: "ownership-expires", + label: "Expiration Date", + oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate), + newValue: ownershipDateLabel(newReport.ownership?.expirationDate), + severity: .medium + ), + compare( + id: "ownership-status", + label: "Status", + oldValue: joined(oldReport.ownership?.status), + newValue: joined(newReport.ownership?.status), + severity: .low + ), + compare( + id: "ownership-nameservers", + label: "Nameservers", + oldValue: joined(oldReport.ownership?.nameservers), + newValue: joined(newReport.ownership?.nameservers), + severity: .medium + ), + compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low) + ].compactMap { $0 } + ) + } + + private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) }) + let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) }) + let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue } + + var items: [DiffItem] = [ + compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium), + compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low) + ].compactMap { $0 } + + for type in recordTypes { + items.append( + compare( + id: "dns-\(type.rawValue.lowercased())-records", + label: "\(type.rawValue) Records", + oldValue: normalizedRecordValues(for: oldSections[type]), + newValue: normalizedRecordValues(for: newSections[type]), + severity: type == .A || type == .NS ? .high : .medium + ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low) + ) + if let ttlChange = compare( + id: "dns-\(type.rawValue.lowercased())-ttl", + label: "\(type.rawValue) TTL", + oldValue: normalizedTTLValues(for: oldSections[type]), + newValue: normalizedTTLValues(for: newSections[type]), + severity: .low + ) { + items.append(ttlChange) + } + } + + return DiffSection( + id: "dns", + title: "DNS", + items: items.filter { !$0.id.isEmpty } + ) + } + + private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "web", + title: "Web", + items: [ + compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium), + compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium), + compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high), + compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium), + compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium), + compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low), + compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium) + ].compactMap { $0 } + ) + } + + private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "email", + title: "Email Security", + items: [ + compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium), + compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium), + compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium), + compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high), + compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium), + compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low), + compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium) + ].compactMap { $0 } + ) + } + + private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "network", + title: "Network", + items: [ + compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium), + compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium), + compare(id: "network-open-ports", label: "Open Ports", oldValue: joined(oldReport.network.openPorts.map(String.init)), newValue: joined(newReport.network.openPorts.map(String.init)), severity: .high), + compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium) + ].compactMap { $0 } + ) + } + + private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "subdomains", + title: "Subdomains", + items: [ + compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low), + compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low), + compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low) + ].compactMap { $0 } + ) + } + + private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "risk", + title: "Risk / Insights", + items: [ + compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high), + compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high), + compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium), + compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium) + ].compactMap { $0 } + ) + } + + private static func compare( + id: String, + label: String, + oldValue: String?, + newValue: String?, + severity: ChangeSeverity + ) -> DiffItem? { + let oldValue = normalized(oldValue) + let newValue = normalized(newValue) + + guard oldValue != nil || newValue != nil else { + return nil + } + + let changeType: DiffChangeType + switch (oldValue?.lowercased(), newValue?.lowercased()) { + case let (old?, new?) where old == new: + changeType = .unchanged + case (nil, _?): + changeType = .added + case (_?, nil): + changeType = .removed + default: + changeType = .changed + } + + return DiffItem( + id: id, + label: label, + changeType: changeType, + oldValue: oldValue, + newValue: newValue, + severity: severity + ) + } + + static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String { + guard !sectionTitles.isEmpty else { + return "No meaningful changes" + } + if sectionTitles.count == 1 { + return "\(sectionTitles[0]) changed" + } + return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)" + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown: + return "Unknown" + } + } + + private static func ownershipDateLabel(_ date: Date?) -> String? { + date?.formatted(date: .abbreviated, time: .omitted) + } + + private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? { + guard let certificate else { return nil } + return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)" + } + + private static func joined(_ values: [String]?) -> String? { + guard let values else { return nil } + let normalizedValues = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .sorted() + return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ", ") + } + + private static func normalizedRecordValues(for section: DNSSection?) -> String? { + guard let section else { return nil } + let values = (section.records + section.wildcardRecords) + .map(\.value) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .sorted() + return values.isEmpty ? nil : values.joined(separator: ", ") + } + + private static func normalizedTTLValues(for section: DNSSection?) -> String? { + guard let section else { return nil } + let values = (section.records + section.wildcardRecords) + .map { "\($0.value.lowercased()):\($0.ttl)" } + .sorted() + return values.isEmpty ? nil : values.joined(separator: ", ") + } + + private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? { + let values = headers + .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" } + .sorted() + return values.isEmpty ? nil : values.joined(separator: " | ") + } + + private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? { + let values = redirects.map { "\($0.statusCode) \($0.url)" } + return values.isEmpty ? nil : values.joined(separator: " -> ") + } + + private static func portScanSummary(_ results: [PortScanResult]) -> String? { + let values = results + .sorted { $0.port < $1.port } + .map { "\($0.port):\($0.open ? "open" : "closed")" } + return values.isEmpty ? nil : values.joined(separator: ", ") + } + + private static func groupSummary(_ groups: [SubdomainGroup]) -> String? { + joined(groups.map { "\($0.label): \($0.subdomains.count)" }) + } + + private static func recordLabel(_ record: EmailSecurityRecord?) -> String? { + guard let record else { return nil } + if record.found { + return record.value ?? "Present" + } + return "Missing" + } + + private static func mtaStsLabel(_ result: MTASTSResult?) -> String? { + guard let result else { return nil } + guard result.txtFound else { return "Missing" } + return result.policyMode ?? "Present" + } + + private static func dnssecLabel(_ value: Bool?) -> String? { + switch value { + case true: + return "Signed" + case false: + return "Unsigned" + case nil: + return nil + } + } +} + +enum DomainDiffService { + static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { + DiffService.compare(from: oldSnapshot, to: newSnapshot).sections + } + + static func summary( + from oldSnapshot: LookupSnapshot, + to newSnapshot: LookupSnapshot, + generatedAt: Date = Date(), + riskAssessment: DomainRiskAssessment? = nil, + insights: [String]? = nil + ) -> DomainChangeSummary { + DiffService.summary( + from: oldSnapshot, + to: newSnapshot, + generatedAt: generatedAt, + riskAssessment: riskAssessment, + insights: insights + ) + } + + static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { + DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot) + } + + static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { + DiffService.certificateWarningLevel(for: snapshot) + } +} diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift deleted file mode 100644 index 0d8795a..0000000 --- a/DomainDig/DomainDiffService.swift +++ /dev/null @@ -1,555 +0,0 @@ -import Foundation - -enum DiffChangeType: String, Codable { - case added - case removed - case changed - case unchanged -} - -struct DomainDiffItem: Identifiable, Equatable { - let id = UUID() - let label: String - let changeType: DiffChangeType - let oldValue: String? - let newValue: String? - let severity: ChangeSeverity - - var hasChanges: Bool { - changeType != .unchanged - } - - var isMeaningful: Bool { - hasChanges && severity >= .medium - } -} - -struct DomainDiffSection: Identifiable, Equatable { - let id = UUID() - let title: String - let items: [DomainDiffItem] - - var hasChanges: Bool { - items.contains(where: \.hasChanges) - } - - var severity: ChangeSeverity { - items.map(\.severity).max() ?? .low - } -} - -enum DomainDiffService { - static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { - [ - availabilitySection(from: oldSnapshot, to: newSnapshot), - primaryIPSection(from: oldSnapshot, to: newSnapshot), - ownershipSection(from: oldSnapshot, to: newSnapshot), - dnsSection(from: oldSnapshot, to: newSnapshot), - redirectSection(from: oldSnapshot, to: newSnapshot), - tlsSection(from: oldSnapshot, to: newSnapshot), - httpSection(from: oldSnapshot, to: newSnapshot), - emailSection(from: oldSnapshot, to: newSnapshot), - subdomainSection(from: oldSnapshot, to: newSnapshot) - ] - .filter { !$0.items.isEmpty } - } - - static func summary( - from oldSnapshot: LookupSnapshot, - to newSnapshot: LookupSnapshot, - generatedAt: Date = Date(), - riskAssessment: DomainRiskAssessment? = nil, - insights: [String]? = nil - ) -> DomainChangeSummary { - let sections = diff(from: oldSnapshot, to: newSnapshot) - let allChangedItems = sections - .flatMap(\.items) - .filter(\.hasChanges) - - let highlights = summaryHighlights(from: allChangedItems) - let severity = allChangedItems.map(\.severity).max() ?? .low - let message = summaryMessage(from: allChangedItems, highlights: highlights) - let observedFacts = observedFacts(from: allChangedItems) - let inferredConclusions = highlights.isEmpty ? [] : [message] - let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot) - let newAnalysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot) - let currentRiskAssessment = riskAssessment ?? newAnalysis.riskAssessment - let currentInsights = insights ?? newAnalysis.insights - let oldRiskAssessment = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment - let riskScoreDelta = currentRiskAssessment.score - oldRiskAssessment.score - let impactClassification = DomainInsightEngine.impactClassification( - severity: severity, - riskDelta: riskScoreDelta, - changedSections: highlights - ) - - return DomainChangeSummary( - hasChanges: !allChangedItems.isEmpty, - changedSections: highlights, - message: message, - severity: severity, - impactClassification: impactClassification, - generatedAt: generatedAt, - observedFacts: observedFacts, - inferredConclusions: inferredConclusions, - contextNote: contextNote, - riskAssessment: currentRiskAssessment, - insights: currentInsights, - riskScoreDelta: riskScoreDelta - ) - } - - static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { - var notes: [String] = [] - if oldSnapshot.resolverURLString != newSnapshot.resolverURLString { - notes.append("Compared snapshots used different DNS resolvers.") - } - if oldSnapshot.resultSource != newSnapshot.resultSource { - notes.append("Compared snapshots came from different collection modes.") - } - return notes.isEmpty ? nil : notes.joined(separator: " ") - } - - static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { - guard let days = snapshot.sslInfo?.daysUntilExpiry else { - return .none - } - if days < 14 { - return .critical - } - if days < 30 { - return .warning - } - return .none - } - - private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - DomainDiffSection( - title: "Availability", - items: [ - compare( - label: "Availability", - oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status), - newValue: availabilityLabel(newSnapshot.availabilityResult?.status), - severity: .high - ) - ].compactMap { $0 } - ) - } - - private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - DomainDiffSection( - title: "Primary IP", - items: [ - compare( - label: "Primary IP", - oldValue: primaryIP(from: oldSnapshot), - newValue: primaryIP(from: newSnapshot), - severity: .high - ) - ].compactMap { $0 } - ) - } - - private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) }) - let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) }) - let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue } - - var items: [DomainDiffItem] = [] - for type in types { - let oldSection = oldSections[type] - let newSection = newSections[type] - - if let recordChange = compare( - label: "\(type.rawValue) Records", - oldValue: normalizedRecordValues(for: oldSection), - newValue: normalizedRecordValues(for: newSection), - severity: .medium - ) { - items.append(recordChange) - } - - if let ttlChange = compare( - label: "\(type.rawValue) TTL", - oldValue: normalizedTTLValues(for: oldSection), - newValue: normalizedTTLValues(for: newSection), - severity: .low - ), let oldSection, let newSection, - normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) { - items.append(ttlChange) - } - } - - return DomainDiffSection(title: "DNS", items: items) - } - - private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - DomainDiffSection( - title: "Ownership", - items: [ - compare( - label: "Registrar", - oldValue: normalized(oldSnapshot.ownership?.registrar), - newValue: normalized(newSnapshot.ownership?.registrar), - severity: .high - ), - compare( - label: "Registration Date", - oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate), - newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate), - severity: .low - ), - compare( - label: "Expiration Date", - oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate), - newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate), - severity: .low - ), - compare( - label: "Ownership Status", - oldValue: ownershipList(oldSnapshot.ownership?.status), - newValue: ownershipList(newSnapshot.ownership?.status), - severity: .low - ), - compare( - label: "Nameservers", - oldValue: ownershipList(oldSnapshot.ownership?.nameservers), - newValue: ownershipList(newSnapshot.ownership?.nameservers), - severity: .medium - ), - compare( - label: "Abuse Contact", - oldValue: normalized(oldSnapshot.ownership?.abuseEmail), - newValue: normalized(newSnapshot.ownership?.abuseEmail), - severity: .low - ) - ].compactMap { $0 } - ) - } - - private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - DomainDiffSection( - title: "Redirect", - items: [ - compare( - label: "Redirect Target", - oldValue: finalRedirectURL(from: oldSnapshot), - newValue: finalRedirectURL(from: newSnapshot), - severity: .high - ) - ].compactMap { $0 } - ) - } - - private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - var items: [DomainDiffItem] = [] - - if let issuerChange = compare( - label: "TLS Issuer", - oldValue: normalized(oldSnapshot.sslInfo?.issuer), - newValue: normalized(newSnapshot.sslInfo?.issuer), - severity: .medium - ) { - items.append(issuerChange) - } - - if let expiryChange = compare( - label: "TLS Expiration", - oldValue: expirationLabel(oldSnapshot.sslInfo), - newValue: expirationLabel(newSnapshot.sslInfo), - severity: .medium - ) { - items.append(expiryChange) - } - - let oldWarning = certificateWarningLevel(for: oldSnapshot) - let newWarning = certificateWarningLevel(for: newSnapshot) - if oldWarning != newWarning, newWarning != .none { - let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0 - items.append( - DomainDiffItem( - label: "Certificate Warning", - changeType: .changed, - oldValue: oldWarning.title, - newValue: "Certificate expires in \(days) days", - severity: newWarning == .critical ? .high : .medium - ) - ) - } - - return DomainDiffSection(title: "TLS", items: items) - } - - private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - var items: [DomainDiffItem] = [] - - if let statusChange = compare( - label: "HTTP Status", - oldValue: httpStatusSummary(from: oldSnapshot), - newValue: httpStatusSummary(from: newSnapshot), - severity: .medium - ) { - items.append(statusChange) - } - - if let gradeChange = compare( - label: "Security Grade", - oldValue: normalized(oldSnapshot.httpSecurityGrade), - newValue: normalized(newSnapshot.httpSecurityGrade), - severity: .low - ) { - items.append(gradeChange) - } - - if let headerChange = compare( - label: "Headers", - oldValue: normalizedHeaders(from: oldSnapshot), - newValue: normalizedHeaders(from: newSnapshot), - severity: .low - ) { - items.append(headerChange) - } - - return DomainDiffSection(title: "HTTP", items: items) - } - - private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - DomainDiffSection( - title: "Email Security", - items: [ - compare( - label: "Email Security", - oldValue: normalized(emailSummary(from: oldSnapshot)), - newValue: normalized(emailSummary(from: newSnapshot)), - severity: .medium - ) - ].compactMap { $0 } - ) - } - - private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - DomainDiffSection( - title: "Subdomains", - items: [ - compare( - label: "Passive Subdomains", - oldValue: subdomainList(from: oldSnapshot), - newValue: subdomainList(from: newSnapshot), - severity: .low - ) - ].compactMap { $0 } - ) - } - - private static func compare( - label: String, - oldValue: String?, - newValue: String?, - severity: ChangeSeverity - ) -> DomainDiffItem? { - let oldValue = normalized(oldValue) - let newValue = normalized(newValue) - let normalizedOldValue = comparisonValue(oldValue) - let normalizedNewValue = comparisonValue(newValue) - - guard oldValue != nil || newValue != nil else { - return nil - } - - let changeType: DiffChangeType - switch (normalizedOldValue, normalizedNewValue) { - case let (old?, new?) where old == new: - changeType = .unchanged - case (nil, _?): - changeType = .added - case (_?, nil): - changeType = .removed - default: - changeType = .changed - } - - return DomainDiffItem( - label: label, - changeType: changeType, - oldValue: oldValue, - newValue: newValue, - severity: severity - ) - } - - private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] { - var highlights: [String] = [] - - let labels = Set(items.map(\.label)) - if labels.contains("Availability") { - highlights.append("Availability changed") - } - if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) { - highlights.append("IP changed") - highlights.append("DNS changed") - return highlights - } - if labels.contains("Primary IP") { - highlights.append("IP changed") - } - if labels.contains("Redirect Target") { - highlights.append("Redirect target changed") - } - if labels.contains("Registrar") { - highlights.append("Registrar changed") - } else if labels.contains("Nameservers") { - highlights.append("Nameservers changed") - } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") { - highlights.append("Ownership metadata changed") - } - if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }), - let message = certificateItem.newValue { - highlights.append(message) - } else if labels.contains("TLS Issuer") { - highlights.append("TLS issuer changed") - } else if labels.contains("TLS Expiration") { - highlights.append("Certificate expiration changed") - } - if labels.contains(where: { $0.hasSuffix("Records") }) { - highlights.append("DNS changed") - } - if labels.contains("HTTP Status") { - highlights.append("HTTP status changed") - } - if labels.contains("Email Security") { - highlights.append("Email security changed") - } - if labels.contains("Passive Subdomains") { - highlights.append("Subdomains changed") - } - - var deduplicated: [String] = [] - for highlight in highlights where !deduplicated.contains(highlight) { - deduplicated.append(highlight) - } - return deduplicated - } - - private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String { - guard !items.isEmpty, !highlights.isEmpty else { - return "No meaningful changes" - } - - if highlights.count == 1 { - return highlights[0] - } - - return "\(highlights[0]) and \(highlights[1].lowercased())" - } - - private static func observedFacts(from items: [DomainDiffItem]) -> [String] { - items.prefix(3).map { item in - let oldValue = item.oldValue ?? "none" - let newValue = item.newValue ?? "none" - return "\(item.label): \(oldValue) -> \(newValue)" - } - } - - private static func normalized(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { - return nil - } - return value - } - - private static func comparisonValue(_ value: String?) -> String? { - value?.lowercased() - } - - private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? { - switch status { - case .available: - return "available" - case .registered: - return "registered" - case .unknown: - return "unknown" - case .none: - return nil - } - } - - private static func primaryIP(from snapshot: LookupSnapshot) -> String? { - snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value - } - - private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? { - snapshot.redirectChain.last?.url - } - - private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? { - guard let sslInfo else { return nil } - return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)" - } - - private static func ownershipDateLabel(_ date: Date?) -> String? { - date?.formatted(date: .abbreviated, time: .omitted) - } - - private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { - if let httpStatusCode = snapshot.httpStatusCode { - return "\(httpStatusCode)" - } - return snapshot.httpHeadersError - } - - private static func emailSummary(from snapshot: LookupSnapshot) -> String? { - if let emailSecurity = snapshot.emailSecurity { - return [ - "spf:\(emailSecurity.spf.found)", - "dmarc:\(emailSecurity.dmarc.found)", - "dkim:\(emailSecurity.dkim.found)", - "bimi:\(emailSecurity.bimi.found)", - "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)" - ].joined(separator: "|") - } - return snapshot.emailSecurityError - } - - private static func normalizedRecordValues(for section: DNSSection?) -> String? { - guard let section else { return nil } - let values = (section.records + section.wildcardRecords) - .map(\.value) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .sorted() - return values.isEmpty ? nil : values.joined(separator: ",") - } - - private static func normalizedTTLValues(for section: DNSSection?) -> String? { - guard let section else { return nil } - let values = (section.records + section.wildcardRecords) - .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" } - .sorted() - return values.isEmpty ? nil : values.joined(separator: ",") - } - - private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? { - let headers = snapshot.httpHeaders - .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" } - .sorted() - return headers.isEmpty ? nil : headers.joined(separator: "|") - } - - private static func ownershipList(_ values: [String]?) -> String? { - guard let values else { return nil } - let normalizedValues = values - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .filter { !$0.isEmpty } - .sorted() - return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",") - } - - private static func subdomainList(from snapshot: LookupSnapshot) -> String? { - let values = snapshot.subdomains - .map(\.hostname) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .sorted() - return values.isEmpty ? nil : values.joined(separator: ",") - } -} diff --git a/DomainDig/DomainDig/DomainDebugLog.swift b/DomainDig/DomainDig/DomainDebugLog.swift new file mode 100644 index 0000000..12a6cc5 --- /dev/null +++ b/DomainDig/DomainDig/DomainDebugLog.swift @@ -0,0 +1,37 @@ +import Foundation +import os + +enum DomainDebugLog { + static let enabled = true + private static let logger = Logger(subsystem: "co.zerolabs.domain-dig", category: "Debug") + + static func debug(_ message: String) { + guard enabled else { return } + logger.debug("\(message, privacy: .public)") + } + + static func error(_ message: String) { + guard enabled else { return } + logger.error("\(message, privacy: .public)") + } + + static func signpostStart(_ scope: String, domain: String? = nil) -> CFAbsoluteTime { + let start = CFAbsoluteTimeGetCurrent() + if let domain { + debug("[START] \(scope) domain=\(domain)") + } else { + debug("[START] \(scope)") + } + return start + } + + static func signpostEnd(_ scope: String, start: CFAbsoluteTime, domain: String? = nil, extra: String? = nil) { + let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - start) * 1000) + let suffix = extra.map { " \($0)" } ?? "" + if let domain { + debug("[END] \(scope) domain=\(domain) elapsedMs=\(elapsedMs)\(suffix)") + } else { + debug("[END] \(scope) elapsedMs=\(elapsedMs)\(suffix)") + } + } +} diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift index 5128c0b..56dfe3f 100644 --- a/DomainDig/DomainMonitoringService.swift +++ b/DomainDig/DomainMonitoringService.swift @@ -509,6 +509,10 @@ final class DomainMonitoringService { isPartialSnapshot: previousSnapshot.isPartialSnapshot, validationIssues: previousSnapshot.validationIssues, totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, + snapshotIndex: previousSnapshot.snapshotIndex, + previousSnapshotID: previousSnapshot.previousSnapshotID, + changeCount: previousSnapshot.changeCount, + severitySummary: previousSnapshot.severitySummary, dnsSections: previousSnapshot.dnsSections, dnsError: previousSnapshot.dnsError, availabilityResult: previousSnapshot.availabilityResult, diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 02e09f7..656529c 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -208,6 +208,10 @@ final class DomainViewModel { private var lastBatchStartedAt: Date? private var activeWorkflowRunID: UUID? private var activeWorkflowRunName: String? + private var historyPersistenceSuspended = false + private var trackedDomainsPersistenceSuspended = false + private var historyPersistenceDirty = false + private var trackedDomainsPersistenceDirty = false private let reportBuilder = DomainReportBuilder() private let inspectionService = DomainInspectionService() private(set) var currentResultSource: LookupResultSource = .live @@ -237,6 +241,8 @@ final class DomainViewModel { var historyDateFilter: HistoryDateFilter = .all var historyChangeFilter: ChangeFilterOption = .all var historySortOption: HistorySortOption = .newest + var timelineGrouping: TimelineGroupingOption = .relativeDay + var timelineDomainFilter = "" var watchlistSearchText = "" var watchlistFilter: WatchlistFilterOption = .all var watchlistSortOption: WatchlistSortOption = .pinned @@ -249,6 +255,12 @@ final class DomainViewModel { var portabilityStatusMessage: String? var upgradePrompt: UpgradePromptContext? var isPaywallPresented = false + var selectedSnapshotIDs = Set<UUID>() + var activeDomainDiff: DomainDiff? + var activeDiffChangeIndex = 0 + + private static let historyAutoPruneKey = "historyAutoPrune" + var historyAutoPruneOption: HistoryAutoPruneOption = DomainViewModel.loadHistoryAutoPruneOption() var trimmedDomain: String { domain @@ -365,6 +377,15 @@ final class DomainViewModel { return sortedTrackedDomains(from: filtered, using: watchlistSortOption) } + var timelineDomains: [String] { + let query = timelineDomainFilter.trimmingCharacters(in: .whitespacesAndNewlines) + let domains = history.map(\.domain) + let filtered = query.isEmpty + ? domains + : domains.filter { $0.localizedCaseInsensitiveContains(query) } + return Array(Set(filtered)).sorted() + } + var batchProgressLabel: String { guard batchTotalCount > 0 else { return "No active batch" } if !batchLookupRunning, batchCompletedCount >= batchTotalCount { @@ -441,6 +462,10 @@ final class DomainViewModel { isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false, validationIssues: currentHistoryEntry?.validationIssues ?? [], totalLookupDurationMs: lastLookupDurationMs, + snapshotIndex: currentHistoryEntry?.snapshotIndex, + previousSnapshotID: currentHistoryEntry?.previousSnapshotID, + changeCount: currentHistoryEntry?.changeCount ?? currentChangeSummary?.changedSections.count ?? 0, + severitySummary: currentHistoryEntry?.severitySummary ?? currentChangeSummary?.severity, dnsSections: dnsSections, dnsError: dnsError, availabilityResult: availabilityResult, @@ -1372,6 +1397,22 @@ final class DomainViewModel { ) } + func exportTimelineText(domain: String, includeDiffSummary: Bool) -> String { + DomainReportExporter.timelineText( + for: timelineReports(for: domain), + domain: domain, + includeDiffSummary: includeDiffSummary + ) + } + + func exportTimelineJSONData(domain: String, includeDiffSummary: Bool) -> Data? { + try? DomainReportExporter.timelineData( + for: timelineReports(for: domain), + domain: domain, + includeDiffSummary: includeDiffSummary + ) + } + func exportFullBackupData() -> Data? { try? DomainDataPortabilityService.backupData() } @@ -1461,20 +1502,25 @@ final class DomainViewModel { } private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { + let lookupStartedAt = DomainDebugLog.signpostStart("DomainViewModel.performLookup", domain: domain) let previous = previousSnapshot( for: domain, trackedDomainID: currentTrackedDomain?.id, replacingLatest: false ) let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous) + DomainDebugLog.debug("DomainViewModel.performLookup inspectionReturned domain=\(domain)") guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous) + let applyStartedAt = DomainDebugLog.signpostStart("DomainViewModel.applySnapshot", domain: domain) applySnapshot(snapshot) + DomainDebugLog.signpostEnd("DomainViewModel.applySnapshot", start: applyStartedAt, domain: domain) lastLookupDurationMs = snapshot.totalLookupDurationMs refreshingTrackedDomainID = nil if DataAccessService.hasAccess(to: .domainPricing), domainPricing == nil { + DomainDebugLog.debug("DomainViewModel.performLookup loadingPricing domain=\(domain)") await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false) } @@ -1483,8 +1529,11 @@ final class DomainViewModel { return history.first(where: { $0.id == snapshot.historyEntryID }) } - let entry = saveHistoryEntry(replaceLatest: false) + let saveStartedAt = DomainDebugLog.signpostStart("DomainViewModel.saveHistoryEntry", domain: domain) + let entry = saveHistoryEntry(replaceLatest: false, reuseCurrentAnalysis: true) + DomainDebugLog.signpostEnd("DomainViewModel.saveHistoryEntry", start: saveStartedAt, domain: domain) await refreshUsageCredits() + DomainDebugLog.signpostEnd("DomainViewModel.performLookup", start: lookupStartedAt, domain: domain) return entry } @@ -1496,6 +1545,7 @@ final class DomainViewModel { currentStatusMessage = snapshot.statusMessage currentDiffSections = [] ownershipDiff = [] + let reportStartedAt = DomainDebugLog.signpostStart("DomainViewModel.reportBuilder.build", domain: snapshot.domain) currentReport = reportBuilder.build( from: snapshot, previousSnapshot: previousSnapshot( @@ -1504,6 +1554,7 @@ final class DomainViewModel { replacingLatest: false ) ) + DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain) currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary dnsSections = snapshot.dnsSections @@ -1596,6 +1647,10 @@ final class DomainViewModel { isPartialSnapshot: previousSnapshot.isPartialSnapshot, validationIssues: previousSnapshot.validationIssues, totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, + snapshotIndex: previousSnapshot.snapshotIndex, + previousSnapshotID: previousSnapshot.previousSnapshotID, + changeCount: previousSnapshot.changeCount, + severitySummary: previousSnapshot.severitySummary, dnsSections: previousSnapshot.dnsSections, dnsError: previousSnapshot.dnsError, availabilityResult: previousSnapshot.availabilityResult, @@ -1956,32 +2011,61 @@ final class DomainViewModel { } @discardableResult - private func saveHistoryEntry(replaceLatest: Bool) -> HistoryEntry? { + private func saveHistoryEntry(replaceLatest: Bool, reuseCurrentAnalysis: Bool = false) -> HistoryEntry? { guard !searchedDomain.isEmpty else { return nil } - return saveHistoryEntry(from: currentSnapshot, replaceLatest: replaceLatest, updateCurrentState: true) + return saveHistoryEntry( + from: currentSnapshot, + replaceLatest: replaceLatest, + updateCurrentState: true, + reuseCurrentAnalysis: reuseCurrentAnalysis + ) } @discardableResult - private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? { + private func saveHistoryEntry( + from snapshot: LookupSnapshot, + replaceLatest: Bool, + updateCurrentState: Bool, + reuseCurrentAnalysis: Bool = false + ) -> HistoryEntry? { let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) - let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) - let changeSummary = previousSnapshot.map { - DomainDiffService.summary( - from: $0, - to: snapshot, - generatedAt: snapshot.timestamp, - riskAssessment: analysis.riskAssessment, - insights: analysis.insights - ) - } - let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] + let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) + let changeSummary = reuseCurrentAnalysis + ? currentChangeSummary ?? snapshot.changeSummary + : previousSnapshot.map { + DiffService.summary( + from: $0, + to: snapshot, + generatedAt: snapshot.timestamp, + riskAssessment: analysis?.riskAssessment, + insights: analysis?.insights + ) + } + let domainDiff = reuseCurrentAnalysis + ? previousSnapshot.map { + DomainDiff( + domain: snapshot.domain, + fromTimestamp: $0.timestamp, + toTimestamp: snapshot.timestamp, + sections: currentDiffSections, + changedSectionIDs: currentDiffSections.filter(\.hasChanges).map(\.id), + changedSectionTitles: currentDiffSections.filter(\.hasChanges).map(\.title), + contextNote: currentChangeSummary?.contextNote + ) + } + : previousSnapshot.map { DiffService.compare(from: $0, to: snapshot) } + let diffSections = domainDiff?.sections ?? currentDiffSections + let previousSnapshotID = previousSnapshot?.historyEntryID + let nextSnapshotIndex = nextSnapshotIndex(for: snapshot.domain, trackedDomainID: trackedDomainID) if updateCurrentState { currentChangeSummary = changeSummary currentDiffSections = diffSections ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] - currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot) + if !reuseCurrentAnalysis { + currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot) + } } let entry = HistoryEntry( @@ -2029,6 +2113,10 @@ final class DomainViewModel { emailSecuritySummary: Self.emailSummary(from: snapshot), httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError, changeSummary: changeSummary, + snapshotIndex: nextSnapshotIndex, + previousSnapshotID: previousSnapshotID, + changeCount: domainDiff?.changeCount ?? changeSummary?.changedSections.count ?? 0, + severitySummary: changeSummary?.severity, sslError: snapshot.sslError, httpHeadersError: snapshot.httpHeadersError, reachabilityError: snapshot.reachabilityError, @@ -2053,9 +2141,7 @@ final class DomainViewModel { history[0] = entry } else { history.insert(entry, at: 0) - if history.count > Self.maxHistory { - history = Array(history.prefix(Self.maxHistory)) - } + trimHistoryToLimit() } updateTrackedDomainSnapshotMetadata( @@ -2074,8 +2160,21 @@ final class DomainViewModel { } private func persistHistory() { + if historyPersistenceSuspended { + historyPersistenceDirty = true + return + } + let persistStartedAt = DomainDebugLog.signpostStart("DomainViewModel.persistHistory") DomainDataPortabilityService.saveHistoryEntries(history) refreshDataLifecycleSummary() + DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)") + } + + func setHistoryAutoPruneOption(_ option: HistoryAutoPruneOption) { + historyAutoPruneOption = option + UserDefaults.standard.set(option.rawValue, forKey: Self.historyAutoPruneKey) + trimHistoryToLimit() + persistHistory() } func updateHistoryNote(_ note: String, for entry: HistoryEntry) { @@ -2085,11 +2184,56 @@ final class DomainViewModel { } private func persistTrackedDomains() { + if trackedDomainsPersistenceSuspended { + trackedDomainsPersistenceDirty = true + return + } DomainDataPortabilityService.saveTrackedDomains(trackedDomains) CloudSyncService.shared.scheduleSyncIfNeeded() refreshDataLifecycleSummary() } + private func beginBulkPersistenceDeferral() { + historyPersistenceSuspended = true + trackedDomainsPersistenceSuspended = true + historyPersistenceDirty = false + trackedDomainsPersistenceDirty = false + } + + private func endBulkPersistenceDeferral() { + historyPersistenceSuspended = false + trackedDomainsPersistenceSuspended = false + + if trackedDomainsPersistenceDirty { + trackedDomainsPersistenceDirty = false + DomainDataPortabilityService.saveTrackedDomains(trackedDomains) + CloudSyncService.shared.scheduleSyncIfNeeded() + } + + if historyPersistenceDirty { + historyPersistenceDirty = false + DomainDataPortabilityService.saveHistoryEntries(history) + } + + refreshDataLifecycleSummary() + } + + private func trimHistoryToLimit() { + let hardLimit = historyAutoPruneOption.keepCount ?? Self.maxHistory + history = Array(history.prefix(min(hardLimit, Self.maxHistory))) + } + + private func nextSnapshotIndex(for domain: String, trackedDomainID: UUID?) -> Int { + let siblings = history.filter { entry in + if let trackedDomainID { + return entry.trackedDomainID == trackedDomainID + } + return entry.domain.caseInsensitiveCompare(domain) == .orderedSame + } + let existingMax = siblings.compactMap(\.snapshotIndex).max() ?? siblings.count + return existingMax + 1 + } + private func persistMonitoringSettings(localActivationConfirmed: Bool = false) { monitoringSettings = MonitoringStorage.sanitizeSettings(monitoringSettings, trackedDomains: trackedDomains) MonitoringStorage.saveSettings(monitoringSettings) @@ -2328,6 +2472,7 @@ final class DomainViewModel { private func runBatchLookup(domains: [String], source: BatchLookupSource) async { let concurrencyLimit = min(source == .watchlistRefresh ? 4 : 3, max(domains.count, 1)) var nextIndex = 0 + beginBulkPersistenceDeferral() await withTaskGroup(of: (String, BatchLookupPayload?).self) { group in for _ in 0..<concurrencyLimit { @@ -2348,6 +2493,7 @@ final class DomainViewModel { } } + endBulkPersistenceDeferral() finishBatchLookup(source: source) } @@ -2396,7 +2542,7 @@ final class DomainViewModel { } } let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) - let riskAssessment = DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment + let riskAssessment = entry?.changeSummary?.riskAssessment ?? DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment let quickStatus: String if entry?.changeSummary?.hasChanges == true { quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed") @@ -2646,15 +2792,105 @@ final class DomainViewModel { } func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { - let siblings = history.filter { candidate in - if let trackedDomainID = entry.trackedDomainID { - return candidate.trackedDomainID == trackedDomainID && candidate.id != entry.id + previousHistoryEntry(for: entry)?.snapshot + } + + func timelineEntries(for domain: String) -> [SnapshotSummary] { + historyEntries(for: domain).map(\.snapshotSummary) + } + + func timelineSections(for domain: String, grouping: TimelineGroupingOption? = nil) -> [TimelineSection] { + let entries = timelineEntries(for: domain) + let grouping = grouping ?? timelineGrouping + + guard grouping == .relativeDay else { + return entries.isEmpty ? [] : [TimelineSection(id: "all", title: "All Snapshots", entries: entries)] + } + + let calendar = Calendar.current + let today = Date() + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today + let grouped = Dictionary(grouping: entries) { entry -> String in + if calendar.isDate(entry.timestamp, inSameDayAs: today) { + return "Today" } - return candidate.domain.caseInsensitiveCompare(entry.domain) == .orderedSame && candidate.id != entry.id + if calendar.isDate(entry.timestamp, inSameDayAs: yesterday) { + return "Yesterday" + } + return "Older" + } + + return ["Today", "Yesterday", "Older"].compactMap { title in + guard let items = grouped[title], !items.isEmpty else { return nil } + return TimelineSection(id: title.lowercased(), title: title, entries: items) + } + } + + func historyEntries(for domain: String) -> [HistoryEntry] { + history + .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + .sorted { $0.timestamp > $1.timestamp } + } + + func historyEntry(withID id: UUID) -> HistoryEntry? { + history.first(where: { $0.id == id }) + } + + func previousHistoryEntry(for entry: HistoryEntry) -> HistoryEntry? { + let siblings = historyEntries(for: entry.domain) + guard let index = siblings.firstIndex(where: { $0.id == entry.id }) else { return nil } + let nextIndex = index + 1 + guard siblings.indices.contains(nextIndex) else { return nil } + return siblings[nextIndex] + } + + func toggleSnapshotSelection(_ entry: HistoryEntry) { + if selectedSnapshotIDs.contains(entry.id) { + selectedSnapshotIDs.remove(entry.id) + } else if selectedSnapshotIDs.count < 2 { + selectedSnapshotIDs.insert(entry.id) + } else if let oldest = selectedSnapshotIDs.first { + selectedSnapshotIDs.remove(oldest) + selectedSnapshotIDs.insert(entry.id) + } + } + + func clearSnapshotSelection() { + selectedSnapshotIDs.removeAll() + } + + var selectedSnapshots: [HistoryEntry] { + selectedSnapshotIDs.compactMap(historyEntry(withID:)).sorted { $0.timestamp < $1.timestamp } + } + + @discardableResult + func generateDiffForSelectedSnapshots(focusSectionID: String? = nil) -> DomainDiff? { + guard selectedSnapshots.count == 2 else { + activeDomainDiff = nil + activeDiffChangeIndex = 0 + return nil + } + + let diff = DiffService.compare(from: selectedSnapshots[0].snapshot, to: selectedSnapshots[1].snapshot) + activeDomainDiff = diff + if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) { + activeDiffChangeIndex = index + } else { + activeDiffChangeIndex = 0 } - .sorted { $0.timestamp > $1.timestamp } + return diff + } - return siblings.first?.snapshot + func generateDiff(from olderEntry: HistoryEntry, to newerEntry: HistoryEntry, focusSectionID: String? = nil) -> DomainDiff { + selectedSnapshotIDs = [olderEntry.id, newerEntry.id] + let diff = DiffService.compare(from: olderEntry.snapshot, to: newerEntry.snapshot) + activeDomainDiff = diff + if let focusSectionID, let index = diff.changedSectionIDs.firstIndex(of: focusSectionID) { + activeDiffChangeIndex = index + } else { + activeDiffChangeIndex = 0 + } + return diff } func historyEntry(for batchResult: BatchLookupResult) -> HistoryEntry? { @@ -2662,6 +2898,23 @@ final class DomainViewModel { return history.first(where: { $0.id == historyEntryID }) } + var currentDiffTargetSectionID: String? { + guard let activeDomainDiff, activeDomainDiff.changedSectionIDs.indices.contains(activeDiffChangeIndex) else { + return nil + } + return activeDomainDiff.changedSectionIDs[activeDiffChangeIndex] + } + + func moveToNextDiffChange() { + guard let activeDomainDiff, !activeDomainDiff.changedSectionIDs.isEmpty else { return } + activeDiffChangeIndex = min(activeDiffChangeIndex + 1, activeDomainDiff.changedSectionIDs.count - 1) + } + + func moveToPreviousDiffChange() { + guard activeDomainDiff != nil else { return } + activeDiffChangeIndex = max(activeDiffChangeIndex - 1, 0) + } + private func exportSnapshots(for domains: [TrackedDomain]) -> [LookupSnapshot] { let latestEntries = latestSnapshots(for: domains) @@ -2709,6 +2962,10 @@ final class DomainViewModel { } } + private func timelineReports(for domain: String) -> [DomainReport] { + historyEntries(for: domain).map { report(for: $0) } + } + private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport { reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext) } @@ -2745,6 +3002,10 @@ final class DomainViewModel { isPartialSnapshot: true, validationIssues: ["No stored snapshot data available"], totalLookupDurationMs: nil, + snapshotIndex: nil, + previousSnapshotID: nil, + changeCount: 0, + severitySummary: trackedDomain.lastChangeSeverity, dnsSections: [], dnsError: nil, availabilityResult: DomainAvailabilityResult(domain: trackedDomain.domain, status: trackedDomain.lastKnownAvailability ?? .unknown), @@ -2795,6 +3056,14 @@ final class DomainViewModel { return DomainDataPortabilityService.loadHistoryEntries() } + private static func loadHistoryAutoPruneOption() -> HistoryAutoPruneOption { + guard let rawValue = UserDefaults.standard.string(forKey: historyAutoPruneKey), + let option = HistoryAutoPruneOption(rawValue: rawValue) else { + return .unlimited + } + return option + } + private static func loadTrackedDomains() -> [TrackedDomain] { DataMigrationService.migrateIfNeeded() return DomainDataPortabilityService.loadTrackedDomains() diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 6eb6777..be7548a 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -7,8 +7,12 @@ struct HistoryView: View { @State private var showClearAllConfirmation = false @State private var showWorkflowAddSheet = false - private var groupedHistory: [HistoryGroup] { - HistoryGroup.groups(for: viewModel.filteredHistory) + private var domainSummaries: [(domain: String, latest: SnapshotSummary, count: Int)] { + viewModel.timelineDomains.compactMap { domain in + let entries = viewModel.timelineEntries(for: domain) + guard let latest = entries.first else { return nil } + return (domain, latest, entries.count) + } } var body: some View { @@ -22,55 +26,41 @@ struct HistoryView: View { ) .listRowBackground(Color(.systemGray6).opacity(0.5)) } else { - ForEach(groupedHistory) { group in - Section(group.title) { - ForEach(group.entries) { entry in - NavigationLink { - HistoryDetailView(viewModel: viewModel, entry: entry) - } label: { - VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { - HStack(alignment: .center, spacing: 8) { - Text(entry.domain) - .font(appDensity.font(.callout)) - .foregroundStyle(.primary) - Spacer() - AppStatusBadgeView(model: AppStatusFactory.change(entry.changeSummary)) - } - - HStack(spacing: 8) { - AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status)) - AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError)) - if entry.isPartialSnapshot { - AppStatusBadgeView(model: .init(title: "Partial", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))) - } - } + Section("Domains") { + ForEach(domainSummaries, id: \.domain) { item in + NavigationLink { + TimelineView(viewModel: viewModel, domain: item.domain) + } label: { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { + HStack(alignment: .center, spacing: 8) { + Text(item.domain) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + Text("\(item.count) snapshots") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } - HStack(spacing: 8) { - Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened)) - Text(entry.timestamp.formatted(.relative(presentation: .named))) - Text(entry.resolverDisplayName) - if let totalLookupDurationMs = entry.totalLookupDurationMs { - Text("\(totalLookupDurationMs) ms") - } - } - .font(appDensity.font(.caption2)) + Text(item.latest.changeSummaryMessage ?? "No change summary") + .font(appDensity.font(.caption)) .foregroundStyle(.secondary) + .lineLimit(2) - if let note = entry.note, !note.isEmpty { - Text(note) - .font(appDensity.font(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(1) + HStack(spacing: 8) { + AppStatusBadgeView(model: AppStatusFactory.availability(item.latest.availability)) + if let severity = item.latest.severitySummary { + AppStatusBadgeView( + model: .init( + title: severity.title, + systemImage: "arrow.triangle.2.circlepath", + foregroundColor: severity == .high ? .red : .yellow, + backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16) + ) + ) } } } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - viewModel.removeHistoryEntries(withIDs: [entry.id]) - } label: { - Label("Delete", systemImage: "trash") - } - } } .listRowBackground(Color(.systemGray6).opacity(0.5)) } @@ -80,7 +70,7 @@ struct HistoryView: View { .scrollContentBackground(.hidden) .background(Color.black) .navigationTitle("History") - .searchable(text: $viewModel.historySearchText, prompt: "Search domains") + .searchable(text: $viewModel.timelineDomainFilter, prompt: "Search domains") .toolbar { if !viewModel.history.isEmpty { ToolbarItemGroup(placement: .topBarTrailing) { @@ -244,7 +234,8 @@ struct HistoryDetailView: View { title: "Compared With Previous Snapshot", sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot), - showsUnchanged: false + showsUnchanged: false, + highlightedSectionID: nil ) .padding(.top, appDensity.metrics.sectionSpacing) } diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 25d1ba2..51273b4 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -541,6 +541,83 @@ enum HistorySortOption: String, CaseIterable, Identifiable { } } +enum TimelineGroupingOption: String, CaseIterable, Identifiable { + case none + case relativeDay + + var id: String { rawValue } + + var title: String { + switch self { + case .none: + return "Ungrouped" + case .relativeDay: + return "Today / Yesterday / Older" + } + } +} + +enum HistoryAutoPruneOption: String, CaseIterable, Codable, Identifiable { + case keep50 + case keep100 + case unlimited + + var id: String { rawValue } + + var title: String { + switch self { + case .keep50: + return "Keep Last 50" + case .keep100: + return "Keep Last 100" + case .unlimited: + return "Unlimited" + } + } + + var keepCount: Int? { + switch self { + case .keep50: + return 50 + case .keep100: + return 100 + case .unlimited: + return nil + } + } +} + +struct SnapshotSummary: Identifiable, Codable, Equatable { + let id: UUID + let domain: String + let timestamp: Date + let trackedDomainID: UUID? + let snapshotIndex: Int? + let previousSnapshotID: UUID? + let changeCount: Int + let severitySummary: ChangeSeverity? + let changeSummaryMessage: String? + let availability: DomainAvailabilityStatus? + let primaryIP: String? + let tlsStatus: String? + let riskScore: Int? + let historyEntryID: UUID + + var hasChanges: Bool { + changeCount > 0 + } +} + +struct FullSnapshot: Codable { + let historyEntry: HistoryEntry +} + +struct TimelineSection: Identifiable, Equatable { + let id: String + let title: String + let entries: [SnapshotSummary] +} + enum WatchlistFilterOption: String, CaseIterable, Identifiable { case all case pinnedOnly @@ -1304,6 +1381,10 @@ struct HistoryEntry: Identifiable, Codable { var emailSecuritySummary: String? var httpGradeSummary: String? var changeSummary: DomainChangeSummary? + var snapshotIndex: Int? + var previousSnapshotID: UUID? + var changeCount: Int + var severitySummary: ChangeSeverity? var sslError: String? var httpHeadersError: String? var reachabilityError: String? @@ -1339,7 +1420,8 @@ struct HistoryEntry: Identifiable, Codable { validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String, totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, - changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, + changeSummary: DomainChangeSummary? = nil, snapshotIndex: Int? = nil, previousSnapshotID: UUID? = nil, + changeCount: Int = 0, severitySummary: ChangeSeverity? = nil, sslError: String? = nil, httpHeadersError: String? = nil, reachabilityError: String? = nil, ipGeolocationError: String? = nil, emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, @@ -1389,6 +1471,10 @@ struct HistoryEntry: Identifiable, Codable { self.emailSecuritySummary = emailSecuritySummary self.httpGradeSummary = httpGradeSummary self.changeSummary = changeSummary + self.snapshotIndex = snapshotIndex + self.previousSnapshotID = previousSnapshotID + self.changeCount = changeCount + self.severitySummary = severitySummary self.sslError = sslError self.httpHeadersError = httpHeadersError self.reachabilityError = reachabilityError @@ -1452,6 +1538,13 @@ struct HistoryEntry: Identifiable, Codable { emailSecuritySummary = try container.decodeIfPresent(String.self, forKey: .emailSecuritySummary) httpGradeSummary = try container.decodeIfPresent(String.self, forKey: .httpGradeSummary) changeSummary = try container.decodeIfPresent(DomainChangeSummary.self, forKey: .changeSummary) + snapshotIndex = try container.decodeIfPresent(Int.self, forKey: .snapshotIndex) + previousSnapshotID = try container.decodeIfPresent(UUID.self, forKey: .previousSnapshotID) + changeCount = try container.decodeIfPresent(Int.self, forKey: .changeCount) + ?? changeSummary?.changedSections.count + ?? 0 + severitySummary = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severitySummary) + ?? changeSummary?.severity sslError = try container.decodeIfPresent(String.self, forKey: .sslError) httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) @@ -1478,6 +1571,25 @@ struct HistoryEntry: Identifiable, Codable { } return issues } + + var snapshotSummary: SnapshotSummary { + SnapshotSummary( + id: id, + domain: domain, + timestamp: timestamp, + trackedDomainID: trackedDomainID, + snapshotIndex: snapshotIndex, + previousSnapshotID: previousSnapshotID, + changeCount: changeCount, + severitySummary: severitySummary ?? changeSummary?.severity, + changeSummaryMessage: changeSummary?.message, + availability: availabilityResult?.status, + primaryIP: primaryIP, + tlsStatus: tlsStatusSummary, + riskScore: changeSummary?.riskAssessment?.score, + historyEntryID: id + ) + } } // MARK: - Cloudflare DNS-over-HTTPS Response diff --git a/DomainDig/PortScanService.swift b/DomainDig/PortScanService.swift index 36c2827..1f3e649 100644 --- a/DomainDig/PortScanService.swift +++ b/DomainDig/PortScanService.swift @@ -124,7 +124,7 @@ struct PortScanService { } private static func probe(domain: String, port: UInt16) async -> PortProbeResult { - await probe(domain: domain, port: port, timeout: 5) + await probe(domain: domain, port: port, timeout: 1.5) } private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult { diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift index e7e2c93..6d67e45 100644 --- a/DomainDig/RDAPService.swift +++ b/DomainDig/RDAPService.swift @@ -48,6 +48,7 @@ enum RDAPService { } private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { + let startedAt = DomainDebugLog.signpostStart("RDAP.fetch", domain: domain) guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { return .error("Unavailable") } @@ -55,9 +56,11 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo do { var request = URLRequest(url: url, timeoutInterval: 8) request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept") + DomainDebugLog.debug("RDAP.request url=\(url.absoluteString) timeout=8") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { + DomainDebugLog.error("RDAP.badResponse domain=\(domain) response=nil") return .error(URLError(.badServerResponse).localizedDescription) } @@ -66,15 +69,22 @@ private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDo let decoder = JSONDecoder() let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data) guard rdapResponse.isDomainRecord else { + DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 nonDomainRecord") return .empty("Unavailable") } + DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 bytes=\(data.count)") return .success(rdapResponse) case 404: + DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=404") return .empty("Unavailable") default: + DomainDebugLog.error("RDAP.httpError domain=\(domain) status=\(httpResponse.statusCode)") + DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=\(httpResponse.statusCode)") return .error("Unavailable") } } catch { + DomainDebugLog.error("RDAP.error domain=\(domain) error=\(error.localizedDescription)") + DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "error") return .error(error.localizedDescription) } } diff --git a/DomainDig/ReachabilityService.swift b/DomainDig/ReachabilityService.swift index 3fb7dd9..207499f 100644 --- a/DomainDig/ReachabilityService.swift +++ b/DomainDig/ReachabilityService.swift @@ -23,7 +23,7 @@ struct ReachabilityService { let queue = DispatchQueue(label: "reachability.\(port)") connection.start(queue: queue) - queue.asyncAfter(deadline: .now() + 5) { + queue.asyncAfter(deadline: .now() + 2) { context.finish(reachable: false) } } diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift index 7339648..4511edd 100644 --- a/DomainDig/SubdomainDiscoveryService.swift +++ b/DomainDig/SubdomainDiscoveryService.swift @@ -17,6 +17,7 @@ enum SubdomainDiscoveryService { } private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { + let startedAt = DomainDebugLog.signpostStart("SubdomainDiscovery.fetch", domain: domain) var components = URLComponents(string: "https://crt.sh/")! components.queryItems = [ URLQueryItem(name: "q", value: "%.\(domain)"), @@ -29,15 +30,25 @@ enum SubdomainDiscoveryService { do { let request = URLRequest(url: url, timeoutInterval: 10) + DomainDebugLog.debug("SubdomainDiscovery.request url=\(url.absoluteString) timeout=10") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + DomainDebugLog.error("SubdomainDiscovery.badResponse domain=\(domain)") return .error("Subdomain discovery unavailable") } let entries = try JSONDecoder().decode([CRTShEntry].self, from: data) let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit) + DomainDebugLog.signpostEnd( + "SubdomainDiscovery.fetch", + start: startedAt, + domain: domain, + extra: "entries=\(entries.count) subdomains=\(subdomains.count)" + ) return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains) } catch { + DomainDebugLog.error("SubdomainDiscovery.error domain=\(domain) error=\(error.localizedDescription)") + DomainDebugLog.signpostEnd("SubdomainDiscovery.fetch", start: startedAt, domain: domain, extra: "error") return .error(error.localizedDescription) } } diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift new file mode 100644 index 0000000..404fc1e --- /dev/null +++ b/DomainDig/TimelineView.swift @@ -0,0 +1,224 @@ +import SwiftUI + +struct TimelineView: View { + @Environment(\.appDensity) private var appDensity + @Bindable var viewModel: DomainViewModel + let domain: String + + @State private var presentedDiff: DomainDiff? + @State private var focusedSectionID: String? + + private var timelineSections: [TimelineSection] { + viewModel.timelineSections(for: domain) + } + + var body: some View { + List { + ForEach(timelineSections) { section in + Section(section.title) { + ForEach(section.entries) { summary in + if let entry = viewModel.historyEntry(withID: summary.historyEntryID) { + NavigationLink { + HistoryDetailView(viewModel: viewModel, entry: entry) + } label: { + TimelineRow(summary: summary) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + viewModel.toggleSnapshotSelection(entry) + } label: { + Label( + viewModel.selectedSnapshotIDs.contains(entry.id) ? "Selected" : "Compare", + systemImage: viewModel.selectedSnapshotIDs.contains(entry.id) ? "checkmark.circle.fill" : "arrow.left.arrow.right" + ) + } + + Button(role: .destructive) { + viewModel.removeHistoryEntries(withIDs: [entry.id]) + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle(domain) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + Picker("Grouping", selection: $viewModel.timelineGrouping) { + ForEach(TimelineGroupingOption.allCases) { option in + Text(option.title).tag(option) + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + } + + Button("Compare") { + presentedDiff = viewModel.generateDiffForSelectedSnapshots() + focusedSectionID = viewModel.currentDiffTargetSectionID + } + .disabled(viewModel.selectedSnapshots.count != 2) + + Menu("Export") { + Button("Export TXT") { + ExportPresenter.share( + filename: "\(domain)-timeline.txt", + contents: viewModel.exportTimelineText(domain: domain, includeDiffSummary: true) + ) + } + + Button("Export JSON") { + guard let data = viewModel.exportTimelineJSONData(domain: domain, includeDiffSummary: true) else { return } + ExportPresenter.share(filename: "\(domain)-timeline.json", data: data) + } + } + } + } + .sheet(item: $presentedDiff) { diff in + NavigationStack { + TimelineDiffView(viewModel: viewModel, diff: diff, focusedSectionID: $focusedSectionID) + } + } + } +} + +private struct TimelineRow: View { + @Environment(\.appDensity) private var appDensity + let summary: SnapshotSummary + + var body: some View { + VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { + HStack(alignment: .center, spacing: 8) { + Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened)) + .font(appDensity.font(.callout)) + .foregroundStyle(.primary) + Spacer() + if let severity = summary.severitySummary { + AppStatusBadgeView( + model: .init( + title: severity.title, + systemImage: "arrow.triangle.2.circlepath", + foregroundColor: severity == .high ? .red : .yellow, + backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16) + ) + ) + } + } + + Text(summary.changeSummaryMessage ?? "No change summary") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack(spacing: 8) { + AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability)) + if let primaryIP = summary.primaryIP { + AppStatusBadgeView( + model: .init( + title: primaryIP, + systemImage: "network", + foregroundColor: .blue, + backgroundColor: .blue.opacity(0.16) + ) + ) + } + if let tlsStatus = summary.tlsStatus { + AppStatusBadgeView( + model: .init( + title: tlsStatus.capitalized, + systemImage: "lock.shield", + foregroundColor: .green, + backgroundColor: .green.opacity(0.16) + ) + ) + } + if let riskScore = summary.riskScore { + AppStatusBadgeView( + model: .init( + title: "Risk \(riskScore)", + systemImage: "exclamationmark.shield", + foregroundColor: riskScore >= 70 ? .red : .orange, + backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16) + ) + ) + } + } + + HStack(spacing: 8) { + if let snapshotIndex = summary.snapshotIndex { + Text("#\(snapshotIndex)") + } + Text(summary.timestamp.formatted(.relative(presentation: .named))) + if summary.changeCount > 0 { + Text("\(summary.changeCount) changes") + } + } + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } +} + +struct TimelineDiffView: View { + @Bindable var viewModel: DomainViewModel + let diff: DomainDiff + @Binding var focusedSectionID: String? + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack { + Button("Previous Change") { + viewModel.moveToPreviousDiffChange() + focusedSectionID = viewModel.currentDiffTargetSectionID + scroll(proxy: proxy) + } + .disabled(viewModel.activeDiffChangeIndex == 0) + + Button("Next Change") { + viewModel.moveToNextDiffChange() + focusedSectionID = viewModel.currentDiffTargetSectionID + scroll(proxy: proxy) + } + .disabled(viewModel.activeDomainDiff?.changedSectionIDs.isEmpty != false || viewModel.currentDiffTargetSectionID == viewModel.activeDomainDiff?.changedSectionIDs.last) + + Spacer() + } + + DomainDiffView( + title: "Snapshot Diff", + sections: diff.sections, + contextNote: diff.contextNote, + showsUnchanged: false, + highlightedSectionID: focusedSectionID + ) + } + .padding() + } + .background(Color.black) + .navigationTitle("Compare Snapshots") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + scroll(proxy: proxy) + } + .onChange(of: focusedSectionID) { _, _ in + scroll(proxy: proxy) + } + } + } + + private func scroll(proxy: ScrollViewProxy) { + guard let focusedSectionID else { return } + withAnimation { + proxy.scrollTo(focusedSectionID, anchor: .top) + } + } +} diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 1a38282..31c6202 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -483,7 +483,8 @@ struct TrackedDomainDetailView: View { contextNote: latestSnapshots.count >= 2 ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot) : nil, - showsUnchanged: false + showsUnchanged: false, + highlightedSectionID: nil ) } .listRowBackground(Color.clear) diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift index bb9dd80..da289a9 100644 --- a/DomainDigCLI.swift +++ b/DomainDigCLI.swift @@ -17,6 +17,16 @@ struct DomainDigCLI { return } + if arguments.first == "history" { + runHistoryCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON) + return + } + + if arguments.first == "diff" { + runDiffCommand(arguments: Array(arguments.dropFirst()), wantsJSON: wantsJSON) + return + } + if arguments.first == "monitor" { await runMonitorCommand(wantsJSON: wantsJSON) return @@ -216,6 +226,10 @@ struct DomainDigCLI { isPartialSnapshot: snapshot.isPartialSnapshot, validationIssues: snapshot.validationIssues, totalLookupDurationMs: snapshot.totalLookupDurationMs, + snapshotIndex: snapshot.snapshotIndex, + previousSnapshotID: snapshot.previousSnapshotID, + changeCount: snapshot.changeCount, + severitySummary: snapshot.severitySummary, dnsSections: snapshot.dnsSections, dnsError: snapshot.dnsError, availabilityResult: snapshot.availabilityResult, @@ -265,6 +279,107 @@ struct DomainDigCLI { DomainDataPortabilityService.loadHistoryEntries() } + private static func runHistoryCommand(arguments: [String], wantsJSON: Bool) { + guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else { + fputs("usage: domaindig history <domain> [--json]\n", stderr) + Foundation.exit(1) + } + + let entries = loadHistoryEntries() + .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + .sorted { $0.timestamp > $1.timestamp } + + if wantsJSON { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let payload = entries.map { entry in + [ + "id": entry.id.uuidString, + "timestamp": ISO8601DateFormatter().string(from: entry.timestamp), + "changeSummary": entry.changeSummary?.message ?? "No change summary", + "changeCount": "\(entry.changeCount)", + "severity": entry.severitySummary?.title ?? "N/A" + ] + } + if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]) { + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data([0x0A])) + return + } + } + + let lines = entries.map { entry in + [ + entry.id.uuidString, + entry.timestamp.formatted(date: .abbreviated, time: .shortened), + entry.changeSummary?.message ?? "No change summary" + ].joined(separator: " | ") + } + + FileHandle.standardOutput.write(Data((lines.isEmpty ? "No history found.\n" : lines.joined(separator: "\n") + "\n").utf8)) + } + + private static func runDiffCommand(arguments: [String], wantsJSON: Bool) { + guard let domain = arguments.first(where: { !$0.hasPrefix("-") }) else { + fputs("usage: domaindig diff <domain> --from <id> --to <id> [--json]\n", stderr) + Foundation.exit(1) + } + + guard let fromID = optionValue(named: "--from", in: arguments), + let toID = optionValue(named: "--to", in: arguments), + let fromUUID = UUID(uuidString: fromID), + let toUUID = UUID(uuidString: toID) else { + fputs("domaindig diff: --from and --to must be valid snapshot IDs\n", stderr) + Foundation.exit(1) + } + + let entries = loadHistoryEntries().filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } + guard let fromEntry = entries.first(where: { $0.id == fromUUID }), + let toEntry = entries.first(where: { $0.id == toUUID }) else { + fputs("domaindig diff: snapshots not found for domain\n", stderr) + Foundation.exit(1) + } + + let orderedEntries = [fromEntry, toEntry].sorted { $0.timestamp < $1.timestamp } + let diff = DiffService.compare(from: orderedEntries[0].snapshot, to: orderedEntries[1].snapshot) + + if wantsJSON { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(diff) { + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write(Data([0x0A])) + return + } + } + + var lines = [ + "DomainDig Diff", + "Domain: \(domain)", + "From: \(orderedEntries[0].id.uuidString)", + "To: \(orderedEntries[1].id.uuidString)" + ] + + for section in diff.sections where section.hasChanges { + lines.append("") + lines.append(section.title) + for item in section.items where item.hasChanges { + lines.append("\(item.changeType.marker) \(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")") + } + } + + FileHandle.standardOutput.write(Data((lines.joined(separator: "\n") + "\n").utf8)) + } + + private static func optionValue(named name: String, in arguments: [String]) -> String? { + guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else { + return nil + } + return arguments[index + 1] + } + private static func runBackupCommand(arguments: [String], wantsJSON: Bool) { guard let subcommand = arguments.first else { fputs(usageText, stderr) @@ -424,6 +539,8 @@ struct DomainDigCLI { private static var usageText: String { """ usage: domaindig <domain> [--json] [--ownership-history] [--dns-history] [--extended-subdomains] [--pricing] [--show-usage] + domaindig history <domain> [--json] + domaindig diff <domain> --from <id> --to <id> [--json] domaindig monitor [--json] domaindig backup export [path] domaindig backup import <path> [--replace] diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift index a4a1172..55e87f5 100644 --- a/DomainInspectionService.swift +++ b/DomainInspectionService.swift @@ -15,6 +15,7 @@ struct DomainInspectionService { func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot { let normalizedDomain = normalize(domain) + let inspectionStartedAt = DomainDebugLog.signpostStart("Inspection.inspectSnapshot", domain: normalizedDomain) let startedAt = Date() let resolverDisplayName = DNSLookupService.currentResolverDisplayName() let resolverURLString = DNSLookupService.currentResolverURLString() @@ -29,13 +30,12 @@ struct DomainInspectionService { async let sslFetch = runtime.ssl(domain: normalizedDomain) async let hstsFetch = runtime.hsts(domain: normalizedDomain) async let httpFetch = runtime.http(domain: normalizedDomain) - async let reachabilityFetch = runtime.reachability(domain: normalizedDomain) async let ownershipFetch = runtime.ownership(domain: normalizedDomain) async let redirectFetch = runtime.redirectChain(domain: normalizedDomain) async let subdomainFetch = runtime.subdomains(domain: normalizedDomain) - async let portScanFetch = runtime.portScan(domain: normalizedDomain) let resolvedDNS = await dnsFetch + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=dns source=\(resolvedDNS.source.rawValue)") let dnsResult = normalizeErrors(in: resolvedDNS.value) track( .dns, @@ -49,6 +49,7 @@ struct DomainInspectionService { captureFailure(for: .dns, result: dnsResult, into: &errorDetails) let availability = await availabilityFetch + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=availability source=\(availability.source.rawValue)") track( .availability, source: availability.source, @@ -60,6 +61,7 @@ struct DomainInspectionService { ) let resolvedSSL = await sslFetch + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ssl source=\(resolvedSSL.source.rawValue)") let sslResult = normalizeErrors(in: resolvedSSL.value) track( .ssl, @@ -73,6 +75,7 @@ struct DomainInspectionService { captureFailure(for: .ssl, result: sslResult, into: &errorDetails) let hsts = await hstsFetch + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=hsts source=\(hsts.source.rawValue)") track( .hsts, source: hsts.source, @@ -84,6 +87,7 @@ struct DomainInspectionService { ) let http = await httpFetch + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=http source=\(http.source.rawValue)") let httpResult = normalizeErrors(in: http.value) track( .httpHeaders, @@ -96,20 +100,8 @@ struct DomainInspectionService { ) captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails) - let reachability = await reachabilityFetch - let reachabilityResult = normalizeErrors(in: reachability.value) - track( - .reachability, - source: reachability.source, - provenance: provenance(for: .reachability, source: reachability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), - cachedSections: &cachedSections, - sectionSources: §ionSources, - provenanceBySection: &provenanceBySection, - dataSources: &dataSources - ) - captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails) - let resolvedOwnership = await ownershipFetch + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ownership source=\(resolvedOwnership.source.rawValue)") let ownershipResult = normalizeErrors(in: resolvedOwnership.value) track( .ownership, @@ -123,6 +115,7 @@ struct DomainInspectionService { captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails) let redirects = await redirectFetch + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=redirect source=\(redirects.source.rawValue)") let redirectResult = normalizeErrors(in: redirects.value) track( .redirectChain, @@ -136,6 +129,7 @@ struct DomainInspectionService { captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails) let resolvedSubdomains = await subdomainFetch + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=subdomains source=\(resolvedSubdomains.source.rawValue)") let subdomainResult = normalizeErrors(in: resolvedSubdomains.value) track( .subdomains, @@ -148,33 +142,68 @@ struct DomainInspectionService { ) captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails) - let ports = await portScanFetch - let portScanResult = normalizeErrors(in: ports.value) - track( - .portScan, - source: ports.source, - provenance: provenance(for: .portScan, source: ports.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), - cachedSections: &cachedSections, - sectionSources: §ionSources, - provenanceBySection: &provenanceBySection, - dataSources: &dataSources - ) - captureFailure(for: .portScan, result: portScanResult, into: &errorDetails) - let dnsSections = mapServiceResult(dnsResult, emptyValue: []) let sslInfo = mapOptionalValueServiceResult(sslResult) let httpHeadersResult = mapHTTPResult(httpResult) - let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: []) let redirectChain = mapServiceResult(redirectResult, emptyValue: []) let ownership = mapOptionalValueServiceResult(ownershipResult) let subdomains = mapServiceResult(subdomainResult, emptyValue: []) - let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain) let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value + let hasNetworkTarget = dnsSections.value.contains { section in + (section.recordType == .A || section.recordType == .AAAA) + && (!section.records.isEmpty || !section.wildcardRecords.isEmpty) + } let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value) let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP) + let reachabilityOutcome: CachedLookupResult<ServiceResult<[PortReachability]>> + if hasNetworkTarget { + reachabilityOutcome = await runtime.reachability(domain: normalizedDomain) + } else { + reachabilityOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live) + } + let reachabilityResult = normalizeErrors(in: reachabilityOutcome.value) + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=reachability hasNetworkTarget=\(hasNetworkTarget) source=\(reachabilityOutcome.source.rawValue)") + track( + .reachability, + source: reachabilityOutcome.source, + provenance: provenance(for: .reachability, source: reachabilityOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + if hasNetworkTarget { + captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails) + } + let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: []) + + let portScanOutcome: CachedLookupResult<ServiceResult<[PortScanResult]>> + if hasNetworkTarget { + portScanOutcome = await runtime.portScan(domain: normalizedDomain) + } else { + portScanOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live) + } + let portScanResult = normalizeErrors(in: portScanOutcome.value) + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=portScan hasNetworkTarget=\(hasNetworkTarget) source=\(portScanOutcome.source.rawValue)") + track( + .portScan, + source: portScanOutcome.source, + provenance: provenance(for: .portScan, source: portScanOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + if hasNetworkTarget { + captureFailure(for: .portScan, result: portScanResult, into: &errorDetails) + } + let portScanResults = hasNetworkTarget + ? await mapPortScanResult(portScanResult, domain: normalizedDomain) + : (value: [], message: nil) + let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>> if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity { emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached) @@ -184,6 +213,7 @@ struct DomainInspectionService { emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords) } let normalizedEmailResult = normalizeErrors(in: emailOutcome.value) + DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=email source=\(emailOutcome.source.rawValue)") track( .emailSecurity, source: emailOutcome.source, @@ -271,7 +301,7 @@ struct DomainInspectionService { let geolocationConfidence = confidenceForGeolocation(result: geolocation.value, error: geolocation.message) let validationIssues = validationIssues(for: normalizedDomain, snapshotTimestamp: startedAt, availability: availability.value, dnsSections: dnsSections.value, provenanceBySection: provenanceBySection) - return LookupSnapshot( + let snapshot = LookupSnapshot( historyEntryID: nil, domain: availability.value.domain, timestamp: Date(), @@ -291,6 +321,10 @@ struct DomainInspectionService { isPartialSnapshot: !validationIssues.isEmpty, validationIssues: validationIssues, totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), + snapshotIndex: nil, + previousSnapshotID: previousSnapshot?.historyEntryID, + changeCount: 0, + severitySummary: nil, dnsSections: dnsSections.value, dnsError: dnsSections.message, availabilityResult: availability.value, @@ -334,6 +368,13 @@ struct DomainInspectionService { cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue }, statusMessage: nil ) + DomainDebugLog.signpostEnd( + "Inspection.inspectSnapshot", + start: inspectionStartedAt, + domain: normalizedDomain, + extra: "resultSource=\(snapshot.resultSource.rawValue) cachedSections=\(snapshot.cachedSections.count) partial=\(snapshot.isPartialSnapshot)" + ) + return snapshot } private func normalize(_ domain: String) -> String { diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift index 3ed88ac..e48b673 100644 --- a/DomainReportBuilder.swift +++ b/DomainReportBuilder.swift @@ -62,6 +62,10 @@ struct DomainReportMetadata: Codable { let isPartialSnapshot: Bool let errorDetails: [LookupSectionKind: InspectionFailure] let statusMessage: String? + let snapshotIndex: Int? + let previousSnapshotID: UUID? + let changeCount: Int + let severitySummary: ChangeSeverity? } struct DNSResultSummary: Codable { @@ -123,26 +127,103 @@ struct DomainReportBuilder { func build( from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil, - workflowContext: DomainWorkflowContext? = nil + workflowContext: DomainWorkflowContext? = nil, + deriveChangeSummary: Bool = true ) -> DomainReport { + let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain) let primaryIP = primaryIPAddress(from: snapshot) let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) let changeSummary: DomainChangeSummary? if let existingChangeSummary = snapshot.changeSummary, existingChangeSummary.riskAssessment != nil { changeSummary = existingChangeSummary - } else { - changeSummary = previousSnapshot.map { - DomainDiffService.summary( - from: $0, - to: snapshot, - generatedAt: snapshot.timestamp, - riskAssessment: analysis.riskAssessment, - insights: analysis.insights - ) + } else if deriveChangeSummary, let previousSnapshot { + let previousReport = build( + from: previousSnapshot, + workflowContext: workflowContext, + deriveChangeSummary: false + ) + let currentReport = buildBaseReport( + from: snapshot, + previousSnapshot: previousSnapshot, + workflowContext: workflowContext, + analysis: analysis, + primaryIP: primaryIP, + changeSummary: nil as DomainChangeSummary? + ) + let diff = DiffService.compare(from: previousReport, to: currentReport) + let changedItems = diff.sections.flatMap { $0.items }.filter { $0.hasChanges } + let highlights = diff.changedSectionTitles + let severity = changedItems.map { $0.severity }.max() ?? .low + let message = DiffService.summaryMessage(from: highlights, changeCount: changedItems.count) + let observedFacts = changedItems.prefix(4).map { item in + "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")" } + let previousRiskScore = previousReport.riskAssessment.score + let riskScoreDelta = analysis.riskAssessment.score - previousRiskScore + let impactClassification = DomainInsightEngine.impactClassification( + severity: severity, + riskDelta: riskScoreDelta, + changedSections: highlights + ) + + changeSummary = DomainChangeSummary( + hasChanges: !changedItems.isEmpty, + changedSections: highlights, + message: message, + severity: severity, + impactClassification: impactClassification, + generatedAt: snapshot.timestamp, + observedFacts: observedFacts, + inferredConclusions: highlights.isEmpty ? [] : [message], + contextNote: diff.contextNote, + riskAssessment: analysis.riskAssessment, + insights: analysis.insights, + riskScoreDelta: riskScoreDelta + ) + } else { + changeSummary = nil } - return DomainReport( + let report = buildBaseReport( + from: snapshot, + previousSnapshot: previousSnapshot, + workflowContext: workflowContext, + analysis: analysis, + primaryIP: primaryIP, + changeSummary: changeSummary + ) + DomainDebugLog.signpostEnd( + "DomainReportBuilder.build", + start: buildStartedAt, + domain: snapshot.domain, + extra: "risk=\(report.riskAssessment.score) insights=\(report.insights.count)" + ) + return report + } + + func build( + from entry: HistoryEntry, + previousSnapshot: LookupSnapshot? = nil, + workflowContext: DomainWorkflowContext? = nil, + deriveChangeSummary: Bool = true + ) -> DomainReport { + build( + from: entry.snapshot, + previousSnapshot: previousSnapshot, + workflowContext: workflowContext, + deriveChangeSummary: deriveChangeSummary + ) + } + + private func buildBaseReport( + from snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot?, + workflowContext: DomainWorkflowContext?, + analysis: DomainAnalysisBundle, + primaryIP: String?, + changeSummary: DomainChangeSummary? + ) -> DomainReport { + DomainReport( domain: snapshot.domain, timestamp: snapshot.timestamp, provenance: DomainReportProvenance( @@ -230,7 +311,7 @@ struct DomainReportBuilder { changeSummary: changeSummary, workflowContext: workflowContext, metadata: DomainReportMetadata( - schemaVersion: "3.2.0", + schemaVersion: "3.7.0", resolverDisplayName: snapshot.resolverDisplayName, resolverURLString: snapshot.resolverURLString, appVersion: snapshot.appVersion, @@ -239,19 +320,15 @@ struct DomainReportBuilder { validationIssues: snapshot.validationIssues, isPartialSnapshot: snapshot.isPartialSnapshot, errorDetails: snapshot.errorDetails, - statusMessage: snapshot.statusMessage + statusMessage: snapshot.statusMessage, + snapshotIndex: snapshot.snapshotIndex, + previousSnapshotID: snapshot.previousSnapshotID ?? previousSnapshot?.historyEntryID, + changeCount: snapshot.changeCount == 0 ? (changeSummary?.changedSections.count ?? 0) : snapshot.changeCount, + severitySummary: snapshot.severitySummary ?? changeSummary?.severity ) ) } - func build( - from entry: HistoryEntry, - previousSnapshot: LookupSnapshot? = nil, - workflowContext: DomainWorkflowContext? = nil - ) -> DomainReport { - build(from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext) - } - private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value } diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift index 4fa7470..55b7372 100644 --- a/DomainReportExporter.swift +++ b/DomainReportExporter.swift @@ -504,6 +504,62 @@ enum DomainReportExporter { .joined(separator: "\n") } + static func timelineText(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) -> String { + guard !reports.isEmpty else { + return "Timeline Export\nNo snapshots available for \(domain)." + } + + var lines = [ + "DomainDig Timeline Export", + "Domain: \(domain)", + "Snapshots: \(reports.count)" + ] + + for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) { + lines.append("") + lines.append("\(textDateFormatter.string(from: report.timestamp))") + lines.append("Summary: \(report.changeSummary?.message ?? "No change summary")") + lines.append("Severity: \(report.changeSummary?.severity.title ?? "N/A")") + if includeDiffSummary, let changeSummary = report.changeSummary { + lines.append("Changed Sections: \(changeSummary.changedSections.joined(separator: ", ").nilIfEmpty ?? "None")") + } + } + + return lines.joined(separator: "\n") + } + + static func timelineData(for reports: [DomainReport], domain: String, includeDiffSummary: Bool) throws -> Data { + struct TimelineExportEntry: Codable { + let timestamp: Date + let summary: String? + let severity: String? + let changedSections: [String]? + } + + struct TimelineExportPayload: Codable { + let domain: String + let exportedAt: Date + let snapshots: [TimelineExportEntry] + } + + let payload = TimelineExportPayload( + domain: domain, + exportedAt: Date(), + snapshots: reports + .sorted(by: { $0.timestamp > $1.timestamp }) + .map { report in + TimelineExportEntry( + timestamp: report.timestamp, + summary: report.changeSummary?.message, + severity: report.changeSummary?.severity.title, + changedSections: includeDiffSummary ? report.changeSummary?.changedSections : nil + ) + } + ) + + return try jsonEncoder.encode(payload) + } + private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) { lines.append("") lines.append(title) diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift index a817805..752e7f2 100644 --- a/LookupSnapshot.swift +++ b/LookupSnapshot.swift @@ -20,6 +20,10 @@ struct LookupSnapshot { let isPartialSnapshot: Bool let validationIssues: [String] let totalLookupDurationMs: Int? + let snapshotIndex: Int? + let previousSnapshotID: UUID? + let changeCount: Int + let severitySummary: ChangeSeverity? let dnsSections: [DNSSection] let dnsError: String? let availabilityResult: DomainAvailabilityResult? @@ -90,6 +94,10 @@ extension HistoryEntry { isPartialSnapshot: isPartialSnapshot, validationIssues: validationIssues, totalLookupDurationMs: totalLookupDurationMs, + snapshotIndex: snapshotIndex, + previousSnapshotID: previousSnapshotID, + changeCount: changeCount, + severitySummary: severitySummary, dnsSections: dnsSections, dnsError: nil, availabilityResult: availabilityResult, |
