From 7b41195620eadd54d25fa98dcd36735cba906ba3 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 22 Apr 2026 09:47:24 -0500 Subject: feat(v2.7.0): add provenance, confidence, and reproducibility metadata * add result provenance across major sections * introduce confidence levels for ambiguous outputs * distinguish observed facts from inferred summaries * expand snapshot metadata for reproducibility * improve error classification and partial snapshot handling * include provenance and confidence in export * add local notes for tracked domains and history --- DomainDig.xcodeproj/project.pbxproj | 12 +- DomainDig/AppVersion.swift | 7 + DomainDig/ContentView.swift | 226 +++++++++++++++---- DomainDig/DomainAvailabilityService.swift | 8 +- DomainDig/DomainDiffService.swift | 27 ++- DomainDig/DomainViewModel.swift | 187 ++++++++++++++-- DomainDig/HistoryView.swift | 112 +++++++++- DomainDig/LookupRuntime.swift | 12 +- DomainDig/Models.swift | 138 +++++++++++- DomainDig/WatchlistView.swift | 25 ++- DomainInspectionService.swift | 347 +++++++++++++++++++++++++++--- DomainReportBuilder.swift | 28 +++ DomainReportExporter.swift | 98 ++++++++- LookupSnapshot.swift | 26 ++- 14 files changed, 1120 insertions(+), 133 deletions(-) create mode 100644 DomainDig/AppVersion.swift diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 1129505..3e7f5d6 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -134,7 +134,7 @@ }; 8BF124FB2F70000100933221 /* DomainDigCLI */ = { isa = PBXNativeTarget; - buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list */; + buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */; buildPhases = ( 8BF124FC2F70000100933221 /* Sources */, 8BF124FD2F70000100933221 /* Frameworks */, @@ -354,7 +354,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -371,7 +371,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.0; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -390,7 +390,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -407,7 +407,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.0; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -460,7 +460,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 8BBFF0292F987EF700E8E144 /* Build configuration list */ = { + 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */ = { isa = XCConfigurationList; buildConfigurations = ( 8BBFF0272F987E8700E8E144 /* Debug */, diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift new file mode 100644 index 0000000..a1516b8 --- /dev/null +++ b/DomainDig/AppVersion.swift @@ -0,0 +1,7 @@ +import Foundation + +enum AppVersion { + static var current: String { + "2.7.0" + } +} diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 21c37d2..ed2da49 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -42,15 +42,20 @@ struct ContentView: View { } if viewModel.hasRun { actionButtons - if let statusMessage = resultStatusMessage { + if !viewModel.resultsLoaded { + LookupProgressOverviewView(steps: viewModel.activeLoadingLabels) + .padding(.top, appDensity.metrics.cardSpacing) + } else if let statusMessage = resultStatusMessage { LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) .padding(.top, appDensity.metrics.cardSpacing) } - SummaryView(fields: viewModel.summaryFields) - .padding(.top, appDensity.metrics.cardSpacing) - if let changeSummary = viewModel.currentChangeSummary { - DomainChangeSummaryView(summary: changeSummary) + if viewModel.resultsLoaded { + SummaryView(fields: viewModel.summaryFields) .padding(.top, appDensity.metrics.cardSpacing) + if let changeSummary = viewModel.currentChangeSummary { + DomainChangeSummaryView(summary: changeSummary) + .padding(.top, appDensity.metrics.cardSpacing) + } } DomainSectionView( isCollapsed: sectionCollapsedBinding(.domain), @@ -59,6 +64,9 @@ struct ContentView: View { showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, availabilityLoading: viewModel.availabilityLoading, suggestionsLoading: viewModel.suggestionsLoading, + provenance: viewModel.currentSnapshot.provenanceBySection[.availability], + confidence: viewModel.currentSnapshot.availabilityConfidence, + snapshotNote: viewModel.currentSnapshot.note, trackedDomain: viewModel.currentTrackedDomain, trackingLimitMessage: viewModel.trackingLimitMessage, onTrack: { @@ -82,6 +90,8 @@ struct ContentView: View { rows: viewModel.ownershipRows, loading: viewModel.ownershipLoading, error: viewModel.ownershipError, + provenance: viewModel.currentSnapshot.provenanceBySection[.ownership], + confidence: viewModel.currentSnapshot.ownershipConfidence, showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -90,6 +100,8 @@ struct ContentView: View { rows: viewModel.subdomainRows, loading: viewModel.subdomainsLoading, error: viewModel.subdomainsError, + provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], + confidence: viewModel.currentSnapshot.subdomainConfidence, showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -97,6 +109,7 @@ struct ContentView: View { DomainDiffView( title: "Latest Changes", sections: viewModel.currentDiffSections, + contextNote: viewModel.currentChangeSummary?.contextNote, showsUnchanged: false ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -107,6 +120,8 @@ struct ContentView: View { sections: viewModel.dnsRows, ptrMessage: viewModel.ptrMessage, loading: viewModel.dnsLoading || viewModel.ptrLoading, + dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns], + ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr], sectionError: viewModel.dnsError ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -116,13 +131,16 @@ struct ContentView: View { sslInfo: viewModel.sslInfo, sslLoading: viewModel.sslLoading || viewModel.hstsLoading, sslError: viewModel.sslError, + tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl], responseRows: viewModel.webResponseRows, headers: viewModel.httpHeaders, headersLoading: viewModel.httpHeadersLoading, headersError: viewModel.httpHeadersError, + httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders], redirects: viewModel.redirectRows, redirectLoading: viewModel.redirectChainLoading, redirectError: viewModel.redirectChainError, + redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain], finalURL: viewModel.currentSnapshot.redirectChain.last?.url ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -130,6 +148,8 @@ struct ContentView: View { isCollapsed: sectionCollapsedBinding(.email), rows: viewModel.emailRows, loading: viewModel.emailSecurityLoading, + provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity], + confidence: viewModel.currentSnapshot.emailSecurityConfidence, error: viewModel.emailSecurityError ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -138,14 +158,18 @@ struct ContentView: View { reachabilityRows: viewModel.reachabilityRows, reachabilityLoading: viewModel.reachabilityLoading, reachabilityError: viewModel.reachabilityError, + reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability], locationRows: viewModel.locationRows, geolocation: viewModel.ipGeolocation, geolocationLoading: viewModel.ipGeolocationLoading, geolocationError: viewModel.ipGeolocationError, + geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation], + geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence, standardPortRows: viewModel.standardPortRows, customPortRows: viewModel.customPortRows, portScanLoading: viewModel.portScanLoading, portScanError: viewModel.portScanError, + portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan], customPortScanLoading: viewModel.customPortScanLoading, customPortScanError: viewModel.customPortScanError, isCloudflareProxied: viewModel.isCloudflareProxied, @@ -161,27 +185,6 @@ struct ContentView: View { .padding(.horizontal) .padding(.bottom, 32) } - .safeAreaInset(edge: .top) { - if viewModel.hasRun { - StickyLookupSummaryView( - domain: viewModel.searchedDomain, - availability: viewModel.availabilityResult?.status, - primaryIP: currentPrimaryIP, - sslInfo: viewModel.sslInfo, - sslError: viewModel.sslError, - emailSecurity: viewModel.emailSecurity, - emailError: viewModel.emailSecurityError, - changeSummary: viewModel.currentChangeSummary - ) - .padding(.horizontal) - .padding(.top, 6) - .background { - Rectangle() - .fill(.ultraThinMaterial) - .opacity(0.96) - } - } - } .background( LinearGradient( colors: [Color.black, Color(.systemGray6).opacity(0.12)], @@ -521,11 +524,7 @@ struct ContentView: View { } private var defaultCollapsedSections: Set { - var sections: Set = [] - if viewModel.standardPortRows.count + viewModel.customPortRows.count > 6 || currentPrimaryIP == nil { - sections.insert(.network) - } - return sections + [] } private var currentPrimaryIP: String? { @@ -636,6 +635,29 @@ struct StickyLookupSummaryView: View { } } +struct LookupProgressOverviewView: View { + @Environment(\.appDensity) private var appDensity + let steps: [String] + + var body: some View { + CardView(allowsHorizontalScroll: false) { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + VStack(alignment: .leading, spacing: 4) { + Text("Running lookup…") + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + Text(steps.isEmpty ? "Preparing requests" : steps.joined(separator: " • ")) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + } +} + struct LookupStatusBannerView: View { @Environment(\.appDensity) private var appDensity let message: String @@ -686,6 +708,7 @@ struct LookupStatusBannerView: View { struct DomainChangeSummaryView: View { @Environment(\.appDensity) private var appDensity let summary: DomainChangeSummary + @State private var showsDetails = false var body: some View { CardView(allowsHorizontalScroll: false) { @@ -706,9 +729,43 @@ struct DomainChangeSummaryView: View { .foregroundStyle(.secondary) } - Text(summary.message) + VStack(alignment: .leading, spacing: 4) { + Text("Inference") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + Text(summary.message) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + .lineLimit(2) + } + + if !summary.observedFacts.isEmpty || summary.contextNote != nil { + DisclosureGroup(showsDetails ? "Hide Details" : "Show Details", isExpanded: $showsDetails) { + VStack(alignment: .leading, spacing: 8) { + if !summary.observedFacts.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Observed") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + ForEach(Array(summary.observedFacts.enumerated()), id: \.offset) { _, fact in + Text(fact) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + } + } + } + + if let contextNote = summary.contextNote { + Text(contextNote) + .font(appDensity.font(.caption2)) + .foregroundStyle(.orange) + } + } + .padding(.top, 4) + } .font(appDensity.font(.caption)) - .foregroundStyle(.primary) + .tint(.secondary) + } } } @@ -727,6 +784,7 @@ struct DomainChangeSummaryView: View { struct DomainDiffView: View { let title: String let sections: [DomainDiffSection] + let contextNote: String? let showsUnchanged: Bool @State private var collapsedSections = Set() @@ -766,6 +824,9 @@ struct DomainDiffView: View { .font(.system(.caption, design: .monospaced)) } } + if let contextNote { + MessageCardView(text: contextNote, isError: false) + } if filteredSections.isEmpty { MessageCardView(text: "No comparison data available", isError: false) } else { @@ -917,6 +978,9 @@ struct DomainSectionView: View { let showSuggestions: Bool let availabilityLoading: Bool let suggestionsLoading: Bool + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? + let snapshotNote: String? let trackedDomain: TrackedDomain? let trackingLimitMessage: String? let onTrack: () -> Void @@ -953,6 +1017,11 @@ struct DomainSectionView: View { } } content: { CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView( + provenance: provenance, + confidence: confidence, + note: snapshotNote == nil ? nil : "Audit note present" + ) ForEach(rows) { row in LabeledValueRow(row: row) } @@ -986,7 +1055,7 @@ struct DomainSectionView: View { .foregroundStyle(.primary) .textSelection(.enabled) Spacer() - AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered)) + AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus)) } } } @@ -1001,11 +1070,14 @@ struct OwnershipSectionView: View { let rows: [InfoRowViewData] let loading: Bool let error: String? + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? let showsHistoryPlaceholder: Bool var body: some View { CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) if loading { ProgressView("Fetching RDAP ownership…") .appLoadingStyle() @@ -1033,11 +1105,14 @@ struct SubdomainsSectionView: View { let rows: [SubdomainRowViewData] let loading: Bool let error: String? + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? let showsExtendedPlaceholder: Bool var body: some View { CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) if loading { ProgressView("Checking certificate transparency…") .appLoadingStyle() @@ -1082,6 +1157,8 @@ struct DNSSectionView: View { let sections: [DNSRecordSectionViewData] let ptrMessage: SectionMessageViewData? let loading: Bool + let dnsProvenance: SectionProvenance? + let ptrProvenance: SectionProvenance? let sectionError: String? var body: some View { @@ -1091,6 +1168,11 @@ struct DNSSectionView: View { } else if let sectionError, sections.isEmpty { MessageCardView(text: sectionError, isError: true) } else { + if dnsProvenance != nil { + CardView(allowsHorizontalScroll: false) { + SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil) + } + } ForEach(sections) { section in CardView { Text(section.title) @@ -1124,6 +1206,7 @@ struct DNSSectionView: View { .font(.system(.subheadline, design: .monospaced)) .fontWeight(.semibold) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil) MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) } } @@ -1139,13 +1222,16 @@ struct WebSectionView: View { let sslInfo: SSLCertificateInfo? let sslLoading: Bool let sslError: String? + let tlsProvenance: SectionProvenance? let responseRows: [InfoRowViewData] let headers: [HTTPHeader] let headersLoading: Bool let headersError: String? + let httpProvenance: SectionProvenance? let redirects: [RedirectHopViewData] let redirectLoading: Bool let redirectError: String? + let redirectProvenance: SectionProvenance? let finalURL: String? var body: some View { @@ -1158,6 +1244,7 @@ struct WebSectionView: View { Spacer() AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) } + SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil) if sslLoading { ProgressView("Checking certificate…") .appLoadingStyle() @@ -1188,6 +1275,7 @@ struct WebSectionView: View { Text("Headers") .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: httpProvenance, confidence: nil) if headersLoading { ProgressView("Fetching headers…") .appLoadingStyle() @@ -1225,6 +1313,7 @@ struct WebSectionView: View { AppCopyButton(value: finalURL, label: "Copy redirect URL") } } + SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil) if redirectLoading { ProgressView("Tracing redirects…") .appLoadingStyle() @@ -1268,11 +1357,14 @@ struct EmailSectionView: View { @Binding var isCollapsed: Bool let rows: [EmailRowViewData] let loading: Bool + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? let error: String? var body: some View { CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { CardView { + SectionTrustMetadataView(provenance: provenance, confidence: confidence) HStack { Spacer() AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) @@ -1331,14 +1423,18 @@ struct NetworkSectionView: View { let reachabilityRows: [ReachabilityRowViewData] let reachabilityLoading: Bool let reachabilityError: String? + let reachabilityProvenance: SectionProvenance? let locationRows: [InfoRowViewData] let geolocation: IPGeolocation? let geolocationLoading: Bool let geolocationError: String? + let geolocationProvenance: SectionProvenance? + let geolocationConfidence: ConfidenceLevel? let standardPortRows: [PortScanRowViewData] let customPortRows: [PortScanRowViewData] let portScanLoading: Bool let portScanError: String? + let portScanProvenance: SectionProvenance? let customPortScanLoading: Bool let customPortScanError: String? let isCloudflareProxied: Bool @@ -1352,6 +1448,7 @@ struct NetworkSectionView: View { Text("Reachability") .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil) if reachabilityLoading { ProgressView("Checking ports…") .appLoadingStyle() @@ -1376,6 +1473,7 @@ struct NetworkSectionView: View { Text("Location") .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence) if geolocationLoading { ProgressView("Looking up location…") .appLoadingStyle() @@ -1407,6 +1505,7 @@ struct NetworkSectionView: View { Text("Port Scan") .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) + SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil) if isCloudflareProxied { Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") @@ -1609,6 +1708,62 @@ struct MessageRowView: View { } } +struct SectionTrustMetadataView: View { + @Environment(\.appDensity) private var appDensity + let provenance: SectionProvenance? + let confidence: ConfidenceLevel? + let note: String? + + init(provenance: SectionProvenance?, confidence: ConfidenceLevel?, note: String? = nil) { + self.provenance = provenance + self.confidence = confidence + self.note = note + } + + var body: some View { + if provenance != nil || confidence != nil || note != nil { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if let confidence { + Text("Confidence \(confidence.title)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + if let provenance { + Text(provenance.provider ?? provenance.source) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + Text(provenance.resultSource.label) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + DisclosureGroup("Details") { + VStack(alignment: .leading, spacing: 4) { + if let provenance { + LabeledValueRow(row: .init(label: "Method", value: provenance.source, tone: .secondary)) + if let provider = provenance.provider { + LabeledValueRow(row: .init(label: "Provider", value: provider, tone: .secondary)) + } + if let resolver = provenance.resolver { + LabeledValueRow(row: .init(label: "Resolver", value: resolver, tone: .secondary)) + } + LabeledValueRow(row: .init(label: "Collected", value: provenance.collectedAt.formatted(date: .abbreviated, time: .shortened), tone: .secondary)) + LabeledValueRow(row: .init(label: "Mode", value: provenance.resultSource.label, tone: .secondary)) + } + if let note { + LabeledValueRow(row: .init(label: "Note", value: note, tone: .secondary)) + } + } + .padding(.top, 4) + } + .font(appDensity.font(.caption)) + .tint(.secondary) + } + } + } +} + struct LabeledValueRow: View { @Environment(\.appDensity) private var appDensity let row: InfoRowViewData @@ -1737,7 +1892,6 @@ private struct SettingsView: View { Section("About") { LabeledContent("Version", value: appVersion) LabeledContent("Storage", value: "Local-only") - LabeledContent("Focus", value: "Readable domain inspection") } } .navigationTitle("Settings") @@ -1776,7 +1930,7 @@ private struct SettingsView: View { } private var appVersion: String { - Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0" + AppVersion.current } } diff --git a/DomainDig/DomainAvailabilityService.swift b/DomainDig/DomainAvailabilityService.swift index 9a350ef..59f6afa 100644 --- a/DomainDig/DomainAvailabilityService.swift +++ b/DomainDig/DomainAvailabilityService.swift @@ -15,8 +15,12 @@ struct DomainAvailabilityService { } let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain) - let method = fallbackStatus == .registered ? "dns" : "fallback" - debugLog(method, domain: normalizedDomain, status: fallbackStatus) + if fallbackStatus == .registered { + debugLog("dns-evidence", domain: normalizedDomain, details: "DNS exists but RDAP did not confirm registration") + return DomainAvailabilityResult(domain: normalizedDomain, status: .unknown) + } + + debugLog("fallback", domain: normalizedDomain, status: fallbackStatus) return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus) } diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift index 340873d..528841d 100644 --- a/DomainDig/DomainDiffService.swift +++ b/DomainDig/DomainDiffService.swift @@ -67,16 +67,33 @@ enum DomainDiffService { 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) return DomainChangeSummary( hasChanges: !allChangedItems.isEmpty, changedSections: highlights, message: message, severity: severity, - generatedAt: generatedAt + generatedAt: generatedAt, + observedFacts: observedFacts, + inferredConclusions: inferredConclusions, + contextNote: contextNote ) } + 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 @@ -410,6 +427,14 @@ enum DomainDiffService { 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 diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 326db3a..3373447 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -87,6 +87,7 @@ struct SubdomainRowViewData: Identifiable { struct DomainSuggestionViewData: Identifiable { let id: UUID let domain: String + let availabilityStatus: DomainAvailabilityStatus let status: String let tone: ResultTone } @@ -204,13 +205,7 @@ final class DomainViewModel { private static let historyKey = "lookupHistory" private static let maxHistory = 250 - var history: [HistoryEntry] = { - guard let data = UserDefaults.standard.data(forKey: historyKey), - let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { - return [] - } - return entries - }() + var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries() var historySearchText = "" var historyDateFilter: HistoryDateFilter = .all var historyChangeFilter: ChangeFilterOption = .all @@ -246,6 +241,24 @@ final class DomainViewModel { !customPortScanLoading } + var activeLoadingLabels: [String] { + var labels: [String] = [] + if availabilityLoading { labels.append("Availability") } + if dnsLoading { labels.append("DNS") } + if sslLoading || hstsLoading { labels.append("TLS") } + if httpHeadersLoading { labels.append("HTTP") } + if ownershipLoading { labels.append("Ownership") } + if emailSecurityLoading { labels.append("Email") } + if subdomainsLoading { labels.append("Subdomains") } + if redirectChainLoading { labels.append("Redirects") } + if reachabilityLoading { labels.append("Reachability") } + if ipGeolocationLoading { labels.append("Geolocation") } + if ptrLoading { labels.append("PTR") } + if portScanLoading { labels.append("Port Scan") } + if customPortScanLoading { labels.append("Custom Ports") } + return labels + } + var isCloudflareProxied: Bool { httpHeaders.contains { $0.name.lowercased() == "cf-ray" } } @@ -365,8 +378,20 @@ final class DomainViewModel { domain: searchedDomain, timestamp: currentSnapshotTimestamp, trackedDomainID: currentTrackedDomain?.id, + note: currentHistoryEntry?.note ?? currentTrackedDomain?.note, + appVersion: AppVersion.current, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, + dataSources: currentHistoryEntry?.dataSources ?? [], + provenanceBySection: currentHistoryEntry?.provenanceBySection ?? [:], + availabilityConfidence: currentHistoryEntry?.availabilityConfidence, + ownershipConfidence: currentHistoryEntry?.ownershipConfidence, + subdomainConfidence: currentHistoryEntry?.subdomainConfidence, + emailSecurityConfidence: currentHistoryEntry?.emailSecurityConfidence, + geolocationConfidence: currentHistoryEntry?.geolocationConfidence, + errorDetails: currentHistoryEntry?.errorDetails ?? [:], + isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false, + validationIssues: currentHistoryEntry?.validationIssues ?? [], totalLookupDurationMs: lastLookupDurationMs, dnsSections: dnsSections, dnsError: dnsError, @@ -405,6 +430,11 @@ final class DomainViewModel { ) } + private var currentHistoryEntry: HistoryEntry? { + guard let currentHistoryEntryID else { return nil } + return history.first(where: { $0.id == currentHistoryEntryID }) + } + var currentReport: DomainReport? { guard !searchedDomain.isEmpty else { return nil } return reportBuilder.build( @@ -543,9 +573,7 @@ final class DomainViewModel { } func rerunInspection(for trackedDomain: TrackedDomain) { - domain = trackedDomain.domain - run() - rerunNavigationToken = UUID() + rerunInspection(for: trackedDomain, useSnapshotResolver: false) } func deleteTrackedDomains(at offsets: IndexSet) { @@ -609,13 +637,24 @@ final class DomainViewModel { UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) } - func rerunLookup(from entry: HistoryEntry) { - UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) + func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { + if useSnapshotResolver { + UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) + } domain = entry.domain run() rerunNavigationToken = UUID() } + func rerunInspection(for trackedDomain: TrackedDomain, useSnapshotResolver: Bool) { + if useSnapshotResolver, let snapshot = latestSnapshot(for: trackedDomain) { + UserDefaults.standard.set(snapshot.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) + } + domain = trackedDomain.domain + run() + rerunNavigationToken = UUID() + } + func reset() { lookupTask?.cancel() customPortScanTask?.cancel() @@ -853,8 +892,20 @@ final class DomainViewModel { domain: previousSnapshot.domain, timestamp: previousSnapshot.timestamp, trackedDomainID: previousSnapshot.trackedDomainID, + note: previousSnapshot.note, + appVersion: previousSnapshot.appVersion, resolverDisplayName: previousSnapshot.resolverDisplayName, resolverURLString: previousSnapshot.resolverURLString, + dataSources: previousSnapshot.dataSources, + provenanceBySection: previousSnapshot.provenanceBySection, + availabilityConfidence: previousSnapshot.availabilityConfidence, + ownershipConfidence: previousSnapshot.ownershipConfidence, + subdomainConfidence: previousSnapshot.subdomainConfidence, + emailSecurityConfidence: previousSnapshot.emailSecurityConfidence, + geolocationConfidence: previousSnapshot.geolocationConfidence, + errorDetails: previousSnapshot.errorDetails, + isPartialSnapshot: previousSnapshot.isPartialSnapshot, + validationIssues: previousSnapshot.validationIssues, totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, dnsSections: previousSnapshot.dnsSections, dnsError: previousSnapshot.dnsError, @@ -1232,6 +1283,7 @@ final class DomainViewModel { domain: snapshot.domain, timestamp: snapshot.timestamp, trackedDomainID: trackedDomainID, + note: currentHistoryEntry?.note, dnsSections: snapshot.dnsSections, sslInfo: snapshot.sslInfo, httpHeaders: snapshot.httpHeaders, @@ -1247,6 +1299,18 @@ final class DomainViewModel { hstsPreloaded: snapshot.hstsPreloaded, availabilityResult: snapshot.availabilityResult, suggestions: snapshot.suggestions, + appVersion: snapshot.appVersion, + resultSource: snapshot.resultSource, + dataSources: snapshot.dataSources, + provenanceBySection: snapshot.provenanceBySection, + availabilityConfidence: snapshot.availabilityConfidence, + ownershipConfidence: snapshot.ownershipConfidence, + subdomainConfidence: snapshot.subdomainConfidence, + emailSecurityConfidence: snapshot.emailSecurityConfidence, + geolocationConfidence: snapshot.geolocationConfidence, + errorDetails: snapshot.errorDetails, + isPartialSnapshot: snapshot.isPartialSnapshot, + validationIssues: snapshot.validationIssues, resolverDisplayName: snapshot.resolverDisplayName, resolverURLString: snapshot.resolverURLString, totalLookupDurationMs: snapshot.totalLookupDurationMs, @@ -1302,6 +1366,12 @@ final class DomainViewModel { } } + func updateHistoryNote(_ note: String, for entry: HistoryEntry) { + guard let index = history.firstIndex(where: { $0.id == entry.id }) else { return } + history[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + persistHistory() + } + private func persistTrackedDomains() { if let data = try? JSONEncoder().encode(trackedDomains) { UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) @@ -1777,6 +1847,22 @@ final class DomainViewModel { trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary } + func latestSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot? { + recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot + } + + func resolverMismatchNote(for entry: HistoryEntry) -> String? { + guard entry.resolverURLString != resolverURLString else { return nil } + return "Current resolver differs from this snapshot. Re-running may produce different evidence." + } + + func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? { + guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else { + return nil + } + return "Current resolver differs from the latest snapshot for this tracked domain." + } + func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { let siblings = history.filter { candidate in if let trackedDomainID = entry.trackedDomainID { @@ -1834,8 +1920,20 @@ final class DomainViewModel { domain: trackedDomain.domain, timestamp: trackedDomain.updatedAt, trackedDomainID: trackedDomain.id, + note: trackedDomain.note, + appVersion: AppVersion.current, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, + dataSources: [], + provenanceBySection: [:], + availabilityConfidence: nil, + ownershipConfidence: nil, + subdomainConfidence: nil, + emailSecurityConfidence: nil, + geolocationConfidence: nil, + errorDetails: [:], + isPartialSnapshot: true, + validationIssues: ["No stored snapshot data available"], totalLookupDurationMs: nil, dnsSections: [], dnsError: nil, @@ -1874,6 +1972,31 @@ final class DomainViewModel { ) } + private static func loadHistoryEntries() -> [HistoryEntry] { + let defaults = UserDefaults.standard + guard let data = defaults.data(forKey: historyKey) else { + return [] + } + + if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) { + return entries + } + + guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { + return [] + } + + let decoder = JSONDecoder() + return rawArray.compactMap { item in + guard JSONSerialization.isValidJSONObject(item), + let itemData = try? JSONSerialization.data(withJSONObject: item), + let entry = try? decoder.decode(HistoryEntry.self, from: itemData) else { + return nil + } + return entry + } + } + private static func loadTrackedDomains() -> [TrackedDomain] { let defaults = UserDefaults.standard let decoder = JSONDecoder() @@ -1953,10 +2076,11 @@ final class DomainViewModel { static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { [ SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), - SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), - SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), + SummaryFieldViewData(label: "Observed IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), + SummaryFieldViewData(label: "Observed Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), + SummaryFieldViewData(label: "Inference", value: availabilityInference(from: snapshot), tone: availabilityTone(snapshot.availabilityResult?.status)), + SummaryFieldViewData(label: "Observed TLS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), - SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)) ] } @@ -1965,17 +2089,32 @@ final class DomainViewModel { var rows = [ InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), + InfoRowViewData(label: "Collected", value: snapshot.timestamp.formatted(date: .abbreviated, time: .shortened), tone: .secondary), InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)), InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) ] rows.insert( InfoRowViewData( - label: "Availability", - value: availabilityLabel(snapshot.availabilityResult?.status), - tone: availabilityTone(snapshot.availabilityResult?.status) + label: "Observed Availability", + value: snapshot.availabilityResult?.status == .unknown ? "No direct registration proof" : "Status collected", + tone: .secondary ), at: 1 ) + rows.insert( + InfoRowViewData( + label: "Inference", + value: availabilityInference(from: snapshot), + tone: availabilityTone(snapshot.availabilityResult?.status) + ), + at: 2 + ) + if let confidence = snapshot.availabilityConfidence { + rows.insert( + InfoRowViewData(label: "Confidence", value: confidence.title, tone: .secondary), + at: 3 + ) + } if let certificateStatus = certificateBadgeLabel(from: snapshot) { rows.insert( InfoRowViewData( @@ -1994,6 +2133,7 @@ final class DomainViewModel { DomainSuggestionViewData( id: $0.id, domain: $0.domain, + availabilityStatus: $0.status, status: availabilityLabel($0.status), tone: availabilityTone($0.status) ) @@ -2562,6 +2702,17 @@ final class DomainViewModel { } } + private static func availabilityInference(from snapshot: LookupSnapshot) -> String { + switch snapshot.availabilityResult?.status { + case .registered: + return "Likely registered" + case .available: + return "Possibly available" + case .unknown, .none: + return "Unclear" + } + } + private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone { switch status { case .available: diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 1a5c0d0..9505c12 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -39,6 +39,9 @@ struct HistoryView: View { 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))) + } } HStack(spacing: 8) { @@ -51,6 +54,13 @@ struct HistoryView: View { } .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) + + if let note = entry.note, !note.isEmpty { + Text(note) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { @@ -128,6 +138,9 @@ struct HistoryDetailView: View { @Bindable var viewModel: DomainViewModel let entry: HistoryEntry @Environment(\.dismiss) private var dismiss + @State private var noteDraft = "" + @State private var isEditingNote = false + @State private var showRerunOptions = false private let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -153,6 +166,9 @@ struct HistoryDetailView: View { showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, availabilityLoading: false, suggestionsLoading: false, + provenance: snapshot.provenanceBySection[.availability], + confidence: snapshot.availabilityConfidence, + snapshotNote: entry.note, trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), trackingLimitMessage: nil, onTrack: { @@ -170,6 +186,8 @@ struct HistoryDetailView: View { rows: DomainViewModel.ownershipRows(from: snapshot), loading: false, error: snapshot.ownershipError, + provenance: snapshot.provenanceBySection[.ownership], + confidence: snapshot.ownershipConfidence, showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -178,6 +196,8 @@ struct HistoryDetailView: View { rows: DomainViewModel.subdomainRows(from: snapshot), loading: false, error: snapshot.subdomainsError, + provenance: snapshot.provenanceBySection[.subdomains], + confidence: snapshot.subdomainConfidence, showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -189,6 +209,7 @@ struct HistoryDetailView: View { DomainDiffView( title: "Compared With Previous Snapshot", sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), + contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot), showsUnchanged: false ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -199,6 +220,8 @@ struct HistoryDetailView: View { sections: DomainViewModel.dnsRows(from: snapshot), ptrMessage: DomainViewModel.ptrMessage(from: snapshot), loading: false, + dnsProvenance: snapshot.provenanceBySection[.dns], + ptrProvenance: snapshot.provenanceBySection[.ptr], sectionError: snapshot.dnsError ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -208,13 +231,16 @@ struct HistoryDetailView: View { sslInfo: snapshot.sslInfo, sslLoading: false, sslError: snapshot.sslError, + tlsProvenance: snapshot.provenanceBySection[.ssl], responseRows: DomainViewModel.webResponseRows(from: snapshot), headers: snapshot.httpHeaders, headersLoading: false, headersError: snapshot.httpHeadersError, + httpProvenance: snapshot.provenanceBySection[.httpHeaders], redirects: DomainViewModel.redirectRows(from: snapshot), redirectLoading: false, redirectError: snapshot.redirectChainError, + redirectProvenance: snapshot.provenanceBySection[.redirectChain], finalURL: snapshot.redirectChain.last?.url ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -222,6 +248,8 @@ struct HistoryDetailView: View { isCollapsed: .constant(false), rows: DomainViewModel.emailRows(from: snapshot), loading: false, + provenance: snapshot.provenanceBySection[.emailSecurity], + confidence: snapshot.emailSecurityConfidence, error: snapshot.emailSecurityError ) .padding(.top, appDensity.metrics.sectionSpacing) @@ -230,14 +258,18 @@ struct HistoryDetailView: View { reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot), reachabilityLoading: false, reachabilityError: snapshot.reachabilityError, + reachabilityProvenance: snapshot.provenanceBySection[.reachability], locationRows: DomainViewModel.locationRows(from: snapshot), geolocation: snapshot.ipGeolocation, geolocationLoading: false, geolocationError: snapshot.ipGeolocationError, + geolocationProvenance: snapshot.provenanceBySection[.ipGeolocation], + geolocationConfidence: snapshot.geolocationConfidence, standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard), customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom), portScanLoading: false, portScanError: snapshot.portScanError, + portScanProvenance: snapshot.provenanceBySection[.portScan], customPortScanLoading: false, customPortScanError: nil, isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }), @@ -253,26 +285,84 @@ struct HistoryDetailView: View { .background(Color.black) .navigationTitle(entry.domain) .toolbar { - Button("Re-run") { - viewModel.rerunLookup(from: entry) + ToolbarItemGroup(placement: .topBarTrailing) { + Button("Note") { + noteDraft = entry.note ?? "" + isEditingNote = true + } + Button("Re-run") { + showRerunOptions = true + } } } .onChange(of: viewModel.rerunNavigationToken) { _, _ in dismiss() } + .confirmationDialog("Re-run lookup", isPresented: $showRerunOptions) { + Button("Run with Current Settings") { + viewModel.rerunLookup(from: entry, useSnapshotResolver: false) + } + Button("Run with Snapshot Resolver") { + viewModel.rerunLookup(from: entry, useSnapshotResolver: true) + } + Button("Cancel", role: .cancel) {} + } message: { + Text(viewModel.resolverMismatchNote(for: entry) ?? "Choose how to reproduce this snapshot.") + } + .sheet(isPresented: $isEditingNote) { + NavigationStack { + Form { + Section("Audit Note") { + TextField("Optional note", text: $noteDraft, axis: .vertical) + .lineLimit(3...6) + } + } + .navigationTitle(entry.domain) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + isEditingNote = false + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + viewModel.updateHistoryNote(noteDraft, for: entry) + isEditingNote = false + } + } + } + } + } .preferredColorScheme(.dark) } private var snapshotBanner: some View { - HStack(spacing: 8) { - Image(systemName: "archivebox") - .font(.caption) - Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") - .font(appDensity.font(.caption)) - Spacer() - Text("Live re-run available") - .font(appDensity.font(.caption2)) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "archivebox") + .font(.caption) + Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") + .font(appDensity.font(.caption)) + Spacer() + Text("Live re-run available") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + if let mismatchNote = viewModel.resolverMismatchNote(for: entry) { + Text(mismatchNote) + .font(appDensity.font(.caption2)) + .foregroundStyle(.orange) + } + if entry.isPartialSnapshot { + Text("Partial snapshot: \(entry.validationIssues.joined(separator: " | "))") + .font(appDensity.font(.caption2)) + .foregroundStyle(.yellow) + } + if let note = entry.note, !note.isEmpty { + Text(note) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } } .foregroundStyle(.secondary) .padding(8) diff --git a/DomainDig/LookupRuntime.swift b/DomainDig/LookupRuntime.swift index 08ea122..5db3134 100644 --- a/DomainDig/LookupRuntime.swift +++ b/DomainDig/LookupRuntime.swift @@ -79,15 +79,9 @@ actor LookupRuntime { } func availability(domain: String) async -> CachedLookupResult { - await execute( - key: .domain(domain, .availability), - extract: { payload in - guard case let .availability(result) = payload else { return nil } - return result - }, - operation: { - .availability(await DomainAvailabilityService.check(domain: domain)) - } + CachedLookupResult( + value: await DomainAvailabilityService.check(domain: domain), + source: .live ) } diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 85136d2..60e5852 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -6,6 +6,59 @@ enum ServiceResult { case error(String) } +enum ConfidenceLevel: String, Codable { + case high + case medium + case low + + var title: String { + rawValue.capitalized + } +} + +enum InspectionErrorKind: String, Codable { + case network + case timeout + case rateLimited + case parsing + case unsupported + case unavailable + case unknown + + var title: String { + switch self { + case .network: + return "Network" + case .timeout: + return "Timeout" + case .rateLimited: + return "Rate limited" + case .parsing: + return "Parsing" + case .unsupported: + return "Unsupported" + case .unavailable: + return "Unavailable" + case .unknown: + return "Unknown" + } + } +} + +struct InspectionFailure: Codable, Equatable { + let kind: InspectionErrorKind + let message: String + let details: String? +} + +struct SectionProvenance: Codable, Equatable { + let source: String + let collectedAt: Date + let provider: String? + let resolver: String? + let resultSource: LookupResultSource +} + enum LookupResultSource: String, Codable { case live case cached @@ -129,19 +182,28 @@ struct DomainChangeSummary: Codable, Equatable { let message: String let severity: ChangeSeverity let generatedAt: Date + let observedFacts: [String] + let inferredConclusions: [String] + let contextNote: String? init( hasChanges: Bool, changedSections: [String], message: String, severity: ChangeSeverity, - generatedAt: Date + generatedAt: Date, + observedFacts: [String] = [], + inferredConclusions: [String] = [], + contextNote: String? = nil ) { self.hasChanges = hasChanges self.changedSections = changedSections self.message = message self.severity = severity self.generatedAt = generatedAt + self.observedFacts = observedFacts + self.inferredConclusions = inferredConclusions + self.contextNote = contextNote } init(from decoder: Decoder) throws { @@ -152,6 +214,9 @@ struct DomainChangeSummary: Codable, Equatable { severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low) message = try container.decodeIfPresent(String.self, forKey: .message) ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • ")) + observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? [] + inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? [] + contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote) } } @@ -709,6 +774,7 @@ struct HistoryEntry: Identifiable, Codable { let domain: String let timestamp: Date var trackedDomainID: UUID? + var note: String? let dnsSections: [DNSSection] let sslInfo: SSLCertificateInfo? let httpHeaders: [HTTPHeader] @@ -724,6 +790,18 @@ struct HistoryEntry: Identifiable, Codable { var hstsPreloaded: Bool? var availabilityResult: DomainAvailabilityResult? var suggestions: [DomainSuggestionResult] + var appVersion: String + var resultSource: LookupResultSource + var dataSources: [String] + var provenanceBySection: [LookupSectionKind: SectionProvenance] + var availabilityConfidence: ConfidenceLevel? + var ownershipConfidence: ConfidenceLevel? + var subdomainConfidence: ConfidenceLevel? + var emailSecurityConfidence: ConfidenceLevel? + var geolocationConfidence: ConfidenceLevel? + var errorDetails: [LookupSectionKind: InspectionFailure] + var isPartialSnapshot: Bool + var validationIssues: [String] var resolverDisplayName: String var resolverURLString: String var totalLookupDurationMs: Int? @@ -744,14 +822,21 @@ struct HistoryEntry: Identifiable, Codable { var subdomainsError: String? var portScanError: String? - init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection], + init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], portScanResults: [PortScanResult] = [], hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, - suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, + suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", + resultSource: LookupResultSource = .snapshot, dataSources: [String] = [], + provenanceBySection: [LookupSectionKind: SectionProvenance] = [:], + availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil, + subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil, + geolocationConfidence: ConfidenceLevel? = nil, + errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false, + 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, @@ -761,6 +846,7 @@ struct HistoryEntry: Identifiable, Codable { self.domain = domain self.timestamp = timestamp self.trackedDomainID = trackedDomainID + self.note = note self.dnsSections = dnsSections self.sslInfo = sslInfo self.httpHeaders = httpHeaders @@ -776,6 +862,18 @@ struct HistoryEntry: Identifiable, Codable { self.hstsPreloaded = hstsPreloaded self.availabilityResult = availabilityResult self.suggestions = suggestions + self.appVersion = appVersion + self.resultSource = resultSource + self.dataSources = dataSources + self.provenanceBySection = provenanceBySection + self.availabilityConfidence = availabilityConfidence + self.ownershipConfidence = ownershipConfidence + self.subdomainConfidence = subdomainConfidence + self.emailSecurityConfidence = emailSecurityConfidence + self.geolocationConfidence = geolocationConfidence + self.errorDetails = errorDetails + self.isPartialSnapshot = isPartialSnapshot + self.validationIssues = validationIssues self.resolverDisplayName = resolverDisplayName self.resolverURLString = resolverURLString self.totalLookupDurationMs = totalLookupDurationMs @@ -800,13 +898,14 @@ struct HistoryEntry: Identifiable, Codable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() - domain = try container.decode(String.self, forKey: .domain) - timestamp = try container.decode(Date.self, forKey: .timestamp) + domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain" + timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID) - dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections) + note = try container.decodeIfPresent(String.self, forKey: .note) + dnsSections = try container.decodeIfPresent([DNSSection].self, forKey: .dnsSections) ?? [] sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) - httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders) - reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults) + httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? [] + reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? [] ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts @@ -818,6 +917,18 @@ struct HistoryEntry: Identifiable, Codable { hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? [] + appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "2.6.0" + resultSource = try container.decodeIfPresent(LookupResultSource.self, forKey: .resultSource) ?? .snapshot + dataSources = try container.decodeIfPresent([String].self, forKey: .dataSources) ?? [] + provenanceBySection = try container.decodeIfPresent([LookupSectionKind: SectionProvenance].self, forKey: .provenanceBySection) ?? [:] + availabilityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .availabilityConfidence) + ownershipConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .ownershipConfidence) + subdomainConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .subdomainConfidence) + emailSecurityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .emailSecurityConfidence) + geolocationConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .geolocationConfidence) + errorDetails = try container.decodeIfPresent([LookupSectionKind: InspectionFailure].self, forKey: .errorDetails) ?? [:] + validationIssues = try container.decodeIfPresent([String].self, forKey: .validationIssues) ?? HistoryEntry.defaultValidationIssues(domain: domain, timestamp: timestamp) + isPartialSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isPartialSnapshot) ?? !validationIssues.isEmpty resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) @@ -838,6 +949,17 @@ struct HistoryEntry: Identifiable, Codable { subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) } + + private static func defaultValidationIssues(domain: String, timestamp: Date) -> [String] { + var issues: [String] = [] + if domain == "unknown-domain" { + issues.append("Missing domain in stored snapshot") + } + if timestamp == .distantPast { + issues.append("Missing collection timestamp in stored snapshot") + } + return issues + } } // MARK: - Cloudflare DNS-over-HTTPS Response diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 0874956..d58061f 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -334,6 +334,7 @@ struct TrackedDomainDetailView: View { @State private var noteDraft = "" @State private var isEditingNote = false + @State private var showRerunOptions = false private var liveTrackedDomain: TrackedDomain { viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain @@ -365,7 +366,7 @@ struct TrackedDomainDetailView: View { } Button { - viewModel.rerunInspection(for: liveTrackedDomain) + showRerunOptions = true } label: { Label("Re-run Inspection", systemImage: "magnifyingglass") } @@ -394,7 +395,14 @@ struct TrackedDomainDetailView: View { if !latestDiffSections.isEmpty { Section("Latest Diff") { - DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false) + DomainDiffView( + title: "Latest Snapshot vs Previous", + sections: latestDiffSections, + contextNote: latestSnapshots.count >= 2 + ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot) + : nil, + showsUnchanged: false + ) } .listRowBackground(Color.clear) } @@ -430,6 +438,19 @@ struct TrackedDomainDetailView: View { .onChange(of: viewModel.rerunNavigationToken) { _, _ in dismiss() } + .confirmationDialog("Re-run inspection", isPresented: $showRerunOptions) { + Button("Run with Current Settings") { + viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: false) + } + if latestSnapshots.first != nil { + Button("Run with Snapshot Resolver") { + viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: true) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text(viewModel.resolverMismatchNote(for: liveTrackedDomain) ?? "Choose how to reproduce the most recent snapshot.") + } .sheet(isPresented: $isEditingNote) { NavigationStack { Form { diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift index cb9350b..b906b83 100644 --- a/DomainInspectionService.swift +++ b/DomainInspectionService.swift @@ -20,6 +20,9 @@ struct DomainInspectionService { let resolverURLString = DNSLookupService.currentResolverURLString() var cachedSections = Set() var sectionSources: [LookupResultSource] = [] + var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:] + var dataSources = Set() + var errorDetails: [LookupSectionKind: InspectionFailure] = [:] async let dnsFetch = runtime.dns(domain: normalizedDomain) async let availabilityFetch = runtime.availability(domain: normalizedDomain) @@ -34,41 +37,129 @@ struct DomainInspectionService { let resolvedDNS = await dnsFetch let dnsResult = normalizeErrors(in: resolvedDNS.value) - track(.dns, source: resolvedDNS.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .dns, + source: resolvedDNS.source, + provenance: provenance(for: .dns, source: resolvedDNS.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .dns, result: dnsResult, into: &errorDetails) let availability = await availabilityFetch - track(.availability, source: availability.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .availability, + source: availability.source, + provenance: provenance(for: .availability, source: availability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) let resolvedSSL = await sslFetch let sslResult = normalizeErrors(in: resolvedSSL.value) - track(.ssl, source: resolvedSSL.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .ssl, + source: resolvedSSL.source, + provenance: provenance(for: .ssl, source: resolvedSSL.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .ssl, result: sslResult, into: &errorDetails) let hsts = await hstsFetch - track(.hsts, source: hsts.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .hsts, + source: hsts.source, + provenance: provenance(for: .hsts, source: hsts.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) let http = await httpFetch let httpResult = normalizeErrors(in: http.value) - track(.httpHeaders, source: http.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .httpHeaders, + source: http.source, + provenance: provenance(for: .httpHeaders, source: http.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails) let reachability = await reachabilityFetch let reachabilityResult = normalizeErrors(in: reachability.value) - track(.reachability, source: reachability.source, cachedSections: &cachedSections, sectionSources: §ionSources) + 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 let ownershipResult = normalizeErrors(in: resolvedOwnership.value) - track(.ownership, source: resolvedOwnership.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .ownership, + source: resolvedOwnership.source, + provenance: provenance(for: .ownership, source: resolvedOwnership.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails) let redirects = await redirectFetch let redirectResult = normalizeErrors(in: redirects.value) - track(.redirectChain, source: redirects.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .redirectChain, + source: redirects.source, + provenance: provenance(for: .redirectChain, source: redirects.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails) let resolvedSubdomains = await subdomainFetch let subdomainResult = normalizeErrors(in: resolvedSubdomains.value) - track(.subdomains, source: resolvedSubdomains.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .subdomains, + source: resolvedSubdomains.source, + provenance: provenance(for: .subdomains, source: resolvedSubdomains.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails) let ports = await portScanFetch let portScanResult = normalizeErrors(in: ports.value) - track(.portScan, source: ports.source, cachedSections: &cachedSections, sectionSources: §ionSources) + 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) @@ -92,12 +183,30 @@ struct DomainInspectionService { } else { emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords) } - track(.emailSecurity, source: emailOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) + let normalizedEmailResult = normalizeErrors(in: emailOutcome.value) + track( + .emailSecurity, + source: emailOutcome.source, + provenance: provenance(for: .emailSecurity, source: emailOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .emailSecurity, result: normalizedEmailResult, into: &errorDetails) let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]> if availability.value.status == .registered { suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain) - track(.suggestions, source: suggestionsOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) + track( + .suggestions, + source: suggestionsOutcome.source, + provenance: provenance(for: .suggestions, source: suggestionsOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) } else { suggestionsOutcome = CachedLookupResult(value: [], source: .live) } @@ -122,27 +231,65 @@ struct DomainInspectionService { } if let ptrOutcome { - track(.ptr, source: ptrOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) + let normalizedPTRResult = normalizeErrors(in: ptrOutcome.value) + track( + .ptr, + source: ptrOutcome.source, + provenance: provenance(for: .ptr, source: ptrOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .ptr, result: normalizedPTRResult, into: &errorDetails) } if let geoOutcome { - track(.ipGeolocation, source: geoOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) + let normalizedGeoResult = normalizeErrors(in: geoOutcome.value) + track( + .ipGeolocation, + source: geoOutcome.source, + provenance: provenance(for: .ipGeolocation, source: geoOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), + cachedSections: &cachedSections, + sectionSources: §ionSources, + provenanceBySection: &provenanceBySection, + dataSources: &dataSources + ) + captureFailure(for: .ipGeolocation, result: normalizedGeoResult, into: &errorDetails) } } else { ptrOutcome = nil geoOutcome = nil } - let emailSecurity = mapOptionalValueServiceResult(normalizeErrors(in: emailOutcome.value)) + let emailSecurity = mapOptionalValueServiceResult(normalizedEmailResult) let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") + let availabilityConfidence = confidenceForAvailability(result: availability.value, provenance: provenanceBySection[.availability]) + let ownershipConfidence = confidenceForOwnership(result: ownership.value, error: ownership.message) + let subdomainConfidence = confidenceForSubdomains(results: subdomains.value, error: subdomains.message) + let emailConfidence = confidenceForEmail(result: emailSecurity.value, error: emailSecurity.message) + 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( historyEntryID: nil, domain: availability.value.domain, timestamp: Date(), trackedDomainID: previousSnapshot?.trackedDomainID, + note: previousSnapshot?.note, + appVersion: AppVersion.current, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, + dataSources: Array(dataSources).sorted(), + provenanceBySection: provenanceBySection, + availabilityConfidence: availabilityConfidence, + ownershipConfidence: ownershipConfidence, + subdomainConfidence: subdomainConfidence, + emailSecurityConfidence: emailConfidence, + geolocationConfidence: geolocationConfidence, + errorDetails: errorDetails, + isPartialSnapshot: !validationIssues.isEmpty, + validationIssues: validationIssues, totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), dnsSections: dnsSections.value, dnsError: dnsSections.message, @@ -193,15 +340,64 @@ struct DomainInspectionService { private func track( _ section: LookupSectionKind, source: LookupResultSource, + provenance: SectionProvenance, cachedSections: inout Set, - sectionSources: inout [LookupResultSource] + sectionSources: inout [LookupResultSource], + provenanceBySection: inout [LookupSectionKind: SectionProvenance], + dataSources: inout Set ) { sectionSources.append(source) + provenanceBySection[section] = provenance + dataSources.insert(provenance.provider ?? provenance.source) if source != .live { cachedSections.insert(section) } } + private func provenance( + for section: LookupSectionKind, + source: LookupResultSource, + collectedAt: Date, + resolverDisplayName: String + ) -> SectionProvenance { + switch section { + case .dns, .ptr: + return SectionProvenance( + source: "DNS-over-HTTPS query", + collectedAt: collectedAt, + provider: "Selected DoH resolver", + resolver: resolverDisplayName, + resultSource: source + ) + case .availability: + return SectionProvenance( + source: "RDAP lookup with DNS fallback", + collectedAt: collectedAt, + provider: "rdap.org / selected resolver", + resolver: resolverDisplayName, + resultSource: source + ) + case .ssl: + return SectionProvenance(source: "Direct TLS handshake", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source) + case .hsts, .httpHeaders, .redirectChain: + return SectionProvenance(source: "HTTP request", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source) + case .reachability: + return SectionProvenance(source: "TCP reachability probe", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source) + case .ipGeolocation: + return SectionProvenance(source: "IP geolocation lookup", collectedAt: collectedAt, provider: "ipapi.co", resolver: nil, resultSource: source) + case .emailSecurity: + return SectionProvenance(source: "DNS TXT inspection", collectedAt: collectedAt, provider: "Selected DoH resolver", resolver: resolverDisplayName, resultSource: source) + case .ownership: + return SectionProvenance(source: "RDAP domain lookup", collectedAt: collectedAt, provider: "rdap.org", resolver: nil, resultSource: source) + case .subdomains: + return SectionProvenance(source: "Certificate transparency search", collectedAt: collectedAt, provider: "crt.sh", resolver: nil, resultSource: source) + case .portScan: + return SectionProvenance(source: "TCP port scan", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source) + case .suggestions: + return SectionProvenance(source: "Availability suggestions", collectedAt: collectedAt, provider: "DomainDig heuristic", resolver: resolverDisplayName, resultSource: source) + } + } + private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource { let normalizedSources = sectionSources.map { source -> LookupResultSource in source == .mixed ? .cached : source @@ -259,44 +455,131 @@ struct DomainInspectionService { case let .success(value): return .success(value) case let .empty(message): - return .empty(message) + return .empty(classifyFailure(from: message, defaultKind: .unavailable).message) case let .error(message): - return .error(classifiedMessage(from: message)) + return .error(classifyFailure(from: message).message) } } - private func classifiedMessage(from message: String) -> String { + private func classifyFailure(from message: String, defaultKind: InspectionErrorKind = .unknown) -> InspectionFailure { let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) let lowercasedMessage = normalizedMessage.lowercased() - - if lowercasedMessage.hasPrefix("network error:") - || lowercasedMessage.hasPrefix("timeout:") - || lowercasedMessage.hasPrefix("rate limit:") - || lowercasedMessage.hasPrefix("parsing error:") { - return normalizedMessage - } - if lowercasedMessage.contains("timed out") { - return "Timeout: Request timed out" + return InspectionFailure(kind: .timeout, message: "Timed out", details: normalizedMessage) } if lowercasedMessage.contains("429") || lowercasedMessage.contains("too many requests") || lowercasedMessage.contains("rate limit") { - return "Rate limit: Try again shortly" + return InspectionFailure(kind: .rateLimited, message: "Rate limited", details: normalizedMessage) } if lowercasedMessage.contains("cannot parse") || lowercasedMessage.contains("decoding") || lowercasedMessage.contains("json") { - return "Parsing error: Invalid server response" + return InspectionFailure(kind: .parsing, message: "Could not parse response", details: normalizedMessage) } if lowercasedMessage.contains("offline") || lowercasedMessage.contains("internet connection") || lowercasedMessage.contains("not connected") || lowercasedMessage.contains("network connection") { - return "Network error: Offline" + return InspectionFailure(kind: .network, message: "Network unavailable", details: normalizedMessage) } + if lowercasedMessage.contains("unsupported") { + return InspectionFailure(kind: .unsupported, message: "Unsupported for this target", details: normalizedMessage) + } + if lowercasedMessage == "unavailable" || lowercasedMessage.contains("no a record available") { + return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil) + } + if defaultKind == .unavailable { + return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil) + } + return InspectionFailure(kind: .unknown, message: normalizedMessage.isEmpty ? defaultKind.title : normalizedMessage, details: normalizedMessage) + } - return "Network error: \(normalizedMessage)" + private func captureFailure( + for section: LookupSectionKind, + result: ServiceResult, + into errorDetails: inout [LookupSectionKind: InspectionFailure] + ) { + switch result { + case .success: + return + case let .empty(message): + errorDetails[section] = classifyFailure(from: message, defaultKind: .unavailable) + case let .error(message): + errorDetails[section] = classifyFailure(from: message) + } + } + + private func confidenceForAvailability(result: DomainAvailabilityResult, provenance: SectionProvenance?) -> ConfidenceLevel { + guard result.status != .unknown else { return .low } + if provenance?.provider?.localizedCaseInsensitiveContains("rdap.org") == true, result.status == .registered { + return .high + } + if result.status == .registered { + return .medium + } + return .low + } + + private func confidenceForOwnership(result: DomainOwnership?, error: String?) -> ConfidenceLevel { + guard let result else { return error == nil ? .low : .low } + let hasDirectRegistrationData = result.registrar != nil || result.createdDate != nil || result.expirationDate != nil + return hasDirectRegistrationData ? .high : .medium + } + + private func confidenceForSubdomains(results: [DiscoveredSubdomain], error: String?) -> ConfidenceLevel { + if !results.isEmpty { + return .medium + } + return error == nil ? .low : .low + } + + private func confidenceForEmail(result: EmailSecurityResult?, error: String?) -> ConfidenceLevel { + guard let result else { return error == nil ? .low : .low } + let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found, result.bimi.found, result.mtaSts?.txtFound == true] + .filter { $0 } + .count + if foundCount >= 3 { + return .high + } + if foundCount >= 1 { + return .medium + } + return .low + } + + private func confidenceForGeolocation(result: IPGeolocation?, error: String?) -> ConfidenceLevel { + guard let result else { return error == nil ? .low : .low } + if result.city != nil && result.country_name != nil && result.latitude != nil && result.longitude != nil { + return .high + } + if result.country_name != nil || result.org != nil { + return .medium + } + return .low + } + + private func validationIssues( + for domain: String, + snapshotTimestamp: Date, + availability: DomainAvailabilityResult, + dnsSections: [DNSSection], + provenanceBySection: [LookupSectionKind: SectionProvenance] + ) -> [String] { + var issues: [String] = [] + if domain.isEmpty { + issues.append("Missing normalized domain") + } + if availability.domain.isEmpty { + issues.append("Missing normalized availability domain") + } + if dnsSections.isEmpty && provenanceBySection[.dns] == nil { + issues.append("Missing DNS provenance") + } + if snapshotTimestamp > Date().addingTimeInterval(5) { + issues.append("Snapshot timestamp is in the future") + } + return issues } private func mapServiceResult(_ result: ServiceResult, emptyValue: Value) -> (value: Value, message: String?) { diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift index 7c5a7c0..765ad4f 100644 --- a/DomainReportBuilder.swift +++ b/DomainReportBuilder.swift @@ -3,8 +3,22 @@ import Foundation struct DomainReport: Codable { let domain: String let timestamp: Date + let appVersion: String + let resolverDisplayName: String + let resolverURLString: String + let dataSources: [String] let resultSource: LookupResultSource + let sectionProvenance: [LookupSectionKind: SectionProvenance] + let errorDetails: [LookupSectionKind: InspectionFailure] + let isPartialSnapshot: Bool + let validationIssues: [String] + let auditNote: String? let availability: DomainAvailabilityStatus + let availabilityConfidence: ConfidenceLevel? + let ownershipConfidence: ConfidenceLevel? + let subdomainConfidence: ConfidenceLevel? + let emailConfidence: ConfidenceLevel? + let geolocationConfidence: ConfidenceLevel? let ownership: DomainOwnership? let dns: DNSResultSummary let web: WebResultSummary @@ -71,8 +85,22 @@ struct DomainReportBuilder { return DomainReport( domain: snapshot.domain, timestamp: snapshot.timestamp, + appVersion: snapshot.appVersion, + resolverDisplayName: snapshot.resolverDisplayName, + resolverURLString: snapshot.resolverURLString, + dataSources: snapshot.dataSources, resultSource: snapshot.resultSource, + sectionProvenance: snapshot.provenanceBySection, + errorDetails: snapshot.errorDetails, + isPartialSnapshot: snapshot.isPartialSnapshot, + validationIssues: snapshot.validationIssues, + auditNote: snapshot.note, availability: snapshot.availabilityResult?.status ?? .unknown, + availabilityConfidence: snapshot.availabilityConfidence, + ownershipConfidence: snapshot.ownershipConfidence, + subdomainConfidence: snapshot.subdomainConfidence, + emailConfidence: snapshot.emailSecurityConfidence, + geolocationConfidence: snapshot.geolocationConfidence, ownership: snapshot.ownership, dns: DNSResultSummary( resolverDisplayName: snapshot.resolverDisplayName, diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift index ecfdbd4..0841fcf 100644 --- a/DomainReportExporter.swift +++ b/DomainReportExporter.swift @@ -36,10 +36,27 @@ enum DomainReportExporter { "DomainDig Report", "Domain: \(report.domain)", "Timestamp: \(textDateFormatter.string(from: report.timestamp))", + "App Version: \(report.appVersion)", + "Resolver: \(report.resolverDisplayName)", + "Resolver URL: \(report.resolverURLString)", "Source: \(report.resultSource.label)", - "Availability: \(availabilityLabel(report.availability))" + "Availability: \(availabilityLabel(report.availability))", + "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")" ] + if report.isPartialSnapshot { + lines.append("Snapshot Integrity: Partial snapshot") + } + if let auditNote = report.auditNote, !auditNote.isEmpty { + lines.append("Audit Note: \(auditNote)") + } + if !report.dataSources.isEmpty { + lines.append("Data Sources: \(report.dataSources.joined(separator: ", "))") + } + if !report.validationIssues.isEmpty { + lines.append("Validation: \(report.validationIssues.joined(separator: " | "))") + } + appendSection("Summary", to: &lines) { [ "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", @@ -53,12 +70,16 @@ enum DomainReportExporter { appendSection("Ownership", to: &lines) { var ownershipLines = [ "Registrar: \(report.ownership?.registrar ?? "Unavailable")", + "Confidence: \(report.ownershipConfidence?.title ?? "N/A")", "Created: \(ownershipDateLabel(report.ownership?.createdDate))", "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", "Status: \(joined(report.ownership?.status) ?? "Unavailable")", "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" ] + if let provenance = report.sectionProvenance[.ownership] { + ownershipLines.append("Provenance: \(provenanceLabel(provenance))") + } if let error = report.dns.error, report.ownership == nil { ownershipLines.append("Error: \(error)") } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil { @@ -69,13 +90,14 @@ enum DomainReportExporter { appendSection("DNS", to: &lines) { var dnsLines = [ - "Resolver: \(report.dns.resolverDisplayName)", - "Resolver URL: \(report.dns.resolverURLString)", "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")", "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))" ] + if let provenance = report.sectionProvenance[.dns] { + dnsLines.append("Provenance: \(provenanceLabel(provenance))") + } if let error = report.dns.error { dnsLines.append("Error: \(error)") } @@ -104,6 +126,15 @@ enum DomainReportExporter { "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))", "Header Count: \(report.web.headerCount)" ] + if let provenance = report.sectionProvenance[.ssl] { + webLines.append("TLS Provenance: \(provenanceLabel(provenance))") + } + if let provenance = report.sectionProvenance[.httpHeaders] { + webLines.append("HTTP Provenance: \(provenanceLabel(provenance))") + } + if let provenance = report.sectionProvenance[.redirectChain] { + webLines.append("Redirect Provenance: \(provenanceLabel(provenance))") + } if let tlsError = report.web.tlsError { webLines.append("TLS Error: \(tlsError)") } @@ -130,6 +161,10 @@ enum DomainReportExporter { appendSection("Email", to: &lines) { var emailLines = [report.email.summary] + emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")") + if let provenance = report.sectionProvenance[.emailSecurity] { + emailLines.append("Provenance: \(provenanceLabel(provenance))") + } if let records = report.email.records { emailLines.append("SPF: \(recordLabel(records.spf))") emailLines.append("DMARC: \(recordLabel(records.dmarc))") @@ -147,8 +182,12 @@ enum DomainReportExporter { var networkLines = [ "Reachability: \(report.network.reachabilitySummary)", "Geolocation: \(report.network.geolocationSummary)", + "Geolocation Confidence: \(report.geolocationConfidence?.title ?? "N/A")", "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")" ] + if let provenance = report.sectionProvenance[.ipGeolocation] { + networkLines.append("Geolocation Provenance: \(provenanceLabel(provenance))") + } if let error = report.network.reachabilityError { networkLines.append("Reachability Error: \(error)") } @@ -170,10 +209,16 @@ enum DomainReportExporter { } appendSection("Subdomains", to: &lines) { + var values = ["Confidence: \(report.subdomainConfidence?.title ?? "N/A")"] + if let provenance = report.sectionProvenance[.subdomains] { + values.append("Provenance: \(provenanceLabel(provenance))") + } if report.subdomains.isEmpty { - return ["None"] + values.append("None") + return values } - return report.subdomains.map { "- \($0)" } + values.append(contentsOf: report.subdomains.map { "- \($0)" }) + return values } appendSection("Changes", to: &lines) { @@ -181,12 +226,19 @@ enum DomainReportExporter { return ["No comparison available"] } - return [ + var values = [ "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")", "Severity: \(changeSummary.severity.title)", - "Summary: \(changeSummary.message)", + "Inferred Summary: \(changeSummary.message)", "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))" ] + if !changeSummary.observedFacts.isEmpty { + values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))") + } + if let contextNote = changeSummary.contextNote { + values.append("Context: \(contextNote)") + } + return values } return lines.joined(separator: "\n") @@ -213,9 +265,13 @@ enum DomainReportExporter { let headers = [ "domain", "timestamp", + "app_version", "result_source", + "resolver", "availability", + "availability_confidence", "registrar", + "ownership_confidence", "ownership_expires", "nameservers", "primary_ip", @@ -228,11 +284,17 @@ enum DomainReportExporter { "http_security_grade", "final_url", "email_summary", + "email_confidence", "subdomain_count", + "subdomain_confidence", "subdomains", "open_ports", "reachability_summary", "geolocation_summary", + "geolocation_confidence", + "data_sources", + "audit_note", + "partial_snapshot", "change_summary" ] @@ -249,9 +311,13 @@ enum DomainReportExporter { return [ report.domain, csvDateFormatter.string(from: report.timestamp), + report.appVersion, report.resultSource.rawValue, + report.resolverDisplayName, availabilityLabel(report.availability), + report.availabilityConfidence?.rawValue ?? "", report.ownership?.registrar ?? "", + report.ownershipConfidence?.rawValue ?? "", expirationDate, nameservers, report.dns.primaryIP ?? "", @@ -264,11 +330,17 @@ enum DomainReportExporter { report.web.securityGrade ?? "", report.web.finalURL ?? "", report.email.summary, + report.emailConfidence?.rawValue ?? "", subdomainCount, + report.subdomainConfidence?.rawValue ?? "", subdomains, openPorts, report.network.reachabilitySummary, report.network.geolocationSummary, + report.geolocationConfidence?.rawValue ?? "", + report.dataSources.joined(separator: " | "), + report.auditNote ?? "", + report.isPartialSnapshot ? "true" : "false", report.changeSummary?.message ?? "" ] } @@ -338,6 +410,18 @@ enum DomainReportExporter { return "Unavailable" } + private static func provenanceLabel(_ provenance: SectionProvenance) -> String { + [ + provenance.source, + provenance.provider, + provenance.resolver.map { "resolver=\($0)" }, + provenance.resultSource.label.lowercased(), + textDateFormatter.string(from: provenance.collectedAt) + ] + .compactMap { $0 } + .joined(separator: " | ") + } + private static let textDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift index dd1e5f4..f51f546 100644 --- a/LookupSnapshot.swift +++ b/LookupSnapshot.swift @@ -5,8 +5,20 @@ struct LookupSnapshot { let domain: String let timestamp: Date let trackedDomainID: UUID? + let note: String? + let appVersion: String let resolverDisplayName: String let resolverURLString: String + let dataSources: [String] + let provenanceBySection: [LookupSectionKind: SectionProvenance] + let availabilityConfidence: ConfidenceLevel? + let ownershipConfidence: ConfidenceLevel? + let subdomainConfidence: ConfidenceLevel? + let emailSecurityConfidence: ConfidenceLevel? + let geolocationConfidence: ConfidenceLevel? + let errorDetails: [LookupSectionKind: InspectionFailure] + let isPartialSnapshot: Bool + let validationIssues: [String] let totalLookupDurationMs: Int? let dnsSections: [DNSSection] let dnsError: String? @@ -55,8 +67,20 @@ extension HistoryEntry { domain: domain, timestamp: timestamp, trackedDomainID: trackedDomainID, + note: note, + appVersion: appVersion, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, + dataSources: dataSources, + provenanceBySection: provenanceBySection, + availabilityConfidence: availabilityConfidence, + ownershipConfidence: ownershipConfidence, + subdomainConfidence: subdomainConfidence, + emailSecurityConfidence: emailSecurityConfidence, + geolocationConfidence: geolocationConfidence, + errorDetails: errorDetails, + isPartialSnapshot: isPartialSnapshot, + validationIssues: validationIssues, totalLookupDurationMs: totalLookupDurationMs, dnsSections: dnsSections, dnsError: nil, @@ -89,7 +113,7 @@ extension HistoryEntry { portScanResults: portScanResults, portScanError: portScanError, changeSummary: changeSummary, - resultSource: .snapshot, + resultSource: resultSource, cachedSections: [], statusMessage: nil ) -- cgit v1.2.3