Commit 7b41195620
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +6 −6
| @@ -134,7 +134,7 @@ | |||
| 134 | }; | 134 | }; |
| 135 | 8BF124FB2F70000100933221 /* DomainDigCLI */ = { | 135 | 8BF124FB2F70000100933221 /* DomainDigCLI */ = { |
| 136 | isa = PBXNativeTarget; | 136 | isa = PBXNativeTarget; |
| 137 | buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list */; | 137 | buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */; |
| 138 | buildPhases = ( | 138 | buildPhases = ( |
| 139 | 8BF124FC2F70000100933221 /* Sources */, | 139 | 8BF124FC2F70000100933221 /* Sources */, |
| 140 | 8BF124FD2F70000100933221 /* Frameworks */, | 140 | 8BF124FD2F70000100933221 /* Frameworks */, |
| @@ -354,7 +354,7 @@ | |||
| 354 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 354 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 355 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 355 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 356 | CODE_SIGN_STYLE = Automatic; | 356 | CODE_SIGN_STYLE = Automatic; |
| 357 | CURRENT_PROJECT_VERSION = 19; | 357 | CURRENT_PROJECT_VERSION = 20; |
| 358 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 358 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 359 | ENABLE_PREVIEWS = YES; | 359 | ENABLE_PREVIEWS = YES; |
| 360 | GENERATE_INFOPLIST_FILE = YES; | 360 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -371,7 +371,7 @@ | |||
| 371 | "$(inherited)", | 371 | "$(inherited)", |
| 372 | "@executable_path/Frameworks", | 372 | "@executable_path/Frameworks", |
| 373 | ); | 373 | ); |
| 374 | MARKETING_VERSION = 2.6.0; | 374 | MARKETING_VERSION = 2.7.0; |
| 375 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 375 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 376 | PRODUCT_NAME = "$(TARGET_NAME)"; | 376 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 377 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 377 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -390,7 +390,7 @@ | |||
| 390 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | 390 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 391 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 391 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 392 | CODE_SIGN_STYLE = Automatic; | 392 | CODE_SIGN_STYLE = Automatic; |
| 393 | CURRENT_PROJECT_VERSION = 19; | 393 | CURRENT_PROJECT_VERSION = 20; |
| 394 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 394 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 395 | ENABLE_PREVIEWS = YES; | 395 | ENABLE_PREVIEWS = YES; |
| 396 | GENERATE_INFOPLIST_FILE = YES; | 396 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -407,7 +407,7 @@ | |||
| 407 | "$(inherited)", | 407 | "$(inherited)", |
| 408 | "@executable_path/Frameworks", | 408 | "@executable_path/Frameworks", |
| 409 | ); | 409 | ); |
| 410 | MARKETING_VERSION = 2.6.0; | 410 | MARKETING_VERSION = 2.7.0; |
| 411 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 411 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 412 | PRODUCT_NAME = "$(TARGET_NAME)"; | 412 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 413 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 413 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -460,7 +460,7 @@ | |||
| 460 | defaultConfigurationIsVisible = 0; | 460 | defaultConfigurationIsVisible = 0; |
| 461 | defaultConfigurationName = Release; | 461 | defaultConfigurationName = Release; |
| 462 | }; | 462 | }; |
| 463 | 8BBFF0292F987EF700E8E144 /* Build configuration list */ = { | 463 | 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */ = { |
| 464 | isa = XCConfigurationList; | 464 | isa = XCConfigurationList; |
| 465 | buildConfigurations = ( | 465 | buildConfigurations = ( |
| 466 | 8BBFF0272F987E8700E8E144 /* Debug */, | 466 | 8BBFF0272F987E8700E8E144 /* Debug */, |
DomainDig/AppVersion.swift added +7
| @@ -0,0 +1,7 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | enum AppVersion { | ||
| 4 | static var current: String { | ||
| 5 | "2.7.0" | ||
| 6 | } | ||
| 7 | } | ||
DomainDig/ContentView.swift +190 −36
| @@ -42,15 +42,20 @@ struct ContentView: View { | |||
| 42 | } | 42 | } |
| 43 | if viewModel.hasRun { | 43 | if viewModel.hasRun { |
| 44 | actionButtons | 44 | actionButtons |
| 45 | if let statusMessage = resultStatusMessage { | 45 | if !viewModel.resultsLoaded { |
| 46 | LookupProgressOverviewView(steps: viewModel.activeLoadingLabels) | ||
| 47 | .padding(.top, appDensity.metrics.cardSpacing) | ||
| 48 | } else if let statusMessage = resultStatusMessage { | ||
| 46 | LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) | 49 | LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) |
| 47 | .padding(.top, appDensity.metrics.cardSpacing) | 50 | .padding(.top, appDensity.metrics.cardSpacing) |
| 48 | } | 51 | } |
| 49 | SummaryView(fields: viewModel.summaryFields) | 52 | if viewModel.resultsLoaded { |
| 50 | .padding(.top, appDensity.metrics.cardSpacing) | 53 | SummaryView(fields: viewModel.summaryFields) |
| 51 | if let changeSummary = viewModel.currentChangeSummary { | ||
| 52 | DomainChangeSummaryView(summary: changeSummary) | ||
| 53 | .padding(.top, appDensity.metrics.cardSpacing) | 54 | .padding(.top, appDensity.metrics.cardSpacing) |
| 55 | if let changeSummary = viewModel.currentChangeSummary { | ||
| 56 | DomainChangeSummaryView(summary: changeSummary) | ||
| 57 | .padding(.top, appDensity.metrics.cardSpacing) | ||
| 58 | } | ||
| 54 | } | 59 | } |
| 55 | DomainSectionView( | 60 | DomainSectionView( |
| 56 | isCollapsed: sectionCollapsedBinding(.domain), | 61 | isCollapsed: sectionCollapsedBinding(.domain), |
| @@ -59,6 +64,9 @@ struct ContentView: View { | |||
| 59 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, | 64 | showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading, |
| 60 | availabilityLoading: viewModel.availabilityLoading, | 65 | availabilityLoading: viewModel.availabilityLoading, |
| 61 | suggestionsLoading: viewModel.suggestionsLoading, | 66 | suggestionsLoading: viewModel.suggestionsLoading, |
| 67 | provenance: viewModel.currentSnapshot.provenanceBySection[.availability], | ||
| 68 | confidence: viewModel.currentSnapshot.availabilityConfidence, | ||
| 69 | snapshotNote: viewModel.currentSnapshot.note, | ||
| 62 | trackedDomain: viewModel.currentTrackedDomain, | 70 | trackedDomain: viewModel.currentTrackedDomain, |
| 63 | trackingLimitMessage: viewModel.trackingLimitMessage, | 71 | trackingLimitMessage: viewModel.trackingLimitMessage, |
| 64 | onTrack: { | 72 | onTrack: { |
| @@ -82,6 +90,8 @@ struct ContentView: View { | |||
| 82 | rows: viewModel.ownershipRows, | 90 | rows: viewModel.ownershipRows, |
| 83 | loading: viewModel.ownershipLoading, | 91 | loading: viewModel.ownershipLoading, |
| 84 | error: viewModel.ownershipError, | 92 | error: viewModel.ownershipError, |
| 93 | provenance: viewModel.currentSnapshot.provenanceBySection[.ownership], | ||
| 94 | confidence: viewModel.currentSnapshot.ownershipConfidence, | ||
| 85 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) | 95 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) |
| 86 | ) | 96 | ) |
| 87 | .padding(.top, appDensity.metrics.sectionSpacing) | 97 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -90,6 +100,8 @@ struct ContentView: View { | |||
| 90 | rows: viewModel.subdomainRows, | 100 | rows: viewModel.subdomainRows, |
| 91 | loading: viewModel.subdomainsLoading, | 101 | loading: viewModel.subdomainsLoading, |
| 92 | error: viewModel.subdomainsError, | 102 | error: viewModel.subdomainsError, |
| 103 | provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], | ||
| 104 | confidence: viewModel.currentSnapshot.subdomainConfidence, | ||
| 93 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) | 105 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) |
| 94 | ) | 106 | ) |
| 95 | .padding(.top, appDensity.metrics.sectionSpacing) | 107 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -97,6 +109,7 @@ struct ContentView: View { | |||
| 97 | DomainDiffView( | 109 | DomainDiffView( |
| 98 | title: "Latest Changes", | 110 | title: "Latest Changes", |
| 99 | sections: viewModel.currentDiffSections, | 111 | sections: viewModel.currentDiffSections, |
| 112 | contextNote: viewModel.currentChangeSummary?.contextNote, | ||
| 100 | showsUnchanged: false | 113 | showsUnchanged: false |
| 101 | ) | 114 | ) |
| 102 | .padding(.top, appDensity.metrics.sectionSpacing) | 115 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -107,6 +120,8 @@ struct ContentView: View { | |||
| 107 | sections: viewModel.dnsRows, | 120 | sections: viewModel.dnsRows, |
| 108 | ptrMessage: viewModel.ptrMessage, | 121 | ptrMessage: viewModel.ptrMessage, |
| 109 | loading: viewModel.dnsLoading || viewModel.ptrLoading, | 122 | loading: viewModel.dnsLoading || viewModel.ptrLoading, |
| 123 | dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns], | ||
| 124 | ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr], | ||
| 110 | sectionError: viewModel.dnsError | 125 | sectionError: viewModel.dnsError |
| 111 | ) | 126 | ) |
| 112 | .padding(.top, appDensity.metrics.sectionSpacing) | 127 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -116,13 +131,16 @@ struct ContentView: View { | |||
| 116 | sslInfo: viewModel.sslInfo, | 131 | sslInfo: viewModel.sslInfo, |
| 117 | sslLoading: viewModel.sslLoading || viewModel.hstsLoading, | 132 | sslLoading: viewModel.sslLoading || viewModel.hstsLoading, |
| 118 | sslError: viewModel.sslError, | 133 | sslError: viewModel.sslError, |
| 134 | tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl], | ||
| 119 | responseRows: viewModel.webResponseRows, | 135 | responseRows: viewModel.webResponseRows, |
| 120 | headers: viewModel.httpHeaders, | 136 | headers: viewModel.httpHeaders, |
| 121 | headersLoading: viewModel.httpHeadersLoading, | 137 | headersLoading: viewModel.httpHeadersLoading, |
| 122 | headersError: viewModel.httpHeadersError, | 138 | headersError: viewModel.httpHeadersError, |
| 139 | httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders], | ||
| 123 | redirects: viewModel.redirectRows, | 140 | redirects: viewModel.redirectRows, |
| 124 | redirectLoading: viewModel.redirectChainLoading, | 141 | redirectLoading: viewModel.redirectChainLoading, |
| 125 | redirectError: viewModel.redirectChainError, | 142 | redirectError: viewModel.redirectChainError, |
| 143 | redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain], | ||
| 126 | finalURL: viewModel.currentSnapshot.redirectChain.last?.url | 144 | finalURL: viewModel.currentSnapshot.redirectChain.last?.url |
| 127 | ) | 145 | ) |
| 128 | .padding(.top, appDensity.metrics.sectionSpacing) | 146 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -130,6 +148,8 @@ struct ContentView: View { | |||
| 130 | isCollapsed: sectionCollapsedBinding(.email), | 148 | isCollapsed: sectionCollapsedBinding(.email), |
| 131 | rows: viewModel.emailRows, | 149 | rows: viewModel.emailRows, |
| 132 | loading: viewModel.emailSecurityLoading, | 150 | loading: viewModel.emailSecurityLoading, |
| 151 | provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity], | ||
| 152 | confidence: viewModel.currentSnapshot.emailSecurityConfidence, | ||
| 133 | error: viewModel.emailSecurityError | 153 | error: viewModel.emailSecurityError |
| 134 | ) | 154 | ) |
| 135 | .padding(.top, appDensity.metrics.sectionSpacing) | 155 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -138,14 +158,18 @@ struct ContentView: View { | |||
| 138 | reachabilityRows: viewModel.reachabilityRows, | 158 | reachabilityRows: viewModel.reachabilityRows, |
| 139 | reachabilityLoading: viewModel.reachabilityLoading, | 159 | reachabilityLoading: viewModel.reachabilityLoading, |
| 140 | reachabilityError: viewModel.reachabilityError, | 160 | reachabilityError: viewModel.reachabilityError, |
| 161 | reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability], | ||
| 141 | locationRows: viewModel.locationRows, | 162 | locationRows: viewModel.locationRows, |
| 142 | geolocation: viewModel.ipGeolocation, | 163 | geolocation: viewModel.ipGeolocation, |
| 143 | geolocationLoading: viewModel.ipGeolocationLoading, | 164 | geolocationLoading: viewModel.ipGeolocationLoading, |
| 144 | geolocationError: viewModel.ipGeolocationError, | 165 | geolocationError: viewModel.ipGeolocationError, |
| 166 | geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation], | ||
| 167 | geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence, | ||
| 145 | standardPortRows: viewModel.standardPortRows, | 168 | standardPortRows: viewModel.standardPortRows, |
| 146 | customPortRows: viewModel.customPortRows, | 169 | customPortRows: viewModel.customPortRows, |
| 147 | portScanLoading: viewModel.portScanLoading, | 170 | portScanLoading: viewModel.portScanLoading, |
| 148 | portScanError: viewModel.portScanError, | 171 | portScanError: viewModel.portScanError, |
| 172 | portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan], | ||
| 149 | customPortScanLoading: viewModel.customPortScanLoading, | 173 | customPortScanLoading: viewModel.customPortScanLoading, |
| 150 | customPortScanError: viewModel.customPortScanError, | 174 | customPortScanError: viewModel.customPortScanError, |
| 151 | isCloudflareProxied: viewModel.isCloudflareProxied, | 175 | isCloudflareProxied: viewModel.isCloudflareProxied, |
| @@ -161,27 +185,6 @@ struct ContentView: View { | |||
| 161 | .padding(.horizontal) | 185 | .padding(.horizontal) |
| 162 | .padding(.bottom, 32) | 186 | .padding(.bottom, 32) |
| 163 | } | 187 | } |
| 164 | .safeAreaInset(edge: .top) { | ||
| 165 | if viewModel.hasRun { | ||
| 166 | StickyLookupSummaryView( | ||
| 167 | domain: viewModel.searchedDomain, | ||
| 168 | availability: viewModel.availabilityResult?.status, | ||
| 169 | primaryIP: currentPrimaryIP, | ||
| 170 | sslInfo: viewModel.sslInfo, | ||
| 171 | sslError: viewModel.sslError, | ||
| 172 | emailSecurity: viewModel.emailSecurity, | ||
| 173 | emailError: viewModel.emailSecurityError, | ||
| 174 | changeSummary: viewModel.currentChangeSummary | ||
| 175 | ) | ||
| 176 | .padding(.horizontal) | ||
| 177 | .padding(.top, 6) | ||
| 178 | .background { | ||
| 179 | Rectangle() | ||
| 180 | .fill(.ultraThinMaterial) | ||
| 181 | .opacity(0.96) | ||
| 182 | } | ||
| 183 | } | ||
| 184 | } | ||
| 185 | .background( | 188 | .background( |
| 186 | LinearGradient( | 189 | LinearGradient( |
| 187 | colors: [Color.black, Color(.systemGray6).opacity(0.12)], | 190 | colors: [Color.black, Color(.systemGray6).opacity(0.12)], |
| @@ -521,11 +524,7 @@ struct ContentView: View { | |||
| 521 | } | 524 | } |
| 522 | 525 | ||
| 523 | private var defaultCollapsedSections: Set<ResultSection> { | 526 | private var defaultCollapsedSections: Set<ResultSection> { |
| 524 | var sections: Set<ResultSection> = [] | 527 | [] |
| 525 | if viewModel.standardPortRows.count + viewModel.customPortRows.count > 6 || currentPrimaryIP == nil { | ||
| 526 | sections.insert(.network) | ||
| 527 | } | ||
| 528 | return sections | ||
| 529 | } | 528 | } |
| 530 | 529 | ||
| 531 | private var currentPrimaryIP: String? { | 530 | private var currentPrimaryIP: String? { |
| @@ -636,6 +635,29 @@ struct StickyLookupSummaryView: View { | |||
| 636 | } | 635 | } |
| 637 | } | 636 | } |
| 638 | 637 | ||
| 638 | struct LookupProgressOverviewView: View { | ||
| 639 | @Environment(\.appDensity) private var appDensity | ||
| 640 | let steps: [String] | ||
| 641 | |||
| 642 | var body: some View { | ||
| 643 | CardView(allowsHorizontalScroll: false) { | ||
| 644 | HStack(spacing: 8) { | ||
| 645 | ProgressView() | ||
| 646 | .controlSize(.small) | ||
| 647 | VStack(alignment: .leading, spacing: 4) { | ||
| 648 | Text("Running lookup…") | ||
| 649 | .font(appDensity.font(.caption)) | ||
| 650 | .foregroundStyle(.primary) | ||
| 651 | Text(steps.isEmpty ? "Preparing requests" : steps.joined(separator: " • ")) | ||
| 652 | .font(appDensity.font(.caption2)) | ||
| 653 | .foregroundStyle(.secondary) | ||
| 654 | } | ||
| 655 | Spacer() | ||
| 656 | } | ||
| 657 | } | ||
| 658 | } | ||
| 659 | } | ||
| 660 | |||
| 639 | struct LookupStatusBannerView: View { | 661 | struct LookupStatusBannerView: View { |
| 640 | @Environment(\.appDensity) private var appDensity | 662 | @Environment(\.appDensity) private var appDensity |
| 641 | let message: String | 663 | let message: String |
| @@ -686,6 +708,7 @@ struct LookupStatusBannerView: View { | |||
| 686 | struct DomainChangeSummaryView: View { | 708 | struct DomainChangeSummaryView: View { |
| 687 | @Environment(\.appDensity) private var appDensity | 709 | @Environment(\.appDensity) private var appDensity |
| 688 | let summary: DomainChangeSummary | 710 | let summary: DomainChangeSummary |
| 711 | @State private var showsDetails = false | ||
| 689 | 712 | ||
| 690 | var body: some View { | 713 | var body: some View { |
| 691 | CardView(allowsHorizontalScroll: false) { | 714 | CardView(allowsHorizontalScroll: false) { |
| @@ -706,9 +729,43 @@ struct DomainChangeSummaryView: View { | |||
| 706 | .foregroundStyle(.secondary) | 729 | .foregroundStyle(.secondary) |
| 707 | } | 730 | } |
| 708 | 731 | ||
| 709 | Text(summary.message) | 732 | VStack(alignment: .leading, spacing: 4) { |
| 733 | Text("Inference") | ||
| 734 | .font(appDensity.font(.caption2)) | ||
| 735 | .foregroundStyle(.secondary) | ||
| 736 | Text(summary.message) | ||
| 737 | .font(appDensity.font(.caption)) | ||
| 738 | .foregroundStyle(.primary) | ||
| 739 | .lineLimit(2) | ||
| 740 | } | ||
| 741 | |||
| 742 | if !summary.observedFacts.isEmpty || summary.contextNote != nil { | ||
| 743 | DisclosureGroup(showsDetails ? "Hide Details" : "Show Details", isExpanded: $showsDetails) { | ||
| 744 | VStack(alignment: .leading, spacing: 8) { | ||
| 745 | if !summary.observedFacts.isEmpty { | ||
| 746 | VStack(alignment: .leading, spacing: 4) { | ||
| 747 | Text("Observed") | ||
| 748 | .font(appDensity.font(.caption2)) | ||
| 749 | .foregroundStyle(.secondary) | ||
| 750 | ForEach(Array(summary.observedFacts.enumerated()), id: \.offset) { _, fact in | ||
| 751 | Text(fact) | ||
| 752 | .font(appDensity.font(.caption)) | ||
| 753 | .foregroundStyle(.primary) | ||
| 754 | } | ||
| 755 | } | ||
| 756 | } | ||
| 757 | |||
| 758 | if let contextNote = summary.contextNote { | ||
| 759 | Text(contextNote) | ||
| 760 | .font(appDensity.font(.caption2)) | ||
| 761 | .foregroundStyle(.orange) | ||
| 762 | } | ||
| 763 | } | ||
| 764 | .padding(.top, 4) | ||
| 765 | } | ||
| 710 | .font(appDensity.font(.caption)) | 766 | .font(appDensity.font(.caption)) |
| 711 | .foregroundStyle(.primary) | 767 | .tint(.secondary) |
| 768 | } | ||
| 712 | } | 769 | } |
| 713 | } | 770 | } |
| 714 | 771 | ||
| @@ -727,6 +784,7 @@ struct DomainChangeSummaryView: View { | |||
| 727 | struct DomainDiffView: View { | 784 | struct DomainDiffView: View { |
| 728 | let title: String | 785 | let title: String |
| 729 | let sections: [DomainDiffSection] | 786 | let sections: [DomainDiffSection] |
| 787 | let contextNote: String? | ||
| 730 | let showsUnchanged: Bool | 788 | let showsUnchanged: Bool |
| 731 | 789 | ||
| 732 | @State private var collapsedSections = Set<UUID>() | 790 | @State private var collapsedSections = Set<UUID>() |
| @@ -766,6 +824,9 @@ struct DomainDiffView: View { | |||
| 766 | .font(.system(.caption, design: .monospaced)) | 824 | .font(.system(.caption, design: .monospaced)) |
| 767 | } | 825 | } |
| 768 | } | 826 | } |
| 827 | if let contextNote { | ||
| 828 | MessageCardView(text: contextNote, isError: false) | ||
| 829 | } | ||
| 769 | if filteredSections.isEmpty { | 830 | if filteredSections.isEmpty { |
| 770 | MessageCardView(text: "No comparison data available", isError: false) | 831 | MessageCardView(text: "No comparison data available", isError: false) |
| 771 | } else { | 832 | } else { |
| @@ -917,6 +978,9 @@ struct DomainSectionView: View { | |||
| 917 | let showSuggestions: Bool | 978 | let showSuggestions: Bool |
| 918 | let availabilityLoading: Bool | 979 | let availabilityLoading: Bool |
| 919 | let suggestionsLoading: Bool | 980 | let suggestionsLoading: Bool |
| 981 | let provenance: SectionProvenance? | ||
| 982 | let confidence: ConfidenceLevel? | ||
| 983 | let snapshotNote: String? | ||
| 920 | let trackedDomain: TrackedDomain? | 984 | let trackedDomain: TrackedDomain? |
| 921 | let trackingLimitMessage: String? | 985 | let trackingLimitMessage: String? |
| 922 | let onTrack: () -> Void | 986 | let onTrack: () -> Void |
| @@ -953,6 +1017,11 @@ struct DomainSectionView: View { | |||
| 953 | } | 1017 | } |
| 954 | } content: { | 1018 | } content: { |
| 955 | CardView(allowsHorizontalScroll: false) { | 1019 | CardView(allowsHorizontalScroll: false) { |
| 1020 | SectionTrustMetadataView( | ||
| 1021 | provenance: provenance, | ||
| 1022 | confidence: confidence, | ||
| 1023 | note: snapshotNote == nil ? nil : "Audit note present" | ||
| 1024 | ) | ||
| 956 | ForEach(rows) { row in | 1025 | ForEach(rows) { row in |
| 957 | LabeledValueRow(row: row) | 1026 | LabeledValueRow(row: row) |
| 958 | } | 1027 | } |
| @@ -986,7 +1055,7 @@ struct DomainSectionView: View { | |||
| 986 | .foregroundStyle(.primary) | 1055 | .foregroundStyle(.primary) |
| 987 | .textSelection(.enabled) | 1056 | .textSelection(.enabled) |
| 988 | Spacer() | 1057 | Spacer() |
| 989 | AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered)) | 1058 | AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus)) |
| 990 | } | 1059 | } |
| 991 | } | 1060 | } |
| 992 | } | 1061 | } |
| @@ -1001,11 +1070,14 @@ struct OwnershipSectionView: View { | |||
| 1001 | let rows: [InfoRowViewData] | 1070 | let rows: [InfoRowViewData] |
| 1002 | let loading: Bool | 1071 | let loading: Bool |
| 1003 | let error: String? | 1072 | let error: String? |
| 1073 | let provenance: SectionProvenance? | ||
| 1074 | let confidence: ConfidenceLevel? | ||
| 1004 | let showsHistoryPlaceholder: Bool | 1075 | let showsHistoryPlaceholder: Bool |
| 1005 | 1076 | ||
| 1006 | var body: some View { | 1077 | var body: some View { |
| 1007 | CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { | 1078 | CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) { |
| 1008 | CardView(allowsHorizontalScroll: false) { | 1079 | CardView(allowsHorizontalScroll: false) { |
| 1080 | SectionTrustMetadataView(provenance: provenance, confidence: confidence) | ||
| 1009 | if loading { | 1081 | if loading { |
| 1010 | ProgressView("Fetching RDAP ownership…") | 1082 | ProgressView("Fetching RDAP ownership…") |
| 1011 | .appLoadingStyle() | 1083 | .appLoadingStyle() |
| @@ -1033,11 +1105,14 @@ struct SubdomainsSectionView: View { | |||
| 1033 | let rows: [SubdomainRowViewData] | 1105 | let rows: [SubdomainRowViewData] |
| 1034 | let loading: Bool | 1106 | let loading: Bool |
| 1035 | let error: String? | 1107 | let error: String? |
| 1108 | let provenance: SectionProvenance? | ||
| 1109 | let confidence: ConfidenceLevel? | ||
| 1036 | let showsExtendedPlaceholder: Bool | 1110 | let showsExtendedPlaceholder: Bool |
| 1037 | 1111 | ||
| 1038 | var body: some View { | 1112 | var body: some View { |
| 1039 | CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { | 1113 | CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") { |
| 1040 | CardView(allowsHorizontalScroll: false) { | 1114 | CardView(allowsHorizontalScroll: false) { |
| 1115 | SectionTrustMetadataView(provenance: provenance, confidence: confidence) | ||
| 1041 | if loading { | 1116 | if loading { |
| 1042 | ProgressView("Checking certificate transparency…") | 1117 | ProgressView("Checking certificate transparency…") |
| 1043 | .appLoadingStyle() | 1118 | .appLoadingStyle() |
| @@ -1082,6 +1157,8 @@ struct DNSSectionView: View { | |||
| 1082 | let sections: [DNSRecordSectionViewData] | 1157 | let sections: [DNSRecordSectionViewData] |
| 1083 | let ptrMessage: SectionMessageViewData? | 1158 | let ptrMessage: SectionMessageViewData? |
| 1084 | let loading: Bool | 1159 | let loading: Bool |
| 1160 | let dnsProvenance: SectionProvenance? | ||
| 1161 | let ptrProvenance: SectionProvenance? | ||
| 1085 | let sectionError: String? | 1162 | let sectionError: String? |
| 1086 | 1163 | ||
| 1087 | var body: some View { | 1164 | var body: some View { |
| @@ -1091,6 +1168,11 @@ struct DNSSectionView: View { | |||
| 1091 | } else if let sectionError, sections.isEmpty { | 1168 | } else if let sectionError, sections.isEmpty { |
| 1092 | MessageCardView(text: sectionError, isError: true) | 1169 | MessageCardView(text: sectionError, isError: true) |
| 1093 | } else { | 1170 | } else { |
| 1171 | if dnsProvenance != nil { | ||
| 1172 | CardView(allowsHorizontalScroll: false) { | ||
| 1173 | SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil) | ||
| 1174 | } | ||
| 1175 | } | ||
| 1094 | ForEach(sections) { section in | 1176 | ForEach(sections) { section in |
| 1095 | CardView { | 1177 | CardView { |
| 1096 | Text(section.title) | 1178 | Text(section.title) |
| @@ -1124,6 +1206,7 @@ struct DNSSectionView: View { | |||
| 1124 | .font(.system(.subheadline, design: .monospaced)) | 1206 | .font(.system(.subheadline, design: .monospaced)) |
| 1125 | .fontWeight(.semibold) | 1207 | .fontWeight(.semibold) |
| 1126 | .foregroundStyle(.cyan) | 1208 | .foregroundStyle(.cyan) |
| 1209 | SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil) | ||
| 1127 | MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) | 1210 | MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) |
| 1128 | } | 1211 | } |
| 1129 | } | 1212 | } |
| @@ -1139,13 +1222,16 @@ struct WebSectionView: View { | |||
| 1139 | let sslInfo: SSLCertificateInfo? | 1222 | let sslInfo: SSLCertificateInfo? |
| 1140 | let sslLoading: Bool | 1223 | let sslLoading: Bool |
| 1141 | let sslError: String? | 1224 | let sslError: String? |
| 1225 | let tlsProvenance: SectionProvenance? | ||
| 1142 | let responseRows: [InfoRowViewData] | 1226 | let responseRows: [InfoRowViewData] |
| 1143 | let headers: [HTTPHeader] | 1227 | let headers: [HTTPHeader] |
| 1144 | let headersLoading: Bool | 1228 | let headersLoading: Bool |
| 1145 | let headersError: String? | 1229 | let headersError: String? |
| 1230 | let httpProvenance: SectionProvenance? | ||
| 1146 | let redirects: [RedirectHopViewData] | 1231 | let redirects: [RedirectHopViewData] |
| 1147 | let redirectLoading: Bool | 1232 | let redirectLoading: Bool |
| 1148 | let redirectError: String? | 1233 | let redirectError: String? |
| 1234 | let redirectProvenance: SectionProvenance? | ||
| 1149 | let finalURL: String? | 1235 | let finalURL: String? |
| 1150 | 1236 | ||
| 1151 | var body: some View { | 1237 | var body: some View { |
| @@ -1158,6 +1244,7 @@ struct WebSectionView: View { | |||
| 1158 | Spacer() | 1244 | Spacer() |
| 1159 | AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) | 1245 | AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) |
| 1160 | } | 1246 | } |
| 1247 | SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil) | ||
| 1161 | if sslLoading { | 1248 | if sslLoading { |
| 1162 | ProgressView("Checking certificate…") | 1249 | ProgressView("Checking certificate…") |
| 1163 | .appLoadingStyle() | 1250 | .appLoadingStyle() |
| @@ -1188,6 +1275,7 @@ struct WebSectionView: View { | |||
| 1188 | Text("Headers") | 1275 | Text("Headers") |
| 1189 | .font(appDensity.font(.subheadline, weight: .semibold)) | 1276 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1190 | .foregroundStyle(.cyan) | 1277 | .foregroundStyle(.cyan) |
| 1278 | SectionTrustMetadataView(provenance: httpProvenance, confidence: nil) | ||
| 1191 | if headersLoading { | 1279 | if headersLoading { |
| 1192 | ProgressView("Fetching headers…") | 1280 | ProgressView("Fetching headers…") |
| 1193 | .appLoadingStyle() | 1281 | .appLoadingStyle() |
| @@ -1225,6 +1313,7 @@ struct WebSectionView: View { | |||
| 1225 | AppCopyButton(value: finalURL, label: "Copy redirect URL") | 1313 | AppCopyButton(value: finalURL, label: "Copy redirect URL") |
| 1226 | } | 1314 | } |
| 1227 | } | 1315 | } |
| 1316 | SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil) | ||
| 1228 | if redirectLoading { | 1317 | if redirectLoading { |
| 1229 | ProgressView("Tracing redirects…") | 1318 | ProgressView("Tracing redirects…") |
| 1230 | .appLoadingStyle() | 1319 | .appLoadingStyle() |
| @@ -1268,11 +1357,14 @@ struct EmailSectionView: View { | |||
| 1268 | @Binding var isCollapsed: Bool | 1357 | @Binding var isCollapsed: Bool |
| 1269 | let rows: [EmailRowViewData] | 1358 | let rows: [EmailRowViewData] |
| 1270 | let loading: Bool | 1359 | let loading: Bool |
| 1360 | let provenance: SectionProvenance? | ||
| 1361 | let confidence: ConfidenceLevel? | ||
| 1271 | let error: String? | 1362 | let error: String? |
| 1272 | 1363 | ||
| 1273 | var body: some View { | 1364 | var body: some View { |
| 1274 | CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { | 1365 | CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) { |
| 1275 | CardView { | 1366 | CardView { |
| 1367 | SectionTrustMetadataView(provenance: provenance, confidence: confidence) | ||
| 1276 | HStack { | 1368 | HStack { |
| 1277 | Spacer() | 1369 | Spacer() |
| 1278 | AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) | 1370 | AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) |
| @@ -1331,14 +1423,18 @@ struct NetworkSectionView: View { | |||
| 1331 | let reachabilityRows: [ReachabilityRowViewData] | 1423 | let reachabilityRows: [ReachabilityRowViewData] |
| 1332 | let reachabilityLoading: Bool | 1424 | let reachabilityLoading: Bool |
| 1333 | let reachabilityError: String? | 1425 | let reachabilityError: String? |
| 1426 | let reachabilityProvenance: SectionProvenance? | ||
| 1334 | let locationRows: [InfoRowViewData] | 1427 | let locationRows: [InfoRowViewData] |
| 1335 | let geolocation: IPGeolocation? | 1428 | let geolocation: IPGeolocation? |
| 1336 | let geolocationLoading: Bool | 1429 | let geolocationLoading: Bool |
| 1337 | let geolocationError: String? | 1430 | let geolocationError: String? |
| 1431 | let geolocationProvenance: SectionProvenance? | ||
| 1432 | let geolocationConfidence: ConfidenceLevel? | ||
| 1338 | let standardPortRows: [PortScanRowViewData] | 1433 | let standardPortRows: [PortScanRowViewData] |
| 1339 | let customPortRows: [PortScanRowViewData] | 1434 | let customPortRows: [PortScanRowViewData] |
| 1340 | let portScanLoading: Bool | 1435 | let portScanLoading: Bool |
| 1341 | let portScanError: String? | 1436 | let portScanError: String? |
| 1437 | let portScanProvenance: SectionProvenance? | ||
| 1342 | let customPortScanLoading: Bool | 1438 | let customPortScanLoading: Bool |
| 1343 | let customPortScanError: String? | 1439 | let customPortScanError: String? |
| 1344 | let isCloudflareProxied: Bool | 1440 | let isCloudflareProxied: Bool |
| @@ -1352,6 +1448,7 @@ struct NetworkSectionView: View { | |||
| 1352 | Text("Reachability") | 1448 | Text("Reachability") |
| 1353 | .font(appDensity.font(.subheadline, weight: .semibold)) | 1449 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1354 | .foregroundStyle(.cyan) | 1450 | .foregroundStyle(.cyan) |
| 1451 | SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil) | ||
| 1355 | if reachabilityLoading { | 1452 | if reachabilityLoading { |
| 1356 | ProgressView("Checking ports…") | 1453 | ProgressView("Checking ports…") |
| 1357 | .appLoadingStyle() | 1454 | .appLoadingStyle() |
| @@ -1376,6 +1473,7 @@ struct NetworkSectionView: View { | |||
| 1376 | Text("Location") | 1473 | Text("Location") |
| 1377 | .font(appDensity.font(.subheadline, weight: .semibold)) | 1474 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1378 | .foregroundStyle(.cyan) | 1475 | .foregroundStyle(.cyan) |
| 1476 | SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence) | ||
| 1379 | if geolocationLoading { | 1477 | if geolocationLoading { |
| 1380 | ProgressView("Looking up location…") | 1478 | ProgressView("Looking up location…") |
| 1381 | .appLoadingStyle() | 1479 | .appLoadingStyle() |
| @@ -1407,6 +1505,7 @@ struct NetworkSectionView: View { | |||
| 1407 | Text("Port Scan") | 1505 | Text("Port Scan") |
| 1408 | .font(appDensity.font(.subheadline, weight: .semibold)) | 1506 | .font(appDensity.font(.subheadline, weight: .semibold)) |
| 1409 | .foregroundStyle(.cyan) | 1507 | .foregroundStyle(.cyan) |
| 1508 | SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil) | ||
| 1410 | 1509 | ||
| 1411 | if isCloudflareProxied { | 1510 | if isCloudflareProxied { |
| 1412 | Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") | 1511 | Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") |
| @@ -1609,6 +1708,62 @@ struct MessageRowView: View { | |||
| 1609 | } | 1708 | } |
| 1610 | } | 1709 | } |
| 1611 | 1710 | ||
| 1711 | struct SectionTrustMetadataView: View { | ||
| 1712 | @Environment(\.appDensity) private var appDensity | ||
| 1713 | let provenance: SectionProvenance? | ||
| 1714 | let confidence: ConfidenceLevel? | ||
| 1715 | let note: String? | ||
| 1716 | |||
| 1717 | init(provenance: SectionProvenance?, confidence: ConfidenceLevel?, note: String? = nil) { | ||
| 1718 | self.provenance = provenance | ||
| 1719 | self.confidence = confidence | ||
| 1720 | self.note = note | ||
| 1721 | } | ||
| 1722 | |||
| 1723 | var body: some View { | ||
| 1724 | if provenance != nil || confidence != nil || note != nil { | ||
| 1725 | VStack(alignment: .leading, spacing: 6) { | ||
| 1726 | HStack(spacing: 8) { | ||
| 1727 | if let confidence { | ||
| 1728 | Text("Confidence \(confidence.title)") | ||
| 1729 | .font(appDensity.font(.caption2)) | ||
| 1730 | .foregroundStyle(.secondary) | ||
| 1731 | } | ||
| 1732 | if let provenance { | ||
| 1733 | Text(provenance.provider ?? provenance.source) | ||
| 1734 | .font(appDensity.font(.caption2)) | ||
| 1735 | .foregroundStyle(.secondary) | ||
| 1736 | Text(provenance.resultSource.label) | ||
| 1737 | .font(appDensity.font(.caption2)) | ||
| 1738 | .foregroundStyle(.secondary) | ||
| 1739 | } | ||
| 1740 | } | ||
| 1741 | DisclosureGroup("Details") { | ||
| 1742 | VStack(alignment: .leading, spacing: 4) { | ||
| 1743 | if let provenance { | ||
| 1744 | LabeledValueRow(row: .init(label: "Method", value: provenance.source, tone: .secondary)) | ||
| 1745 | if let provider = provenance.provider { | ||
| 1746 | LabeledValueRow(row: .init(label: "Provider", value: provider, tone: .secondary)) | ||
| 1747 | } | ||
| 1748 | if let resolver = provenance.resolver { | ||
| 1749 | LabeledValueRow(row: .init(label: "Resolver", value: resolver, tone: .secondary)) | ||
| 1750 | } | ||
| 1751 | LabeledValueRow(row: .init(label: "Collected", value: provenance.collectedAt.formatted(date: .abbreviated, time: .shortened), tone: .secondary)) | ||
| 1752 | LabeledValueRow(row: .init(label: "Mode", value: provenance.resultSource.label, tone: .secondary)) | ||
| 1753 | } | ||
| 1754 | if let note { | ||
| 1755 | LabeledValueRow(row: .init(label: "Note", value: note, tone: .secondary)) | ||
| 1756 | } | ||
| 1757 | } | ||
| 1758 | .padding(.top, 4) | ||
| 1759 | } | ||
| 1760 | .font(appDensity.font(.caption)) | ||
| 1761 | .tint(.secondary) | ||
| 1762 | } | ||
| 1763 | } | ||
| 1764 | } | ||
| 1765 | } | ||
| 1766 | |||
| 1612 | struct LabeledValueRow: View { | 1767 | struct LabeledValueRow: View { |
| 1613 | @Environment(\.appDensity) private var appDensity | 1768 | @Environment(\.appDensity) private var appDensity |
| 1614 | let row: InfoRowViewData | 1769 | let row: InfoRowViewData |
| @@ -1737,7 +1892,6 @@ private struct SettingsView: View { | |||
| 1737 | Section("About") { | 1892 | Section("About") { |
| 1738 | LabeledContent("Version", value: appVersion) | 1893 | LabeledContent("Version", value: appVersion) |
| 1739 | LabeledContent("Storage", value: "Local-only") | 1894 | LabeledContent("Storage", value: "Local-only") |
| 1740 | LabeledContent("Focus", value: "Readable domain inspection") | ||
| 1741 | } | 1895 | } |
| 1742 | } | 1896 | } |
| 1743 | .navigationTitle("Settings") | 1897 | .navigationTitle("Settings") |
| @@ -1776,7 +1930,7 @@ private struct SettingsView: View { | |||
| 1776 | } | 1930 | } |
| 1777 | 1931 | ||
| 1778 | private var appVersion: String { | 1932 | private var appVersion: String { |
| 1779 | Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0" | 1933 | AppVersion.current |
| 1780 | } | 1934 | } |
| 1781 | } | 1935 | } |
| 1782 | 1936 | ||
DomainDig/DomainAvailabilityService.swift +6 −2
| @@ -15,8 +15,12 @@ struct DomainAvailabilityService { | |||
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain) | 17 | let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain) |
| 18 | let method = fallbackStatus == .registered ? "dns" : "fallback" | 18 | if fallbackStatus == .registered { |
| 19 | debugLog(method, domain: normalizedDomain, status: fallbackStatus) | 19 | debugLog("dns-evidence", domain: normalizedDomain, details: "DNS exists but RDAP did not confirm registration") |
| 20 | return DomainAvailabilityResult(domain: normalizedDomain, status: .unknown) | ||
| 21 | } | ||
| 22 | |||
| 23 | debugLog("fallback", domain: normalizedDomain, status: fallbackStatus) | ||
| 20 | return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus) | 24 | return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus) |
| 21 | } | 25 | } |
| 22 | 26 | ||
DomainDig/DomainDiffService.swift +26 −1
| @@ -67,16 +67,33 @@ enum DomainDiffService { | |||
| 67 | let highlights = summaryHighlights(from: allChangedItems) | 67 | let highlights = summaryHighlights(from: allChangedItems) |
| 68 | let severity = allChangedItems.map(\.severity).max() ?? .low | 68 | let severity = allChangedItems.map(\.severity).max() ?? .low |
| 69 | let message = summaryMessage(from: allChangedItems, highlights: highlights) | 69 | let message = summaryMessage(from: allChangedItems, highlights: highlights) |
| 70 | let observedFacts = observedFacts(from: allChangedItems) | ||
| 71 | let inferredConclusions = highlights.isEmpty ? [] : [message] | ||
| 72 | let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot) | ||
| 70 | 73 | ||
| 71 | return DomainChangeSummary( | 74 | return DomainChangeSummary( |
| 72 | hasChanges: !allChangedItems.isEmpty, | 75 | hasChanges: !allChangedItems.isEmpty, |
| 73 | changedSections: highlights, | 76 | changedSections: highlights, |
| 74 | message: message, | 77 | message: message, |
| 75 | severity: severity, | 78 | severity: severity, |
| 76 | generatedAt: generatedAt | 79 | generatedAt: generatedAt, |
| 80 | observedFacts: observedFacts, | ||
| 81 | inferredConclusions: inferredConclusions, | ||
| 82 | contextNote: contextNote | ||
| 77 | ) | 83 | ) |
| 78 | } | 84 | } |
| 79 | 85 | ||
| 86 | static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { | ||
| 87 | var notes: [String] = [] | ||
| 88 | if oldSnapshot.resolverURLString != newSnapshot.resolverURLString { | ||
| 89 | notes.append("Compared snapshots used different DNS resolvers.") | ||
| 90 | } | ||
| 91 | if oldSnapshot.resultSource != newSnapshot.resultSource { | ||
| 92 | notes.append("Compared snapshots came from different collection modes.") | ||
| 93 | } | ||
| 94 | return notes.isEmpty ? nil : notes.joined(separator: " ") | ||
| 95 | } | ||
| 96 | |||
| 80 | static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { | 97 | static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { |
| 81 | guard let days = snapshot.sslInfo?.daysUntilExpiry else { | 98 | guard let days = snapshot.sslInfo?.daysUntilExpiry else { |
| 82 | return .none | 99 | return .none |
| @@ -410,6 +427,14 @@ enum DomainDiffService { | |||
| 410 | return "\(highlights[0]) and \(highlights[1].lowercased())" | 427 | return "\(highlights[0]) and \(highlights[1].lowercased())" |
| 411 | } | 428 | } |
| 412 | 429 | ||
| 430 | private static func observedFacts(from items: [DomainDiffItem]) -> [String] { | ||
| 431 | items.prefix(3).map { item in | ||
| 432 | let oldValue = item.oldValue ?? "none" | ||
| 433 | let newValue = item.newValue ?? "none" | ||
| 434 | return "\(item.label): \(oldValue) -> \(newValue)" | ||
| 435 | } | ||
| 436 | } | ||
| 437 | |||
| 413 | private static func normalized(_ value: String?) -> String? { | 438 | private static func normalized(_ value: String?) -> String? { |
| 414 | guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { | 439 | guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { |
| 415 | return nil | 440 | return nil |
DomainDig/DomainViewModel.swift +169 −18
| @@ -87,6 +87,7 @@ struct SubdomainRowViewData: Identifiable { | |||
| 87 | struct DomainSuggestionViewData: Identifiable { | 87 | struct DomainSuggestionViewData: Identifiable { |
| 88 | let id: UUID | 88 | let id: UUID |
| 89 | let domain: String | 89 | let domain: String |
| 90 | let availabilityStatus: DomainAvailabilityStatus | ||
| 90 | let status: String | 91 | let status: String |
| 91 | let tone: ResultTone | 92 | let tone: ResultTone |
| 92 | } | 93 | } |
| @@ -204,13 +205,7 @@ final class DomainViewModel { | |||
| 204 | 205 | ||
| 205 | private static let historyKey = "lookupHistory" | 206 | private static let historyKey = "lookupHistory" |
| 206 | private static let maxHistory = 250 | 207 | private static let maxHistory = 250 |
| 207 | var history: [HistoryEntry] = { | 208 | var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries() |
| 208 | guard let data = UserDefaults.standard.data(forKey: historyKey), | ||
| 209 | let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { | ||
| 210 | return [] | ||
| 211 | } | ||
| 212 | return entries | ||
| 213 | }() | ||
| 214 | var historySearchText = "" | 209 | var historySearchText = "" |
| 215 | var historyDateFilter: HistoryDateFilter = .all | 210 | var historyDateFilter: HistoryDateFilter = .all |
| 216 | var historyChangeFilter: ChangeFilterOption = .all | 211 | var historyChangeFilter: ChangeFilterOption = .all |
| @@ -246,6 +241,24 @@ final class DomainViewModel { | |||
| 246 | !customPortScanLoading | 241 | !customPortScanLoading |
| 247 | } | 242 | } |
| 248 | 243 | ||
| 244 | var activeLoadingLabels: [String] { | ||
| 245 | var labels: [String] = [] | ||
| 246 | if availabilityLoading { labels.append("Availability") } | ||
| 247 | if dnsLoading { labels.append("DNS") } | ||
| 248 | if sslLoading || hstsLoading { labels.append("TLS") } | ||
| 249 | if httpHeadersLoading { labels.append("HTTP") } | ||
| 250 | if ownershipLoading { labels.append("Ownership") } | ||
| 251 | if emailSecurityLoading { labels.append("Email") } | ||
| 252 | if subdomainsLoading { labels.append("Subdomains") } | ||
| 253 | if redirectChainLoading { labels.append("Redirects") } | ||
| 254 | if reachabilityLoading { labels.append("Reachability") } | ||
| 255 | if ipGeolocationLoading { labels.append("Geolocation") } | ||
| 256 | if ptrLoading { labels.append("PTR") } | ||
| 257 | if portScanLoading { labels.append("Port Scan") } | ||
| 258 | if customPortScanLoading { labels.append("Custom Ports") } | ||
| 259 | return labels | ||
| 260 | } | ||
| 261 | |||
| 249 | var isCloudflareProxied: Bool { | 262 | var isCloudflareProxied: Bool { |
| 250 | httpHeaders.contains { $0.name.lowercased() == "cf-ray" } | 263 | httpHeaders.contains { $0.name.lowercased() == "cf-ray" } |
| 251 | } | 264 | } |
| @@ -365,8 +378,20 @@ final class DomainViewModel { | |||
| 365 | domain: searchedDomain, | 378 | domain: searchedDomain, |
| 366 | timestamp: currentSnapshotTimestamp, | 379 | timestamp: currentSnapshotTimestamp, |
| 367 | trackedDomainID: currentTrackedDomain?.id, | 380 | trackedDomainID: currentTrackedDomain?.id, |
| 381 | note: currentHistoryEntry?.note ?? currentTrackedDomain?.note, | ||
| 382 | appVersion: AppVersion.current, | ||
| 368 | resolverDisplayName: resolverDisplayName, | 383 | resolverDisplayName: resolverDisplayName, |
| 369 | resolverURLString: resolverURLString, | 384 | resolverURLString: resolverURLString, |
| 385 | dataSources: currentHistoryEntry?.dataSources ?? [], | ||
| 386 | provenanceBySection: currentHistoryEntry?.provenanceBySection ?? [:], | ||
| 387 | availabilityConfidence: currentHistoryEntry?.availabilityConfidence, | ||
| 388 | ownershipConfidence: currentHistoryEntry?.ownershipConfidence, | ||
| 389 | subdomainConfidence: currentHistoryEntry?.subdomainConfidence, | ||
| 390 | emailSecurityConfidence: currentHistoryEntry?.emailSecurityConfidence, | ||
| 391 | geolocationConfidence: currentHistoryEntry?.geolocationConfidence, | ||
| 392 | errorDetails: currentHistoryEntry?.errorDetails ?? [:], | ||
| 393 | isPartialSnapshot: currentHistoryEntry?.isPartialSnapshot ?? false, | ||
| 394 | validationIssues: currentHistoryEntry?.validationIssues ?? [], | ||
| 370 | totalLookupDurationMs: lastLookupDurationMs, | 395 | totalLookupDurationMs: lastLookupDurationMs, |
| 371 | dnsSections: dnsSections, | 396 | dnsSections: dnsSections, |
| 372 | dnsError: dnsError, | 397 | dnsError: dnsError, |
| @@ -405,6 +430,11 @@ final class DomainViewModel { | |||
| 405 | ) | 430 | ) |
| 406 | } | 431 | } |
| 407 | 432 | ||
| 433 | private var currentHistoryEntry: HistoryEntry? { | ||
| 434 | guard let currentHistoryEntryID else { return nil } | ||
| 435 | return history.first(where: { $0.id == currentHistoryEntryID }) | ||
| 436 | } | ||
| 437 | |||
| 408 | var currentReport: DomainReport? { | 438 | var currentReport: DomainReport? { |
| 409 | guard !searchedDomain.isEmpty else { return nil } | 439 | guard !searchedDomain.isEmpty else { return nil } |
| 410 | return reportBuilder.build( | 440 | return reportBuilder.build( |
| @@ -543,9 +573,7 @@ final class DomainViewModel { | |||
| 543 | } | 573 | } |
| 544 | 574 | ||
| 545 | func rerunInspection(for trackedDomain: TrackedDomain) { | 575 | func rerunInspection(for trackedDomain: TrackedDomain) { |
| 546 | domain = trackedDomain.domain | 576 | rerunInspection(for: trackedDomain, useSnapshotResolver: false) |
| 547 | run() | ||
| 548 | rerunNavigationToken = UUID() | ||
| 549 | } | 577 | } |
| 550 | 578 | ||
| 551 | func deleteTrackedDomains(at offsets: IndexSet) { | 579 | func deleteTrackedDomains(at offsets: IndexSet) { |
| @@ -609,13 +637,24 @@ final class DomainViewModel { | |||
| 609 | UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) | 637 | UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) |
| 610 | } | 638 | } |
| 611 | 639 | ||
| 612 | func rerunLookup(from entry: HistoryEntry) { | 640 | func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) { |
| 613 | UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) | 641 | if useSnapshotResolver { |
| 642 | UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) | ||
| 643 | } | ||
| 614 | domain = entry.domain | 644 | domain = entry.domain |
| 615 | run() | 645 | run() |
| 616 | rerunNavigationToken = UUID() | 646 | rerunNavigationToken = UUID() |
| 617 | } | 647 | } |
| 618 | 648 | ||
| 649 | func rerunInspection(for trackedDomain: TrackedDomain, useSnapshotResolver: Bool) { | ||
| 650 | if useSnapshotResolver, let snapshot = latestSnapshot(for: trackedDomain) { | ||
| 651 | UserDefaults.standard.set(snapshot.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) | ||
| 652 | } | ||
| 653 | domain = trackedDomain.domain | ||
| 654 | run() | ||
| 655 | rerunNavigationToken = UUID() | ||
| 656 | } | ||
| 657 | |||
| 619 | func reset() { | 658 | func reset() { |
| 620 | lookupTask?.cancel() | 659 | lookupTask?.cancel() |
| 621 | customPortScanTask?.cancel() | 660 | customPortScanTask?.cancel() |
| @@ -853,8 +892,20 @@ final class DomainViewModel { | |||
| 853 | domain: previousSnapshot.domain, | 892 | domain: previousSnapshot.domain, |
| 854 | timestamp: previousSnapshot.timestamp, | 893 | timestamp: previousSnapshot.timestamp, |
| 855 | trackedDomainID: previousSnapshot.trackedDomainID, | 894 | trackedDomainID: previousSnapshot.trackedDomainID, |
| 895 | note: previousSnapshot.note, | ||
| 896 | appVersion: previousSnapshot.appVersion, | ||
| 856 | resolverDisplayName: previousSnapshot.resolverDisplayName, | 897 | resolverDisplayName: previousSnapshot.resolverDisplayName, |
| 857 | resolverURLString: previousSnapshot.resolverURLString, | 898 | resolverURLString: previousSnapshot.resolverURLString, |
| 899 | dataSources: previousSnapshot.dataSources, | ||
| 900 | provenanceBySection: previousSnapshot.provenanceBySection, | ||
| 901 | availabilityConfidence: previousSnapshot.availabilityConfidence, | ||
| 902 | ownershipConfidence: previousSnapshot.ownershipConfidence, | ||
| 903 | subdomainConfidence: previousSnapshot.subdomainConfidence, | ||
| 904 | emailSecurityConfidence: previousSnapshot.emailSecurityConfidence, | ||
| 905 | geolocationConfidence: previousSnapshot.geolocationConfidence, | ||
| 906 | errorDetails: previousSnapshot.errorDetails, | ||
| 907 | isPartialSnapshot: previousSnapshot.isPartialSnapshot, | ||
| 908 | validationIssues: previousSnapshot.validationIssues, | ||
| 858 | totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, | 909 | totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, |
| 859 | dnsSections: previousSnapshot.dnsSections, | 910 | dnsSections: previousSnapshot.dnsSections, |
| 860 | dnsError: previousSnapshot.dnsError, | 911 | dnsError: previousSnapshot.dnsError, |
| @@ -1232,6 +1283,7 @@ final class DomainViewModel { | |||
| 1232 | domain: snapshot.domain, | 1283 | domain: snapshot.domain, |
| 1233 | timestamp: snapshot.timestamp, | 1284 | timestamp: snapshot.timestamp, |
| 1234 | trackedDomainID: trackedDomainID, | 1285 | trackedDomainID: trackedDomainID, |
| 1286 | note: currentHistoryEntry?.note, | ||
| 1235 | dnsSections: snapshot.dnsSections, | 1287 | dnsSections: snapshot.dnsSections, |
| 1236 | sslInfo: snapshot.sslInfo, | 1288 | sslInfo: snapshot.sslInfo, |
| 1237 | httpHeaders: snapshot.httpHeaders, | 1289 | httpHeaders: snapshot.httpHeaders, |
| @@ -1247,6 +1299,18 @@ final class DomainViewModel { | |||
| 1247 | hstsPreloaded: snapshot.hstsPreloaded, | 1299 | hstsPreloaded: snapshot.hstsPreloaded, |
| 1248 | availabilityResult: snapshot.availabilityResult, | 1300 | availabilityResult: snapshot.availabilityResult, |
| 1249 | suggestions: snapshot.suggestions, | 1301 | suggestions: snapshot.suggestions, |
| 1302 | appVersion: snapshot.appVersion, | ||
| 1303 | resultSource: snapshot.resultSource, | ||
| 1304 | dataSources: snapshot.dataSources, | ||
| 1305 | provenanceBySection: snapshot.provenanceBySection, | ||
| 1306 | availabilityConfidence: snapshot.availabilityConfidence, | ||
| 1307 | ownershipConfidence: snapshot.ownershipConfidence, | ||
| 1308 | subdomainConfidence: snapshot.subdomainConfidence, | ||
| 1309 | emailSecurityConfidence: snapshot.emailSecurityConfidence, | ||
| 1310 | geolocationConfidence: snapshot.geolocationConfidence, | ||
| 1311 | errorDetails: snapshot.errorDetails, | ||
| 1312 | isPartialSnapshot: snapshot.isPartialSnapshot, | ||
| 1313 | validationIssues: snapshot.validationIssues, | ||
| 1250 | resolverDisplayName: snapshot.resolverDisplayName, | 1314 | resolverDisplayName: snapshot.resolverDisplayName, |
| 1251 | resolverURLString: snapshot.resolverURLString, | 1315 | resolverURLString: snapshot.resolverURLString, |
| 1252 | totalLookupDurationMs: snapshot.totalLookupDurationMs, | 1316 | totalLookupDurationMs: snapshot.totalLookupDurationMs, |
| @@ -1302,6 +1366,12 @@ final class DomainViewModel { | |||
| 1302 | } | 1366 | } |
| 1303 | } | 1367 | } |
| 1304 | 1368 | ||
| 1369 | func updateHistoryNote(_ note: String, for entry: HistoryEntry) { | ||
| 1370 | guard let index = history.firstIndex(where: { $0.id == entry.id }) else { return } | ||
| 1371 | history[index].note = note.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty | ||
| 1372 | persistHistory() | ||
| 1373 | } | ||
| 1374 | |||
| 1305 | private func persistTrackedDomains() { | 1375 | private func persistTrackedDomains() { |
| 1306 | if let data = try? JSONEncoder().encode(trackedDomains) { | 1376 | if let data = try? JSONEncoder().encode(trackedDomains) { |
| 1307 | UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) | 1377 | UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey) |
| @@ -1777,6 +1847,22 @@ final class DomainViewModel { | |||
| 1777 | trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary | 1847 | trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary |
| 1778 | } | 1848 | } |
| 1779 | 1849 | ||
| 1850 | func latestSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot? { | ||
| 1851 | recentSnapshots(for: trackedDomain, limit: 1).first?.snapshot | ||
| 1852 | } | ||
| 1853 | |||
| 1854 | func resolverMismatchNote(for entry: HistoryEntry) -> String? { | ||
| 1855 | guard entry.resolverURLString != resolverURLString else { return nil } | ||
| 1856 | return "Current resolver differs from this snapshot. Re-running may produce different evidence." | ||
| 1857 | } | ||
| 1858 | |||
| 1859 | func resolverMismatchNote(for trackedDomain: TrackedDomain) -> String? { | ||
| 1860 | guard let snapshot = latestSnapshot(for: trackedDomain), snapshot.resolverURLString != resolverURLString else { | ||
| 1861 | return nil | ||
| 1862 | } | ||
| 1863 | return "Current resolver differs from the latest snapshot for this tracked domain." | ||
| 1864 | } | ||
| 1865 | |||
| 1780 | func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { | 1866 | func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? { |
| 1781 | let siblings = history.filter { candidate in | 1867 | let siblings = history.filter { candidate in |
| 1782 | if let trackedDomainID = entry.trackedDomainID { | 1868 | if let trackedDomainID = entry.trackedDomainID { |
| @@ -1834,8 +1920,20 @@ final class DomainViewModel { | |||
| 1834 | domain: trackedDomain.domain, | 1920 | domain: trackedDomain.domain, |
| 1835 | timestamp: trackedDomain.updatedAt, | 1921 | timestamp: trackedDomain.updatedAt, |
| 1836 | trackedDomainID: trackedDomain.id, | 1922 | trackedDomainID: trackedDomain.id, |
| 1923 | note: trackedDomain.note, | ||
| 1924 | appVersion: AppVersion.current, | ||
| 1837 | resolverDisplayName: resolverDisplayName, | 1925 | resolverDisplayName: resolverDisplayName, |
| 1838 | resolverURLString: resolverURLString, | 1926 | resolverURLString: resolverURLString, |
| 1927 | dataSources: [], | ||
| 1928 | provenanceBySection: [:], | ||
| 1929 | availabilityConfidence: nil, | ||
| 1930 | ownershipConfidence: nil, | ||
| 1931 | subdomainConfidence: nil, | ||
| 1932 | emailSecurityConfidence: nil, | ||
| 1933 | geolocationConfidence: nil, | ||
| 1934 | errorDetails: [:], | ||
| 1935 | isPartialSnapshot: true, | ||
| 1936 | validationIssues: ["No stored snapshot data available"], | ||
| 1839 | totalLookupDurationMs: nil, | 1937 | totalLookupDurationMs: nil, |
| 1840 | dnsSections: [], | 1938 | dnsSections: [], |
| 1841 | dnsError: nil, | 1939 | dnsError: nil, |
| @@ -1874,6 +1972,31 @@ final class DomainViewModel { | |||
| 1874 | ) | 1972 | ) |
| 1875 | } | 1973 | } |
| 1876 | 1974 | ||
| 1975 | private static func loadHistoryEntries() -> [HistoryEntry] { | ||
| 1976 | let defaults = UserDefaults.standard | ||
| 1977 | guard let data = defaults.data(forKey: historyKey) else { | ||
| 1978 | return [] | ||
| 1979 | } | ||
| 1980 | |||
| 1981 | if let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) { | ||
| 1982 | return entries | ||
| 1983 | } | ||
| 1984 | |||
| 1985 | guard let rawArray = (try? JSONSerialization.jsonObject(with: data)) as? [Any] else { | ||
| 1986 | return [] | ||
| 1987 | } | ||
| 1988 | |||
| 1989 | let decoder = JSONDecoder() | ||
| 1990 | return rawArray.compactMap { item in | ||
| 1991 | guard JSONSerialization.isValidJSONObject(item), | ||
| 1992 | let itemData = try? JSONSerialization.data(withJSONObject: item), | ||
| 1993 | let entry = try? decoder.decode(HistoryEntry.self, from: itemData) else { | ||
| 1994 | return nil | ||
| 1995 | } | ||
| 1996 | return entry | ||
| 1997 | } | ||
| 1998 | } | ||
| 1999 | |||
| 1877 | private static func loadTrackedDomains() -> [TrackedDomain] { | 2000 | private static func loadTrackedDomains() -> [TrackedDomain] { |
| 1878 | let defaults = UserDefaults.standard | 2001 | let defaults = UserDefaults.standard |
| 1879 | let decoder = JSONDecoder() | 2002 | let decoder = JSONDecoder() |
| @@ -1953,10 +2076,11 @@ final class DomainViewModel { | |||
| 1953 | static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { | 2076 | static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { |
| 1954 | [ | 2077 | [ |
| 1955 | SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), | 2078 | SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), |
| 1956 | SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), | 2079 | SummaryFieldViewData(label: "Observed IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), |
| 1957 | SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), | 2080 | SummaryFieldViewData(label: "Observed Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), |
| 2081 | SummaryFieldViewData(label: "Inference", value: availabilityInference(from: snapshot), tone: availabilityTone(snapshot.availabilityResult?.status)), | ||
| 2082 | SummaryFieldViewData(label: "Observed TLS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), | ||
| 1958 | SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), | 2083 | SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), |
| 1959 | SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), | ||
| 1960 | SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)) | 2084 | SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)) |
| 1961 | ] | 2085 | ] |
| 1962 | } | 2086 | } |
| @@ -1965,17 +2089,32 @@ final class DomainViewModel { | |||
| 1965 | var rows = [ | 2089 | var rows = [ |
| 1966 | InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), | 2090 | InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), |
| 1967 | InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), | 2091 | InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), |
| 2092 | InfoRowViewData(label: "Collected", value: snapshot.timestamp.formatted(date: .abbreviated, time: .shortened), tone: .secondary), | ||
| 1968 | InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)), | 2093 | InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)), |
| 1969 | InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) | 2094 | InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) |
| 1970 | ] | 2095 | ] |
| 1971 | rows.insert( | 2096 | rows.insert( |
| 1972 | InfoRowViewData( | 2097 | InfoRowViewData( |
| 1973 | label: "Availability", | 2098 | label: "Observed Availability", |
| 1974 | value: availabilityLabel(snapshot.availabilityResult?.status), | 2099 | value: snapshot.availabilityResult?.status == .unknown ? "No direct registration proof" : "Status collected", |
| 1975 | tone: availabilityTone(snapshot.availabilityResult?.status) | 2100 | tone: .secondary |
| 1976 | ), | 2101 | ), |
| 1977 | at: 1 | 2102 | at: 1 |
| 1978 | ) | 2103 | ) |
| 2104 | rows.insert( | ||
| 2105 | InfoRowViewData( | ||
| 2106 | label: "Inference", | ||
| 2107 | value: availabilityInference(from: snapshot), | ||
| 2108 | tone: availabilityTone(snapshot.availabilityResult?.status) | ||
| 2109 | ), | ||
| 2110 | at: 2 | ||
| 2111 | ) | ||
| 2112 | if let confidence = snapshot.availabilityConfidence { | ||
| 2113 | rows.insert( | ||
| 2114 | InfoRowViewData(label: "Confidence", value: confidence.title, tone: .secondary), | ||
| 2115 | at: 3 | ||
| 2116 | ) | ||
| 2117 | } | ||
| 1979 | if let certificateStatus = certificateBadgeLabel(from: snapshot) { | 2118 | if let certificateStatus = certificateBadgeLabel(from: snapshot) { |
| 1980 | rows.insert( | 2119 | rows.insert( |
| 1981 | InfoRowViewData( | 2120 | InfoRowViewData( |
| @@ -1994,6 +2133,7 @@ final class DomainViewModel { | |||
| 1994 | DomainSuggestionViewData( | 2133 | DomainSuggestionViewData( |
| 1995 | id: $0.id, | 2134 | id: $0.id, |
| 1996 | domain: $0.domain, | 2135 | domain: $0.domain, |
| 2136 | availabilityStatus: $0.status, | ||
| 1997 | status: availabilityLabel($0.status), | 2137 | status: availabilityLabel($0.status), |
| 1998 | tone: availabilityTone($0.status) | 2138 | tone: availabilityTone($0.status) |
| 1999 | ) | 2139 | ) |
| @@ -2562,6 +2702,17 @@ final class DomainViewModel { | |||
| 2562 | } | 2702 | } |
| 2563 | } | 2703 | } |
| 2564 | 2704 | ||
| 2705 | private static func availabilityInference(from snapshot: LookupSnapshot) -> String { | ||
| 2706 | switch snapshot.availabilityResult?.status { | ||
| 2707 | case .registered: | ||
| 2708 | return "Likely registered" | ||
| 2709 | case .available: | ||
| 2710 | return "Possibly available" | ||
| 2711 | case .unknown, .none: | ||
| 2712 | return "Unclear" | ||
| 2713 | } | ||
| 2714 | } | ||
| 2715 | |||
| 2565 | private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone { | 2716 | private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone { |
| 2566 | switch status { | 2717 | switch status { |
| 2567 | case .available: | 2718 | case .available: |
DomainDig/HistoryView.swift +101 −11
| @@ -39,6 +39,9 @@ struct HistoryView: View { | |||
| 39 | HStack(spacing: 8) { | 39 | HStack(spacing: 8) { |
| 40 | AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status)) | 40 | AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status)) |
| 41 | AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError)) | 41 | AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: entry.sslInfo, error: entry.sslError)) |
| 42 | if entry.isPartialSnapshot { | ||
| 43 | AppStatusBadgeView(model: .init(title: "Partial", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))) | ||
| 44 | } | ||
| 42 | } | 45 | } |
| 43 | 46 | ||
| 44 | HStack(spacing: 8) { | 47 | HStack(spacing: 8) { |
| @@ -51,6 +54,13 @@ struct HistoryView: View { | |||
| 51 | } | 54 | } |
| 52 | .font(appDensity.font(.caption2)) | 55 | .font(appDensity.font(.caption2)) |
| 53 | .foregroundStyle(.secondary) | 56 | .foregroundStyle(.secondary) |
| 57 | |||
| 58 | if let note = entry.note, !note.isEmpty { | ||
| 59 | Text(note) | ||
| 60 | .font(appDensity.font(.caption2)) | ||
| 61 | .foregroundStyle(.secondary) | ||
| 62 | .lineLimit(1) | ||
| 63 | } | ||
| 54 | } | 64 | } |
| 55 | } | 65 | } |
| 56 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | 66 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { |
| @@ -128,6 +138,9 @@ struct HistoryDetailView: View { | |||
| 128 | @Bindable var viewModel: DomainViewModel | 138 | @Bindable var viewModel: DomainViewModel |
| 129 | let entry: HistoryEntry | 139 | let entry: HistoryEntry |
| 130 | @Environment(\.dismiss) private var dismiss | 140 | @Environment(\.dismiss) private var dismiss |
| 141 | @State private var noteDraft = "" | ||
| 142 | @State private var isEditingNote = false | ||
| 143 | @State private var showRerunOptions = false | ||
| 131 | 144 | ||
| 132 | private let dateFormatter: DateFormatter = { | 145 | private let dateFormatter: DateFormatter = { |
| 133 | let formatter = DateFormatter() | 146 | let formatter = DateFormatter() |
| @@ -153,6 +166,9 @@ struct HistoryDetailView: View { | |||
| 153 | showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, | 166 | showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty, |
| 154 | availabilityLoading: false, | 167 | availabilityLoading: false, |
| 155 | suggestionsLoading: false, | 168 | suggestionsLoading: false, |
| 169 | provenance: snapshot.provenanceBySection[.availability], | ||
| 170 | confidence: snapshot.availabilityConfidence, | ||
| 171 | snapshotNote: entry.note, | ||
| 156 | trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), | 172 | trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), |
| 157 | trackingLimitMessage: nil, | 173 | trackingLimitMessage: nil, |
| 158 | onTrack: { | 174 | onTrack: { |
| @@ -170,6 +186,8 @@ struct HistoryDetailView: View { | |||
| 170 | rows: DomainViewModel.ownershipRows(from: snapshot), | 186 | rows: DomainViewModel.ownershipRows(from: snapshot), |
| 171 | loading: false, | 187 | loading: false, |
| 172 | error: snapshot.ownershipError, | 188 | error: snapshot.ownershipError, |
| 189 | provenance: snapshot.provenanceBySection[.ownership], | ||
| 190 | confidence: snapshot.ownershipConfidence, | ||
| 173 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) | 191 | showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) |
| 174 | ) | 192 | ) |
| 175 | .padding(.top, appDensity.metrics.sectionSpacing) | 193 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -178,6 +196,8 @@ struct HistoryDetailView: View { | |||
| 178 | rows: DomainViewModel.subdomainRows(from: snapshot), | 196 | rows: DomainViewModel.subdomainRows(from: snapshot), |
| 179 | loading: false, | 197 | loading: false, |
| 180 | error: snapshot.subdomainsError, | 198 | error: snapshot.subdomainsError, |
| 199 | provenance: snapshot.provenanceBySection[.subdomains], | ||
| 200 | confidence: snapshot.subdomainConfidence, | ||
| 181 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) | 201 | showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) |
| 182 | ) | 202 | ) |
| 183 | .padding(.top, appDensity.metrics.sectionSpacing) | 203 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -189,6 +209,7 @@ struct HistoryDetailView: View { | |||
| 189 | DomainDiffView( | 209 | DomainDiffView( |
| 190 | title: "Compared With Previous Snapshot", | 210 | title: "Compared With Previous Snapshot", |
| 191 | sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), | 211 | sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot), |
| 212 | contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot), | ||
| 192 | showsUnchanged: false | 213 | showsUnchanged: false |
| 193 | ) | 214 | ) |
| 194 | .padding(.top, appDensity.metrics.sectionSpacing) | 215 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -199,6 +220,8 @@ struct HistoryDetailView: View { | |||
| 199 | sections: DomainViewModel.dnsRows(from: snapshot), | 220 | sections: DomainViewModel.dnsRows(from: snapshot), |
| 200 | ptrMessage: DomainViewModel.ptrMessage(from: snapshot), | 221 | ptrMessage: DomainViewModel.ptrMessage(from: snapshot), |
| 201 | loading: false, | 222 | loading: false, |
| 223 | dnsProvenance: snapshot.provenanceBySection[.dns], | ||
| 224 | ptrProvenance: snapshot.provenanceBySection[.ptr], | ||
| 202 | sectionError: snapshot.dnsError | 225 | sectionError: snapshot.dnsError |
| 203 | ) | 226 | ) |
| 204 | .padding(.top, appDensity.metrics.sectionSpacing) | 227 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -208,13 +231,16 @@ struct HistoryDetailView: View { | |||
| 208 | sslInfo: snapshot.sslInfo, | 231 | sslInfo: snapshot.sslInfo, |
| 209 | sslLoading: false, | 232 | sslLoading: false, |
| 210 | sslError: snapshot.sslError, | 233 | sslError: snapshot.sslError, |
| 234 | tlsProvenance: snapshot.provenanceBySection[.ssl], | ||
| 211 | responseRows: DomainViewModel.webResponseRows(from: snapshot), | 235 | responseRows: DomainViewModel.webResponseRows(from: snapshot), |
| 212 | headers: snapshot.httpHeaders, | 236 | headers: snapshot.httpHeaders, |
| 213 | headersLoading: false, | 237 | headersLoading: false, |
| 214 | headersError: snapshot.httpHeadersError, | 238 | headersError: snapshot.httpHeadersError, |
| 239 | httpProvenance: snapshot.provenanceBySection[.httpHeaders], | ||
| 215 | redirects: DomainViewModel.redirectRows(from: snapshot), | 240 | redirects: DomainViewModel.redirectRows(from: snapshot), |
| 216 | redirectLoading: false, | 241 | redirectLoading: false, |
| 217 | redirectError: snapshot.redirectChainError, | 242 | redirectError: snapshot.redirectChainError, |
| 243 | redirectProvenance: snapshot.provenanceBySection[.redirectChain], | ||
| 218 | finalURL: snapshot.redirectChain.last?.url | 244 | finalURL: snapshot.redirectChain.last?.url |
| 219 | ) | 245 | ) |
| 220 | .padding(.top, appDensity.metrics.sectionSpacing) | 246 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -222,6 +248,8 @@ struct HistoryDetailView: View { | |||
| 222 | isCollapsed: .constant(false), | 248 | isCollapsed: .constant(false), |
| 223 | rows: DomainViewModel.emailRows(from: snapshot), | 249 | rows: DomainViewModel.emailRows(from: snapshot), |
| 224 | loading: false, | 250 | loading: false, |
| 251 | provenance: snapshot.provenanceBySection[.emailSecurity], | ||
| 252 | confidence: snapshot.emailSecurityConfidence, | ||
| 225 | error: snapshot.emailSecurityError | 253 | error: snapshot.emailSecurityError |
| 226 | ) | 254 | ) |
| 227 | .padding(.top, appDensity.metrics.sectionSpacing) | 255 | .padding(.top, appDensity.metrics.sectionSpacing) |
| @@ -230,14 +258,18 @@ struct HistoryDetailView: View { | |||
| 230 | reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot), | 258 | reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot), |
| 231 | reachabilityLoading: false, | 259 | reachabilityLoading: false, |
| 232 | reachabilityError: snapshot.reachabilityError, | 260 | reachabilityError: snapshot.reachabilityError, |
| 261 | reachabilityProvenance: snapshot.provenanceBySection[.reachability], | ||
| 233 | locationRows: DomainViewModel.locationRows(from: snapshot), | 262 | locationRows: DomainViewModel.locationRows(from: snapshot), |
| 234 | geolocation: snapshot.ipGeolocation, | 263 | geolocation: snapshot.ipGeolocation, |
| 235 | geolocationLoading: false, | 264 | geolocationLoading: false, |
| 236 | geolocationError: snapshot.ipGeolocationError, | 265 | geolocationError: snapshot.ipGeolocationError, |
| 266 | geolocationProvenance: snapshot.provenanceBySection[.ipGeolocation], | ||
| 267 | geolocationConfidence: snapshot.geolocationConfidence, | ||
| 237 | standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard), | 268 | standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard), |
| 238 | customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom), | 269 | customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom), |
| 239 | portScanLoading: false, | 270 | portScanLoading: false, |
| 240 | portScanError: snapshot.portScanError, | 271 | portScanError: snapshot.portScanError, |
| 272 | portScanProvenance: snapshot.provenanceBySection[.portScan], | ||
| 241 | customPortScanLoading: false, | 273 | customPortScanLoading: false, |
| 242 | customPortScanError: nil, | 274 | customPortScanError: nil, |
| 243 | isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }), | 275 | isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }), |
| @@ -253,26 +285,84 @@ struct HistoryDetailView: View { | |||
| 253 | .background(Color.black) | 285 | .background(Color.black) |
| 254 | .navigationTitle(entry.domain) | 286 | .navigationTitle(entry.domain) |
| 255 | .toolbar { | 287 | .toolbar { |
| 256 | Button("Re-run") { | 288 | ToolbarItemGroup(placement: .topBarTrailing) { |
| 257 | viewModel.rerunLookup(from: entry) | 289 | Button("Note") { |
| 290 | noteDraft = entry.note ?? "" | ||
| 291 | isEditingNote = true | ||
| 292 | } | ||
| 293 | Button("Re-run") { | ||
| 294 | showRerunOptions = true | ||
| 295 | } | ||
| 258 | } | 296 | } |
| 259 | } | 297 | } |
| 260 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | 298 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 261 | dismiss() | 299 | dismiss() |
| 262 | } | 300 | } |
| 301 | .confirmationDialog("Re-run lookup", isPresented: $showRerunOptions) { | ||
| 302 | Button("Run with Current Settings") { | ||
| 303 | viewModel.rerunLookup(from: entry, useSnapshotResolver: false) | ||
| 304 | } | ||
| 305 | Button("Run with Snapshot Resolver") { | ||
| 306 | viewModel.rerunLookup(from: entry, useSnapshotResolver: true) | ||
| 307 | } | ||
| 308 | Button("Cancel", role: .cancel) {} | ||
| 309 | } message: { | ||
| 310 | Text(viewModel.resolverMismatchNote(for: entry) ?? "Choose how to reproduce this snapshot.") | ||
| 311 | } | ||
| 312 | .sheet(isPresented: $isEditingNote) { | ||
| 313 | NavigationStack { | ||
| 314 | Form { | ||
| 315 | Section("Audit Note") { | ||
| 316 | TextField("Optional note", text: $noteDraft, axis: .vertical) | ||
| 317 | .lineLimit(3...6) | ||
| 318 | } | ||
| 319 | } | ||
| 320 | .navigationTitle(entry.domain) | ||
| 321 | .toolbar { | ||
| 322 | ToolbarItem(placement: .cancellationAction) { | ||
| 323 | Button("Cancel") { | ||
| 324 | isEditingNote = false | ||
| 325 | } | ||
| 326 | } | ||
| 327 | ToolbarItem(placement: .confirmationAction) { | ||
| 328 | Button("Save") { | ||
| 329 | viewModel.updateHistoryNote(noteDraft, for: entry) | ||
| 330 | isEditingNote = false | ||
| 331 | } | ||
| 332 | } | ||
| 333 | } | ||
| 334 | } | ||
| 335 | } | ||
| 263 | .preferredColorScheme(.dark) | 336 | .preferredColorScheme(.dark) |
| 264 | } | 337 | } |
| 265 | 338 | ||
| 266 | private var snapshotBanner: some View { | 339 | private var snapshotBanner: some View { |
| 267 | HStack(spacing: 8) { | 340 | VStack(alignment: .leading, spacing: 8) { |
| 268 | Image(systemName: "archivebox") | 341 | HStack(spacing: 8) { |
| 269 | .font(.caption) | 342 | Image(systemName: "archivebox") |
| 270 | Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") | 343 | .font(.caption) |
| 271 | .font(appDensity.font(.caption)) | 344 | Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") |
| 272 | Spacer() | 345 | .font(appDensity.font(.caption)) |
| 273 | Text("Live re-run available") | 346 | Spacer() |
| 274 | .font(appDensity.font(.caption2)) | 347 | Text("Live re-run available") |
| 275 | .foregroundStyle(.secondary) | 348 | .font(appDensity.font(.caption2)) |
| 349 | .foregroundStyle(.secondary) | ||
| 350 | } | ||
| 351 | if let mismatchNote = viewModel.resolverMismatchNote(for: entry) { | ||
| 352 | Text(mismatchNote) | ||
| 353 | .font(appDensity.font(.caption2)) | ||
| 354 | .foregroundStyle(.orange) | ||
| 355 | } | ||
| 356 | if entry.isPartialSnapshot { | ||
| 357 | Text("Partial snapshot: \(entry.validationIssues.joined(separator: " | "))") | ||
| 358 | .font(appDensity.font(.caption2)) | ||
| 359 | .foregroundStyle(.yellow) | ||
| 360 | } | ||
| 361 | if let note = entry.note, !note.isEmpty { | ||
| 362 | Text(note) | ||
| 363 | .font(appDensity.font(.caption2)) | ||
| 364 | .foregroundStyle(.secondary) | ||
| 365 | } | ||
| 276 | } | 366 | } |
| 277 | .foregroundStyle(.secondary) | 367 | .foregroundStyle(.secondary) |
| 278 | .padding(8) | 368 | .padding(8) |
DomainDig/LookupRuntime.swift +3 −9
| @@ -79,15 +79,9 @@ actor LookupRuntime { | |||
| 79 | } | 79 | } |
| 80 | 80 | ||
| 81 | func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> { | 81 | func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> { |
| 82 | await execute( | 82 | CachedLookupResult( |
| 83 | key: .domain(domain, .availability), | 83 | value: await DomainAvailabilityService.check(domain: domain), |
| 84 | extract: { payload in | 84 | source: .live |
| 85 | guard case let .availability(result) = payload else { return nil } | ||
| 86 | return result | ||
| 87 | }, | ||
| 88 | operation: { | ||
| 89 | .availability(await DomainAvailabilityService.check(domain: domain)) | ||
| 90 | } | ||
| 91 | ) | 85 | ) |
| 92 | } | 86 | } |
| 93 | 87 | ||
DomainDig/Models.swift +130 −8
| @@ -6,6 +6,59 @@ enum ServiceResult<Value> { | |||
| 6 | case error(String) | 6 | case error(String) |
| 7 | } | 7 | } |
| 8 | 8 | ||
| 9 | enum ConfidenceLevel: String, Codable { | ||
| 10 | case high | ||
| 11 | case medium | ||
| 12 | case low | ||
| 13 | |||
| 14 | var title: String { | ||
| 15 | rawValue.capitalized | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | enum InspectionErrorKind: String, Codable { | ||
| 20 | case network | ||
| 21 | case timeout | ||
| 22 | case rateLimited | ||
| 23 | case parsing | ||
| 24 | case unsupported | ||
| 25 | case unavailable | ||
| 26 | case unknown | ||
| 27 | |||
| 28 | var title: String { | ||
| 29 | switch self { | ||
| 30 | case .network: | ||
| 31 | return "Network" | ||
| 32 | case .timeout: | ||
| 33 | return "Timeout" | ||
| 34 | case .rateLimited: | ||
| 35 | return "Rate limited" | ||
| 36 | case .parsing: | ||
| 37 | return "Parsing" | ||
| 38 | case .unsupported: | ||
| 39 | return "Unsupported" | ||
| 40 | case .unavailable: | ||
| 41 | return "Unavailable" | ||
| 42 | case .unknown: | ||
| 43 | return "Unknown" | ||
| 44 | } | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | struct InspectionFailure: Codable, Equatable { | ||
| 49 | let kind: InspectionErrorKind | ||
| 50 | let message: String | ||
| 51 | let details: String? | ||
| 52 | } | ||
| 53 | |||
| 54 | struct SectionProvenance: Codable, Equatable { | ||
| 55 | let source: String | ||
| 56 | let collectedAt: Date | ||
| 57 | let provider: String? | ||
| 58 | let resolver: String? | ||
| 59 | let resultSource: LookupResultSource | ||
| 60 | } | ||
| 61 | |||
| 9 | enum LookupResultSource: String, Codable { | 62 | enum LookupResultSource: String, Codable { |
| 10 | case live | 63 | case live |
| 11 | case cached | 64 | case cached |
| @@ -129,19 +182,28 @@ struct DomainChangeSummary: Codable, Equatable { | |||
| 129 | let message: String | 182 | let message: String |
| 130 | let severity: ChangeSeverity | 183 | let severity: ChangeSeverity |
| 131 | let generatedAt: Date | 184 | let generatedAt: Date |
| 185 | let observedFacts: [String] | ||
| 186 | let inferredConclusions: [String] | ||
| 187 | let contextNote: String? | ||
| 132 | 188 | ||
| 133 | init( | 189 | init( |
| 134 | hasChanges: Bool, | 190 | hasChanges: Bool, |
| 135 | changedSections: [String], | 191 | changedSections: [String], |
| 136 | message: String, | 192 | message: String, |
| 137 | severity: ChangeSeverity, | 193 | severity: ChangeSeverity, |
| 138 | generatedAt: Date | 194 | generatedAt: Date, |
| 195 | observedFacts: [String] = [], | ||
| 196 | inferredConclusions: [String] = [], | ||
| 197 | contextNote: String? = nil | ||
| 139 | ) { | 198 | ) { |
| 140 | self.hasChanges = hasChanges | 199 | self.hasChanges = hasChanges |
| 141 | self.changedSections = changedSections | 200 | self.changedSections = changedSections |
| 142 | self.message = message | 201 | self.message = message |
| 143 | self.severity = severity | 202 | self.severity = severity |
| 144 | self.generatedAt = generatedAt | 203 | self.generatedAt = generatedAt |
| 204 | self.observedFacts = observedFacts | ||
| 205 | self.inferredConclusions = inferredConclusions | ||
| 206 | self.contextNote = contextNote | ||
| 145 | } | 207 | } |
| 146 | 208 | ||
| 147 | init(from decoder: Decoder) throws { | 209 | init(from decoder: Decoder) throws { |
| @@ -152,6 +214,9 @@ struct DomainChangeSummary: Codable, Equatable { | |||
| 152 | severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low) | 214 | severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low) |
| 153 | message = try container.decodeIfPresent(String.self, forKey: .message) | 215 | message = try container.decodeIfPresent(String.self, forKey: .message) |
| 154 | ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • ")) | 216 | ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • ")) |
| 217 | observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? [] | ||
| 218 | inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? [] | ||
| 219 | contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote) | ||
| 155 | } | 220 | } |
| 156 | } | 221 | } |
| 157 | 222 | ||
| @@ -709,6 +774,7 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 709 | let domain: String | 774 | let domain: String |
| 710 | let timestamp: Date | 775 | let timestamp: Date |
| 711 | var trackedDomainID: UUID? | 776 | var trackedDomainID: UUID? |
| 777 | var note: String? | ||
| 712 | let dnsSections: [DNSSection] | 778 | let dnsSections: [DNSSection] |
| 713 | let sslInfo: SSLCertificateInfo? | 779 | let sslInfo: SSLCertificateInfo? |
| 714 | let httpHeaders: [HTTPHeader] | 780 | let httpHeaders: [HTTPHeader] |
| @@ -724,6 +790,18 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 724 | var hstsPreloaded: Bool? | 790 | var hstsPreloaded: Bool? |
| 725 | var availabilityResult: DomainAvailabilityResult? | 791 | var availabilityResult: DomainAvailabilityResult? |
| 726 | var suggestions: [DomainSuggestionResult] | 792 | var suggestions: [DomainSuggestionResult] |
| 793 | var appVersion: String | ||
| 794 | var resultSource: LookupResultSource | ||
| 795 | var dataSources: [String] | ||
| 796 | var provenanceBySection: [LookupSectionKind: SectionProvenance] | ||
| 797 | var availabilityConfidence: ConfidenceLevel? | ||
| 798 | var ownershipConfidence: ConfidenceLevel? | ||
| 799 | var subdomainConfidence: ConfidenceLevel? | ||
| 800 | var emailSecurityConfidence: ConfidenceLevel? | ||
| 801 | var geolocationConfidence: ConfidenceLevel? | ||
| 802 | var errorDetails: [LookupSectionKind: InspectionFailure] | ||
| 803 | var isPartialSnapshot: Bool | ||
| 804 | var validationIssues: [String] | ||
| 727 | var resolverDisplayName: String | 805 | var resolverDisplayName: String |
| 728 | var resolverURLString: String | 806 | var resolverURLString: String |
| 729 | var totalLookupDurationMs: Int? | 807 | var totalLookupDurationMs: Int? |
| @@ -744,14 +822,21 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 744 | var subdomainsError: String? | 822 | var subdomainsError: String? |
| 745 | var portScanError: String? | 823 | var portScanError: String? |
| 746 | 824 | ||
| 747 | init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection], | 825 | init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], |
| 748 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], | 826 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], |
| 749 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, | 827 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 750 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, | 828 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, |
| 751 | ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], | 829 | ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], |
| 752 | portScanResults: [PortScanResult] = [], | 830 | portScanResults: [PortScanResult] = [], |
| 753 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, | 831 | hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, |
| 754 | suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, | 832 | suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", |
| 833 | resultSource: LookupResultSource = .snapshot, dataSources: [String] = [], | ||
| 834 | provenanceBySection: [LookupSectionKind: SectionProvenance] = [:], | ||
| 835 | availabilityConfidence: ConfidenceLevel? = nil, ownershipConfidence: ConfidenceLevel? = nil, | ||
| 836 | subdomainConfidence: ConfidenceLevel? = nil, emailSecurityConfidence: ConfidenceLevel? = nil, | ||
| 837 | geolocationConfidence: ConfidenceLevel? = nil, | ||
| 838 | errorDetails: [LookupSectionKind: InspectionFailure] = [:], isPartialSnapshot: Bool = false, | ||
| 839 | validationIssues: [String] = [], resolverDisplayName: String, resolverURLString: String, | ||
| 755 | totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, | 840 | totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, |
| 756 | tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, | 841 | tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, |
| 757 | changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | 842 | changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, |
| @@ -761,6 +846,7 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 761 | self.domain = domain | 846 | self.domain = domain |
| 762 | self.timestamp = timestamp | 847 | self.timestamp = timestamp |
| 763 | self.trackedDomainID = trackedDomainID | 848 | self.trackedDomainID = trackedDomainID |
| 849 | self.note = note | ||
| 764 | self.dnsSections = dnsSections | 850 | self.dnsSections = dnsSections |
| 765 | self.sslInfo = sslInfo | 851 | self.sslInfo = sslInfo |
| 766 | self.httpHeaders = httpHeaders | 852 | self.httpHeaders = httpHeaders |
| @@ -776,6 +862,18 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 776 | self.hstsPreloaded = hstsPreloaded | 862 | self.hstsPreloaded = hstsPreloaded |
| 777 | self.availabilityResult = availabilityResult | 863 | self.availabilityResult = availabilityResult |
| 778 | self.suggestions = suggestions | 864 | self.suggestions = suggestions |
| 865 | self.appVersion = appVersion | ||
| 866 | self.resultSource = resultSource | ||
| 867 | self.dataSources = dataSources | ||
| 868 | self.provenanceBySection = provenanceBySection | ||
| 869 | self.availabilityConfidence = availabilityConfidence | ||
| 870 | self.ownershipConfidence = ownershipConfidence | ||
| 871 | self.subdomainConfidence = subdomainConfidence | ||
| 872 | self.emailSecurityConfidence = emailSecurityConfidence | ||
| 873 | self.geolocationConfidence = geolocationConfidence | ||
| 874 | self.errorDetails = errorDetails | ||
| 875 | self.isPartialSnapshot = isPartialSnapshot | ||
| 876 | self.validationIssues = validationIssues | ||
| 779 | self.resolverDisplayName = resolverDisplayName | 877 | self.resolverDisplayName = resolverDisplayName |
| 780 | self.resolverURLString = resolverURLString | 878 | self.resolverURLString = resolverURLString |
| 781 | self.totalLookupDurationMs = totalLookupDurationMs | 879 | self.totalLookupDurationMs = totalLookupDurationMs |
| @@ -800,13 +898,14 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 800 | init(from decoder: Decoder) throws { | 898 | init(from decoder: Decoder) throws { |
| 801 | let container = try decoder.container(keyedBy: CodingKeys.self) | 899 | let container = try decoder.container(keyedBy: CodingKeys.self) |
| 802 | id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | 900 | id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() |
| 803 | domain = try container.decode(String.self, forKey: .domain) | 901 | domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain" |
| 804 | timestamp = try container.decode(Date.self, forKey: .timestamp) | 902 | timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast |
| 805 | trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID) | 903 | trackedDomainID = try container.decodeIfPresent(UUID.self, forKey: .trackedDomainID) |
| 806 | dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections) | 904 | note = try container.decodeIfPresent(String.self, forKey: .note) |
| 905 | dnsSections = try container.decodeIfPresent([DNSSection].self, forKey: .dnsSections) ?? [] | ||
| 807 | sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) | 906 | sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) |
| 808 | httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders) | 907 | httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? [] |
| 809 | reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults) | 908 | reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? [] |
| 810 | ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) | 909 | ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) |
| 811 | emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) | 910 | emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) |
| 812 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts | 911 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts |
| @@ -818,6 +917,18 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 818 | hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) | 917 | hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) |
| 819 | availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) | 918 | availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) |
| 820 | suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? [] | 919 | suggestions = try container.decodeIfPresent([DomainSuggestionResult].self, forKey: .suggestions) ?? [] |
| 920 | appVersion = try container.decodeIfPresent(String.self, forKey: .appVersion) ?? "2.6.0" | ||
| 921 | resultSource = try container.decodeIfPresent(LookupResultSource.self, forKey: .resultSource) ?? .snapshot | ||
| 922 | dataSources = try container.decodeIfPresent([String].self, forKey: .dataSources) ?? [] | ||
| 923 | provenanceBySection = try container.decodeIfPresent([LookupSectionKind: SectionProvenance].self, forKey: .provenanceBySection) ?? [:] | ||
| 924 | availabilityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .availabilityConfidence) | ||
| 925 | ownershipConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .ownershipConfidence) | ||
| 926 | subdomainConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .subdomainConfidence) | ||
| 927 | emailSecurityConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .emailSecurityConfidence) | ||
| 928 | geolocationConfidence = try container.decodeIfPresent(ConfidenceLevel.self, forKey: .geolocationConfidence) | ||
| 929 | errorDetails = try container.decodeIfPresent([LookupSectionKind: InspectionFailure].self, forKey: .errorDetails) ?? [:] | ||
| 930 | validationIssues = try container.decodeIfPresent([String].self, forKey: .validationIssues) ?? HistoryEntry.defaultValidationIssues(domain: domain, timestamp: timestamp) | ||
| 931 | isPartialSnapshot = try container.decodeIfPresent(Bool.self, forKey: .isPartialSnapshot) ?? !validationIssues.isEmpty | ||
| 821 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" | 932 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" |
| 822 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString | 933 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString |
| 823 | totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) | 934 | totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) |
| @@ -838,6 +949,17 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 838 | subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) | 949 | subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) |
| 839 | portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) | 950 | portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) |
| 840 | } | 951 | } |
| 952 | |||
| 953 | private static func defaultValidationIssues(domain: String, timestamp: Date) -> [String] { | ||
| 954 | var issues: [String] = [] | ||
| 955 | if domain == "unknown-domain" { | ||
| 956 | issues.append("Missing domain in stored snapshot") | ||
| 957 | } | ||
| 958 | if timestamp == .distantPast { | ||
| 959 | issues.append("Missing collection timestamp in stored snapshot") | ||
| 960 | } | ||
| 961 | return issues | ||
| 962 | } | ||
| 841 | } | 963 | } |
| 842 | 964 | ||
| 843 | // MARK: - Cloudflare DNS-over-HTTPS Response | 965 | // MARK: - Cloudflare DNS-over-HTTPS Response |
DomainDig/WatchlistView.swift +23 −2
| @@ -334,6 +334,7 @@ struct TrackedDomainDetailView: View { | |||
| 334 | 334 | ||
| 335 | @State private var noteDraft = "" | 335 | @State private var noteDraft = "" |
| 336 | @State private var isEditingNote = false | 336 | @State private var isEditingNote = false |
| 337 | @State private var showRerunOptions = false | ||
| 337 | 338 | ||
| 338 | private var liveTrackedDomain: TrackedDomain { | 339 | private var liveTrackedDomain: TrackedDomain { |
| 339 | viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain | 340 | viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain |
| @@ -365,7 +366,7 @@ struct TrackedDomainDetailView: View { | |||
| 365 | } | 366 | } |
| 366 | 367 | ||
| 367 | Button { | 368 | Button { |
| 368 | viewModel.rerunInspection(for: liveTrackedDomain) | 369 | showRerunOptions = true |
| 369 | } label: { | 370 | } label: { |
| 370 | Label("Re-run Inspection", systemImage: "magnifyingglass") | 371 | Label("Re-run Inspection", systemImage: "magnifyingglass") |
| 371 | } | 372 | } |
| @@ -394,7 +395,14 @@ struct TrackedDomainDetailView: View { | |||
| 394 | 395 | ||
| 395 | if !latestDiffSections.isEmpty { | 396 | if !latestDiffSections.isEmpty { |
| 396 | Section("Latest Diff") { | 397 | Section("Latest Diff") { |
| 397 | DomainDiffView(title: "Latest Snapshot vs Previous", sections: latestDiffSections, showsUnchanged: false) | 398 | DomainDiffView( |
| 399 | title: "Latest Snapshot vs Previous", | ||
| 400 | sections: latestDiffSections, | ||
| 401 | contextNote: latestSnapshots.count >= 2 | ||
| 402 | ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot) | ||
| 403 | : nil, | ||
| 404 | showsUnchanged: false | ||
| 405 | ) | ||
| 398 | } | 406 | } |
| 399 | .listRowBackground(Color.clear) | 407 | .listRowBackground(Color.clear) |
| 400 | } | 408 | } |
| @@ -430,6 +438,19 @@ struct TrackedDomainDetailView: View { | |||
| 430 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in | 438 | .onChange(of: viewModel.rerunNavigationToken) { _, _ in |
| 431 | dismiss() | 439 | dismiss() |
| 432 | } | 440 | } |
| 441 | .confirmationDialog("Re-run inspection", isPresented: $showRerunOptions) { | ||
| 442 | Button("Run with Current Settings") { | ||
| 443 | viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: false) | ||
| 444 | } | ||
| 445 | if latestSnapshots.first != nil { | ||
| 446 | Button("Run with Snapshot Resolver") { | ||
| 447 | viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: true) | ||
| 448 | } | ||
| 449 | } | ||
| 450 | Button("Cancel", role: .cancel) {} | ||
| 451 | } message: { | ||
| 452 | Text(viewModel.resolverMismatchNote(for: liveTrackedDomain) ?? "Choose how to reproduce the most recent snapshot.") | ||
| 453 | } | ||
| 433 | .sheet(isPresented: $isEditingNote) { | 454 | .sheet(isPresented: $isEditingNote) { |
| 434 | NavigationStack { | 455 | NavigationStack { |
| 435 | Form { | 456 | Form { |
DomainInspectionService.swift +315 −32
| @@ -20,6 +20,9 @@ struct DomainInspectionService { | |||
| 20 | let resolverURLString = DNSLookupService.currentResolverURLString() | 20 | let resolverURLString = DNSLookupService.currentResolverURLString() |
| 21 | var cachedSections = Set<LookupSectionKind>() | 21 | var cachedSections = Set<LookupSectionKind>() |
| 22 | var sectionSources: [LookupResultSource] = [] | 22 | var sectionSources: [LookupResultSource] = [] |
| 23 | var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:] | ||
| 24 | var dataSources = Set<String>() | ||
| 25 | var errorDetails: [LookupSectionKind: InspectionFailure] = [:] | ||
| 23 | 26 | ||
| 24 | async let dnsFetch = runtime.dns(domain: normalizedDomain) | 27 | async let dnsFetch = runtime.dns(domain: normalizedDomain) |
| 25 | async let availabilityFetch = runtime.availability(domain: normalizedDomain) | 28 | async let availabilityFetch = runtime.availability(domain: normalizedDomain) |
| @@ -34,41 +37,129 @@ struct DomainInspectionService { | |||
| 34 | 37 | ||
| 35 | let resolvedDNS = await dnsFetch | 38 | let resolvedDNS = await dnsFetch |
| 36 | let dnsResult = normalizeErrors(in: resolvedDNS.value) | 39 | let dnsResult = normalizeErrors(in: resolvedDNS.value) |
| 37 | track(.dns, source: resolvedDNS.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 40 | track( |
| 41 | .dns, | ||
| 42 | source: resolvedDNS.source, | ||
| 43 | provenance: provenance(for: .dns, source: resolvedDNS.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 44 | cachedSections: &cachedSections, | ||
| 45 | sectionSources: §ionSources, | ||
| 46 | provenanceBySection: &provenanceBySection, | ||
| 47 | dataSources: &dataSources | ||
| 48 | ) | ||
| 49 | captureFailure(for: .dns, result: dnsResult, into: &errorDetails) | ||
| 38 | 50 | ||
| 39 | let availability = await availabilityFetch | 51 | let availability = await availabilityFetch |
| 40 | track(.availability, source: availability.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 52 | track( |
| 53 | .availability, | ||
| 54 | source: availability.source, | ||
| 55 | provenance: provenance(for: .availability, source: availability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 56 | cachedSections: &cachedSections, | ||
| 57 | sectionSources: §ionSources, | ||
| 58 | provenanceBySection: &provenanceBySection, | ||
| 59 | dataSources: &dataSources | ||
| 60 | ) | ||
| 41 | 61 | ||
| 42 | let resolvedSSL = await sslFetch | 62 | let resolvedSSL = await sslFetch |
| 43 | let sslResult = normalizeErrors(in: resolvedSSL.value) | 63 | let sslResult = normalizeErrors(in: resolvedSSL.value) |
| 44 | track(.ssl, source: resolvedSSL.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 64 | track( |
| 65 | .ssl, | ||
| 66 | source: resolvedSSL.source, | ||
| 67 | provenance: provenance(for: .ssl, source: resolvedSSL.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 68 | cachedSections: &cachedSections, | ||
| 69 | sectionSources: §ionSources, | ||
| 70 | provenanceBySection: &provenanceBySection, | ||
| 71 | dataSources: &dataSources | ||
| 72 | ) | ||
| 73 | captureFailure(for: .ssl, result: sslResult, into: &errorDetails) | ||
| 45 | 74 | ||
| 46 | let hsts = await hstsFetch | 75 | let hsts = await hstsFetch |
| 47 | track(.hsts, source: hsts.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 76 | track( |
| 77 | .hsts, | ||
| 78 | source: hsts.source, | ||
| 79 | provenance: provenance(for: .hsts, source: hsts.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 80 | cachedSections: &cachedSections, | ||
| 81 | sectionSources: §ionSources, | ||
| 82 | provenanceBySection: &provenanceBySection, | ||
| 83 | dataSources: &dataSources | ||
| 84 | ) | ||
| 48 | 85 | ||
| 49 | let http = await httpFetch | 86 | let http = await httpFetch |
| 50 | let httpResult = normalizeErrors(in: http.value) | 87 | let httpResult = normalizeErrors(in: http.value) |
| 51 | track(.httpHeaders, source: http.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 88 | track( |
| 89 | .httpHeaders, | ||
| 90 | source: http.source, | ||
| 91 | provenance: provenance(for: .httpHeaders, source: http.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 92 | cachedSections: &cachedSections, | ||
| 93 | sectionSources: §ionSources, | ||
| 94 | provenanceBySection: &provenanceBySection, | ||
| 95 | dataSources: &dataSources | ||
| 96 | ) | ||
| 97 | captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails) | ||
| 52 | 98 | ||
| 53 | let reachability = await reachabilityFetch | 99 | let reachability = await reachabilityFetch |
| 54 | let reachabilityResult = normalizeErrors(in: reachability.value) | 100 | let reachabilityResult = normalizeErrors(in: reachability.value) |
| 55 | track(.reachability, source: reachability.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 101 | track( |
| 102 | .reachability, | ||
| 103 | source: reachability.source, | ||
| 104 | provenance: provenance(for: .reachability, source: reachability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 105 | cachedSections: &cachedSections, | ||
| 106 | sectionSources: §ionSources, | ||
| 107 | provenanceBySection: &provenanceBySection, | ||
| 108 | dataSources: &dataSources | ||
| 109 | ) | ||
| 110 | captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails) | ||
| 56 | 111 | ||
| 57 | let resolvedOwnership = await ownershipFetch | 112 | let resolvedOwnership = await ownershipFetch |
| 58 | let ownershipResult = normalizeErrors(in: resolvedOwnership.value) | 113 | let ownershipResult = normalizeErrors(in: resolvedOwnership.value) |
| 59 | track(.ownership, source: resolvedOwnership.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 114 | track( |
| 115 | .ownership, | ||
| 116 | source: resolvedOwnership.source, | ||
| 117 | provenance: provenance(for: .ownership, source: resolvedOwnership.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 118 | cachedSections: &cachedSections, | ||
| 119 | sectionSources: §ionSources, | ||
| 120 | provenanceBySection: &provenanceBySection, | ||
| 121 | dataSources: &dataSources | ||
| 122 | ) | ||
| 123 | captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails) | ||
| 60 | 124 | ||
| 61 | let redirects = await redirectFetch | 125 | let redirects = await redirectFetch |
| 62 | let redirectResult = normalizeErrors(in: redirects.value) | 126 | let redirectResult = normalizeErrors(in: redirects.value) |
| 63 | track(.redirectChain, source: redirects.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 127 | track( |
| 128 | .redirectChain, | ||
| 129 | source: redirects.source, | ||
| 130 | provenance: provenance(for: .redirectChain, source: redirects.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 131 | cachedSections: &cachedSections, | ||
| 132 | sectionSources: §ionSources, | ||
| 133 | provenanceBySection: &provenanceBySection, | ||
| 134 | dataSources: &dataSources | ||
| 135 | ) | ||
| 136 | captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails) | ||
| 64 | 137 | ||
| 65 | let resolvedSubdomains = await subdomainFetch | 138 | let resolvedSubdomains = await subdomainFetch |
| 66 | let subdomainResult = normalizeErrors(in: resolvedSubdomains.value) | 139 | let subdomainResult = normalizeErrors(in: resolvedSubdomains.value) |
| 67 | track(.subdomains, source: resolvedSubdomains.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 140 | track( |
| 141 | .subdomains, | ||
| 142 | source: resolvedSubdomains.source, | ||
| 143 | provenance: provenance(for: .subdomains, source: resolvedSubdomains.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 144 | cachedSections: &cachedSections, | ||
| 145 | sectionSources: §ionSources, | ||
| 146 | provenanceBySection: &provenanceBySection, | ||
| 147 | dataSources: &dataSources | ||
| 148 | ) | ||
| 149 | captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails) | ||
| 68 | 150 | ||
| 69 | let ports = await portScanFetch | 151 | let ports = await portScanFetch |
| 70 | let portScanResult = normalizeErrors(in: ports.value) | 152 | let portScanResult = normalizeErrors(in: ports.value) |
| 71 | track(.portScan, source: ports.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 153 | track( |
| 154 | .portScan, | ||
| 155 | source: ports.source, | ||
| 156 | provenance: provenance(for: .portScan, source: ports.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 157 | cachedSections: &cachedSections, | ||
| 158 | sectionSources: §ionSources, | ||
| 159 | provenanceBySection: &provenanceBySection, | ||
| 160 | dataSources: &dataSources | ||
| 161 | ) | ||
| 162 | captureFailure(for: .portScan, result: portScanResult, into: &errorDetails) | ||
| 72 | 163 | ||
| 73 | let dnsSections = mapServiceResult(dnsResult, emptyValue: []) | 164 | let dnsSections = mapServiceResult(dnsResult, emptyValue: []) |
| 74 | let sslInfo = mapOptionalValueServiceResult(sslResult) | 165 | let sslInfo = mapOptionalValueServiceResult(sslResult) |
| @@ -92,12 +183,30 @@ struct DomainInspectionService { | |||
| 92 | } else { | 183 | } else { |
| 93 | emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords) | 184 | emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords) |
| 94 | } | 185 | } |
| 95 | track(.emailSecurity, source: emailOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 186 | let normalizedEmailResult = normalizeErrors(in: emailOutcome.value) |
| 187 | track( | ||
| 188 | .emailSecurity, | ||
| 189 | source: emailOutcome.source, | ||
| 190 | provenance: provenance(for: .emailSecurity, source: emailOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 191 | cachedSections: &cachedSections, | ||
| 192 | sectionSources: §ionSources, | ||
| 193 | provenanceBySection: &provenanceBySection, | ||
| 194 | dataSources: &dataSources | ||
| 195 | ) | ||
| 196 | captureFailure(for: .emailSecurity, result: normalizedEmailResult, into: &errorDetails) | ||
| 96 | 197 | ||
| 97 | let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]> | 198 | let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]> |
| 98 | if availability.value.status == .registered { | 199 | if availability.value.status == .registered { |
| 99 | suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain) | 200 | suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain) |
| 100 | track(.suggestions, source: suggestionsOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 201 | track( |
| 202 | .suggestions, | ||
| 203 | source: suggestionsOutcome.source, | ||
| 204 | provenance: provenance(for: .suggestions, source: suggestionsOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 205 | cachedSections: &cachedSections, | ||
| 206 | sectionSources: §ionSources, | ||
| 207 | provenanceBySection: &provenanceBySection, | ||
| 208 | dataSources: &dataSources | ||
| 209 | ) | ||
| 101 | } else { | 210 | } else { |
| 102 | suggestionsOutcome = CachedLookupResult(value: [], source: .live) | 211 | suggestionsOutcome = CachedLookupResult(value: [], source: .live) |
| 103 | } | 212 | } |
| @@ -122,27 +231,65 @@ struct DomainInspectionService { | |||
| 122 | } | 231 | } |
| 123 | 232 | ||
| 124 | if let ptrOutcome { | 233 | if let ptrOutcome { |
| 125 | track(.ptr, source: ptrOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 234 | let normalizedPTRResult = normalizeErrors(in: ptrOutcome.value) |
| 235 | track( | ||
| 236 | .ptr, | ||
| 237 | source: ptrOutcome.source, | ||
| 238 | provenance: provenance(for: .ptr, source: ptrOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 239 | cachedSections: &cachedSections, | ||
| 240 | sectionSources: §ionSources, | ||
| 241 | provenanceBySection: &provenanceBySection, | ||
| 242 | dataSources: &dataSources | ||
| 243 | ) | ||
| 244 | captureFailure(for: .ptr, result: normalizedPTRResult, into: &errorDetails) | ||
| 126 | } | 245 | } |
| 127 | if let geoOutcome { | 246 | if let geoOutcome { |
| 128 | track(.ipGeolocation, source: geoOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) | 247 | let normalizedGeoResult = normalizeErrors(in: geoOutcome.value) |
| 248 | track( | ||
| 249 | .ipGeolocation, | ||
| 250 | source: geoOutcome.source, | ||
| 251 | provenance: provenance(for: .ipGeolocation, source: geoOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName), | ||
| 252 | cachedSections: &cachedSections, | ||
| 253 | sectionSources: §ionSources, | ||
| 254 | provenanceBySection: &provenanceBySection, | ||
| 255 | dataSources: &dataSources | ||
| 256 | ) | ||
| 257 | captureFailure(for: .ipGeolocation, result: normalizedGeoResult, into: &errorDetails) | ||
| 129 | } | 258 | } |
| 130 | } else { | 259 | } else { |
| 131 | ptrOutcome = nil | 260 | ptrOutcome = nil |
| 132 | geoOutcome = nil | 261 | geoOutcome = nil |
| 133 | } | 262 | } |
| 134 | 263 | ||
| 135 | let emailSecurity = mapOptionalValueServiceResult(normalizeErrors(in: emailOutcome.value)) | 264 | let emailSecurity = mapOptionalValueServiceResult(normalizedEmailResult) |
| 136 | let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") | 265 | let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") |
| 137 | let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") | 266 | let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") |
| 267 | let availabilityConfidence = confidenceForAvailability(result: availability.value, provenance: provenanceBySection[.availability]) | ||
| 268 | let ownershipConfidence = confidenceForOwnership(result: ownership.value, error: ownership.message) | ||
| 269 | let subdomainConfidence = confidenceForSubdomains(results: subdomains.value, error: subdomains.message) | ||
| 270 | let emailConfidence = confidenceForEmail(result: emailSecurity.value, error: emailSecurity.message) | ||
| 271 | let geolocationConfidence = confidenceForGeolocation(result: geolocation.value, error: geolocation.message) | ||
| 272 | let validationIssues = validationIssues(for: normalizedDomain, snapshotTimestamp: startedAt, availability: availability.value, dnsSections: dnsSections.value, provenanceBySection: provenanceBySection) | ||
| 138 | 273 | ||
| 139 | return LookupSnapshot( | 274 | return LookupSnapshot( |
| 140 | historyEntryID: nil, | 275 | historyEntryID: nil, |
| 141 | domain: availability.value.domain, | 276 | domain: availability.value.domain, |
| 142 | timestamp: Date(), | 277 | timestamp: Date(), |
| 143 | trackedDomainID: previousSnapshot?.trackedDomainID, | 278 | trackedDomainID: previousSnapshot?.trackedDomainID, |
| 279 | note: previousSnapshot?.note, | ||
| 280 | appVersion: AppVersion.current, | ||
| 144 | resolverDisplayName: resolverDisplayName, | 281 | resolverDisplayName: resolverDisplayName, |
| 145 | resolverURLString: resolverURLString, | 282 | resolverURLString: resolverURLString, |
| 283 | dataSources: Array(dataSources).sorted(), | ||
| 284 | provenanceBySection: provenanceBySection, | ||
| 285 | availabilityConfidence: availabilityConfidence, | ||
| 286 | ownershipConfidence: ownershipConfidence, | ||
| 287 | subdomainConfidence: subdomainConfidence, | ||
| 288 | emailSecurityConfidence: emailConfidence, | ||
| 289 | geolocationConfidence: geolocationConfidence, | ||
| 290 | errorDetails: errorDetails, | ||
| 291 | isPartialSnapshot: !validationIssues.isEmpty, | ||
| 292 | validationIssues: validationIssues, | ||
| 146 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), | 293 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), |
| 147 | dnsSections: dnsSections.value, | 294 | dnsSections: dnsSections.value, |
| 148 | dnsError: dnsSections.message, | 295 | dnsError: dnsSections.message, |
| @@ -193,15 +340,64 @@ struct DomainInspectionService { | |||
| 193 | private func track( | 340 | private func track( |
| 194 | _ section: LookupSectionKind, | 341 | _ section: LookupSectionKind, |
| 195 | source: LookupResultSource, | 342 | source: LookupResultSource, |
| 343 | provenance: SectionProvenance, | ||
| 196 | cachedSections: inout Set<LookupSectionKind>, | 344 | cachedSections: inout Set<LookupSectionKind>, |
| 197 | sectionSources: inout [LookupResultSource] | 345 | sectionSources: inout [LookupResultSource], |
| 346 | provenanceBySection: inout [LookupSectionKind: SectionProvenance], | ||
| 347 | dataSources: inout Set<String> | ||
| 198 | ) { | 348 | ) { |
| 199 | sectionSources.append(source) | 349 | sectionSources.append(source) |
| 350 | provenanceBySection[section] = provenance | ||
| 351 | dataSources.insert(provenance.provider ?? provenance.source) | ||
| 200 | if source != .live { | 352 | if source != .live { |
| 201 | cachedSections.insert(section) | 353 | cachedSections.insert(section) |
| 202 | } | 354 | } |
| 203 | } | 355 | } |
| 204 | 356 | ||
| 357 | private func provenance( | ||
| 358 | for section: LookupSectionKind, | ||
| 359 | source: LookupResultSource, | ||
| 360 | collectedAt: Date, | ||
| 361 | resolverDisplayName: String | ||
| 362 | ) -> SectionProvenance { | ||
| 363 | switch section { | ||
| 364 | case .dns, .ptr: | ||
| 365 | return SectionProvenance( | ||
| 366 | source: "DNS-over-HTTPS query", | ||
| 367 | collectedAt: collectedAt, | ||
| 368 | provider: "Selected DoH resolver", | ||
| 369 | resolver: resolverDisplayName, | ||
| 370 | resultSource: source | ||
| 371 | ) | ||
| 372 | case .availability: | ||
| 373 | return SectionProvenance( | ||
| 374 | source: "RDAP lookup with DNS fallback", | ||
| 375 | collectedAt: collectedAt, | ||
| 376 | provider: "rdap.org / selected resolver", | ||
| 377 | resolver: resolverDisplayName, | ||
| 378 | resultSource: source | ||
| 379 | ) | ||
| 380 | case .ssl: | ||
| 381 | return SectionProvenance(source: "Direct TLS handshake", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source) | ||
| 382 | case .hsts, .httpHeaders, .redirectChain: | ||
| 383 | return SectionProvenance(source: "HTTP request", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source) | ||
| 384 | case .reachability: | ||
| 385 | return SectionProvenance(source: "TCP reachability probe", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source) | ||
| 386 | case .ipGeolocation: | ||
| 387 | return SectionProvenance(source: "IP geolocation lookup", collectedAt: collectedAt, provider: "ipapi.co", resolver: nil, resultSource: source) | ||
| 388 | case .emailSecurity: | ||
| 389 | return SectionProvenance(source: "DNS TXT inspection", collectedAt: collectedAt, provider: "Selected DoH resolver", resolver: resolverDisplayName, resultSource: source) | ||
| 390 | case .ownership: | ||
| 391 | return SectionProvenance(source: "RDAP domain lookup", collectedAt: collectedAt, provider: "rdap.org", resolver: nil, resultSource: source) | ||
| 392 | case .subdomains: | ||
| 393 | return SectionProvenance(source: "Certificate transparency search", collectedAt: collectedAt, provider: "crt.sh", resolver: nil, resultSource: source) | ||
| 394 | case .portScan: | ||
| 395 | return SectionProvenance(source: "TCP port scan", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source) | ||
| 396 | case .suggestions: | ||
| 397 | return SectionProvenance(source: "Availability suggestions", collectedAt: collectedAt, provider: "DomainDig heuristic", resolver: resolverDisplayName, resultSource: source) | ||
| 398 | } | ||
| 399 | } | ||
| 400 | |||
| 205 | private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource { | 401 | private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource { |
| 206 | let normalizedSources = sectionSources.map { source -> LookupResultSource in | 402 | let normalizedSources = sectionSources.map { source -> LookupResultSource in |
| 207 | source == .mixed ? .cached : source | 403 | source == .mixed ? .cached : source |
| @@ -259,44 +455,131 @@ struct DomainInspectionService { | |||
| 259 | case let .success(value): | 455 | case let .success(value): |
| 260 | return .success(value) | 456 | return .success(value) |
| 261 | case let .empty(message): | 457 | case let .empty(message): |
| 262 | return .empty(message) | 458 | return .empty(classifyFailure(from: message, defaultKind: .unavailable).message) |
| 263 | case let .error(message): | 459 | case let .error(message): |
| 264 | return .error(classifiedMessage(from: message)) | 460 | return .error(classifyFailure(from: message).message) |
| 265 | } | 461 | } |
| 266 | } | 462 | } |
| 267 | 463 | ||
| 268 | private func classifiedMessage(from message: String) -> String { | 464 | private func classifyFailure(from message: String, defaultKind: InspectionErrorKind = .unknown) -> InspectionFailure { |
| 269 | let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) | 465 | let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) |
| 270 | let lowercasedMessage = normalizedMessage.lowercased() | 466 | let lowercasedMessage = normalizedMessage.lowercased() |
| 271 | |||
| 272 | if lowercasedMessage.hasPrefix("network error:") | ||
| 273 | || lowercasedMessage.hasPrefix("timeout:") | ||
| 274 | || lowercasedMessage.hasPrefix("rate limit:") | ||
| 275 | || lowercasedMessage.hasPrefix("parsing error:") { | ||
| 276 | return normalizedMessage | ||
| 277 | } | ||
| 278 | |||
| 279 | if lowercasedMessage.contains("timed out") { | 467 | if lowercasedMessage.contains("timed out") { |
| 280 | return "Timeout: Request timed out" | 468 | return InspectionFailure(kind: .timeout, message: "Timed out", details: normalizedMessage) |
| 281 | } | 469 | } |
| 282 | if lowercasedMessage.contains("429") | 470 | if lowercasedMessage.contains("429") |
| 283 | || lowercasedMessage.contains("too many requests") | 471 | || lowercasedMessage.contains("too many requests") |
| 284 | || lowercasedMessage.contains("rate limit") { | 472 | || lowercasedMessage.contains("rate limit") { |
| 285 | return "Rate limit: Try again shortly" | 473 | return InspectionFailure(kind: .rateLimited, message: "Rate limited", details: normalizedMessage) |
| 286 | } | 474 | } |
| 287 | if lowercasedMessage.contains("cannot parse") | 475 | if lowercasedMessage.contains("cannot parse") |
| 288 | || lowercasedMessage.contains("decoding") | 476 | || lowercasedMessage.contains("decoding") |
| 289 | || lowercasedMessage.contains("json") { | 477 | || lowercasedMessage.contains("json") { |
| 290 | return "Parsing error: Invalid server response" | 478 | return InspectionFailure(kind: .parsing, message: "Could not parse response", details: normalizedMessage) |
| 291 | } | 479 | } |
| 292 | if lowercasedMessage.contains("offline") | 480 | if lowercasedMessage.contains("offline") |
| 293 | || lowercasedMessage.contains("internet connection") | 481 | || lowercasedMessage.contains("internet connection") |
| 294 | || lowercasedMessage.contains("not connected") | 482 | || lowercasedMessage.contains("not connected") |
| 295 | || lowercasedMessage.contains("network connection") { | 483 | || lowercasedMessage.contains("network connection") { |
| 296 | return "Network error: Offline" | 484 | return InspectionFailure(kind: .network, message: "Network unavailable", details: normalizedMessage) |
| 297 | } | 485 | } |
| 486 | if lowercasedMessage.contains("unsupported") { | ||
| 487 | return InspectionFailure(kind: .unsupported, message: "Unsupported for this target", details: normalizedMessage) | ||
| 488 | } | ||
| 489 | if lowercasedMessage == "unavailable" || lowercasedMessage.contains("no a record available") { | ||
| 490 | return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil) | ||
| 491 | } | ||
| 492 | if defaultKind == .unavailable { | ||
| 493 | return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil) | ||
| 494 | } | ||
| 495 | return InspectionFailure(kind: .unknown, message: normalizedMessage.isEmpty ? defaultKind.title : normalizedMessage, details: normalizedMessage) | ||
| 496 | } | ||
| 298 | 497 | ||
| 299 | return "Network error: \(normalizedMessage)" | 498 | private func captureFailure<Value>( |
| 499 | for section: LookupSectionKind, | ||
| 500 | result: ServiceResult<Value>, | ||
| 501 | into errorDetails: inout [LookupSectionKind: InspectionFailure] | ||
| 502 | ) { | ||
| 503 | switch result { | ||
| 504 | case .success: | ||
| 505 | return | ||
| 506 | case let .empty(message): | ||
| 507 | errorDetails[section] = classifyFailure(from: message, defaultKind: .unavailable) | ||
| 508 | case let .error(message): | ||
| 509 | errorDetails[section] = classifyFailure(from: message) | ||
| 510 | } | ||
| 511 | } | ||
| 512 | |||
| 513 | private func confidenceForAvailability(result: DomainAvailabilityResult, provenance: SectionProvenance?) -> ConfidenceLevel { | ||
| 514 | guard result.status != .unknown else { return .low } | ||
| 515 | if provenance?.provider?.localizedCaseInsensitiveContains("rdap.org") == true, result.status == .registered { | ||
| 516 | return .high | ||
| 517 | } | ||
| 518 | if result.status == .registered { | ||
| 519 | return .medium | ||
| 520 | } | ||
| 521 | return .low | ||
| 522 | } | ||
| 523 | |||
| 524 | private func confidenceForOwnership(result: DomainOwnership?, error: String?) -> ConfidenceLevel { | ||
| 525 | guard let result else { return error == nil ? .low : .low } | ||
| 526 | let hasDirectRegistrationData = result.registrar != nil || result.createdDate != nil || result.expirationDate != nil | ||
| 527 | return hasDirectRegistrationData ? .high : .medium | ||
| 528 | } | ||
| 529 | |||
| 530 | private func confidenceForSubdomains(results: [DiscoveredSubdomain], error: String?) -> ConfidenceLevel { | ||
| 531 | if !results.isEmpty { | ||
| 532 | return .medium | ||
| 533 | } | ||
| 534 | return error == nil ? .low : .low | ||
| 535 | } | ||
| 536 | |||
| 537 | private func confidenceForEmail(result: EmailSecurityResult?, error: String?) -> ConfidenceLevel { | ||
| 538 | guard let result else { return error == nil ? .low : .low } | ||
| 539 | let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found, result.bimi.found, result.mtaSts?.txtFound == true] | ||
| 540 | .filter { $0 } | ||
| 541 | .count | ||
| 542 | if foundCount >= 3 { | ||
| 543 | return .high | ||
| 544 | } | ||
| 545 | if foundCount >= 1 { | ||
| 546 | return .medium | ||
| 547 | } | ||
| 548 | return .low | ||
| 549 | } | ||
| 550 | |||
| 551 | private func confidenceForGeolocation(result: IPGeolocation?, error: String?) -> ConfidenceLevel { | ||
| 552 | guard let result else { return error == nil ? .low : .low } | ||
| 553 | if result.city != nil && result.country_name != nil && result.latitude != nil && result.longitude != nil { | ||
| 554 | return .high | ||
| 555 | } | ||
| 556 | if result.country_name != nil || result.org != nil { | ||
| 557 | return .medium | ||
| 558 | } | ||
| 559 | return .low | ||
| 560 | } | ||
| 561 | |||
| 562 | private func validationIssues( | ||
| 563 | for domain: String, | ||
| 564 | snapshotTimestamp: Date, | ||
| 565 | availability: DomainAvailabilityResult, | ||
| 566 | dnsSections: [DNSSection], | ||
| 567 | provenanceBySection: [LookupSectionKind: SectionProvenance] | ||
| 568 | ) -> [String] { | ||
| 569 | var issues: [String] = [] | ||
| 570 | if domain.isEmpty { | ||
| 571 | issues.append("Missing normalized domain") | ||
| 572 | } | ||
| 573 | if availability.domain.isEmpty { | ||
| 574 | issues.append("Missing normalized availability domain") | ||
| 575 | } | ||
| 576 | if dnsSections.isEmpty && provenanceBySection[.dns] == nil { | ||
| 577 | issues.append("Missing DNS provenance") | ||
| 578 | } | ||
| 579 | if snapshotTimestamp > Date().addingTimeInterval(5) { | ||
| 580 | issues.append("Snapshot timestamp is in the future") | ||
| 581 | } | ||
| 582 | return issues | ||
| 300 | } | 583 | } |
| 301 | 584 | ||
| 302 | private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { | 585 | private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { |
DomainReportBuilder.swift +28
| @@ -3,8 +3,22 @@ import Foundation | |||
| 3 | struct DomainReport: Codable { | 3 | struct DomainReport: Codable { |
| 4 | let domain: String | 4 | let domain: String |
| 5 | let timestamp: Date | 5 | let timestamp: Date |
| 6 | let appVersion: String | ||
| 7 | let resolverDisplayName: String | ||
| 8 | let resolverURLString: String | ||
| 9 | let dataSources: [String] | ||
| 6 | let resultSource: LookupResultSource | 10 | let resultSource: LookupResultSource |
| 11 | let sectionProvenance: [LookupSectionKind: SectionProvenance] | ||
| 12 | let errorDetails: [LookupSectionKind: InspectionFailure] | ||
| 13 | let isPartialSnapshot: Bool | ||
| 14 | let validationIssues: [String] | ||
| 15 | let auditNote: String? | ||
| 7 | let availability: DomainAvailabilityStatus | 16 | let availability: DomainAvailabilityStatus |
| 17 | let availabilityConfidence: ConfidenceLevel? | ||
| 18 | let ownershipConfidence: ConfidenceLevel? | ||
| 19 | let subdomainConfidence: ConfidenceLevel? | ||
| 20 | let emailConfidence: ConfidenceLevel? | ||
| 21 | let geolocationConfidence: ConfidenceLevel? | ||
| 8 | let ownership: DomainOwnership? | 22 | let ownership: DomainOwnership? |
| 9 | let dns: DNSResultSummary | 23 | let dns: DNSResultSummary |
| 10 | let web: WebResultSummary | 24 | let web: WebResultSummary |
| @@ -71,8 +85,22 @@ struct DomainReportBuilder { | |||
| 71 | return DomainReport( | 85 | return DomainReport( |
| 72 | domain: snapshot.domain, | 86 | domain: snapshot.domain, |
| 73 | timestamp: snapshot.timestamp, | 87 | timestamp: snapshot.timestamp, |
| 88 | appVersion: snapshot.appVersion, | ||
| 89 | resolverDisplayName: snapshot.resolverDisplayName, | ||
| 90 | resolverURLString: snapshot.resolverURLString, | ||
| 91 | dataSources: snapshot.dataSources, | ||
| 74 | resultSource: snapshot.resultSource, | 92 | resultSource: snapshot.resultSource, |
| 93 | sectionProvenance: snapshot.provenanceBySection, | ||
| 94 | errorDetails: snapshot.errorDetails, | ||
| 95 | isPartialSnapshot: snapshot.isPartialSnapshot, | ||
| 96 | validationIssues: snapshot.validationIssues, | ||
| 97 | auditNote: snapshot.note, | ||
| 75 | availability: snapshot.availabilityResult?.status ?? .unknown, | 98 | availability: snapshot.availabilityResult?.status ?? .unknown, |
| 99 | availabilityConfidence: snapshot.availabilityConfidence, | ||
| 100 | ownershipConfidence: snapshot.ownershipConfidence, | ||
| 101 | subdomainConfidence: snapshot.subdomainConfidence, | ||
| 102 | emailConfidence: snapshot.emailSecurityConfidence, | ||
| 103 | geolocationConfidence: snapshot.geolocationConfidence, | ||
| 76 | ownership: snapshot.ownership, | 104 | ownership: snapshot.ownership, |
| 77 | dns: DNSResultSummary( | 105 | dns: DNSResultSummary( |
| 78 | resolverDisplayName: snapshot.resolverDisplayName, | 106 | resolverDisplayName: snapshot.resolverDisplayName, |
DomainReportExporter.swift +91 −7
| @@ -36,10 +36,27 @@ enum DomainReportExporter { | |||
| 36 | "DomainDig Report", | 36 | "DomainDig Report", |
| 37 | "Domain: \(report.domain)", | 37 | "Domain: \(report.domain)", |
| 38 | "Timestamp: \(textDateFormatter.string(from: report.timestamp))", | 38 | "Timestamp: \(textDateFormatter.string(from: report.timestamp))", |
| 39 | "App Version: \(report.appVersion)", | ||
| 40 | "Resolver: \(report.resolverDisplayName)", | ||
| 41 | "Resolver URL: \(report.resolverURLString)", | ||
| 39 | "Source: \(report.resultSource.label)", | 42 | "Source: \(report.resultSource.label)", |
| 40 | "Availability: \(availabilityLabel(report.availability))" | 43 | "Availability: \(availabilityLabel(report.availability))", |
| 44 | "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")" | ||
| 41 | ] | 45 | ] |
| 42 | 46 | ||
| 47 | if report.isPartialSnapshot { | ||
| 48 | lines.append("Snapshot Integrity: Partial snapshot") | ||
| 49 | } | ||
| 50 | if let auditNote = report.auditNote, !auditNote.isEmpty { | ||
| 51 | lines.append("Audit Note: \(auditNote)") | ||
| 52 | } | ||
| 53 | if !report.dataSources.isEmpty { | ||
| 54 | lines.append("Data Sources: \(report.dataSources.joined(separator: ", "))") | ||
| 55 | } | ||
| 56 | if !report.validationIssues.isEmpty { | ||
| 57 | lines.append("Validation: \(report.validationIssues.joined(separator: " | "))") | ||
| 58 | } | ||
| 59 | |||
| 43 | appendSection("Summary", to: &lines) { | 60 | appendSection("Summary", to: &lines) { |
| 44 | [ | 61 | [ |
| 45 | "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", | 62 | "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", |
| @@ -53,12 +70,16 @@ enum DomainReportExporter { | |||
| 53 | appendSection("Ownership", to: &lines) { | 70 | appendSection("Ownership", to: &lines) { |
| 54 | var ownershipLines = [ | 71 | var ownershipLines = [ |
| 55 | "Registrar: \(report.ownership?.registrar ?? "Unavailable")", | 72 | "Registrar: \(report.ownership?.registrar ?? "Unavailable")", |
| 73 | "Confidence: \(report.ownershipConfidence?.title ?? "N/A")", | ||
| 56 | "Created: \(ownershipDateLabel(report.ownership?.createdDate))", | 74 | "Created: \(ownershipDateLabel(report.ownership?.createdDate))", |
| 57 | "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", | 75 | "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", |
| 58 | "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", | 76 | "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", |
| 59 | "Status: \(joined(report.ownership?.status) ?? "Unavailable")", | 77 | "Status: \(joined(report.ownership?.status) ?? "Unavailable")", |
| 60 | "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" | 78 | "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" |
| 61 | ] | 79 | ] |
| 80 | if let provenance = report.sectionProvenance[.ownership] { | ||
| 81 | ownershipLines.append("Provenance: \(provenanceLabel(provenance))") | ||
| 82 | } | ||
| 62 | if let error = report.dns.error, report.ownership == nil { | 83 | if let error = report.dns.error, report.ownership == nil { |
| 63 | ownershipLines.append("Error: \(error)") | 84 | ownershipLines.append("Error: \(error)") |
| 64 | } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil { | 85 | } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil { |
| @@ -69,13 +90,14 @@ enum DomainReportExporter { | |||
| 69 | 90 | ||
| 70 | appendSection("DNS", to: &lines) { | 91 | appendSection("DNS", to: &lines) { |
| 71 | var dnsLines = [ | 92 | var dnsLines = [ |
| 72 | "Resolver: \(report.dns.resolverDisplayName)", | ||
| 73 | "Resolver URL: \(report.dns.resolverURLString)", | ||
| 74 | "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", | 93 | "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", |
| 75 | "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", | 94 | "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", |
| 76 | "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")", | 95 | "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")", |
| 77 | "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))" | 96 | "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))" |
| 78 | ] | 97 | ] |
| 98 | if let provenance = report.sectionProvenance[.dns] { | ||
| 99 | dnsLines.append("Provenance: \(provenanceLabel(provenance))") | ||
| 100 | } | ||
| 79 | if let error = report.dns.error { | 101 | if let error = report.dns.error { |
| 80 | dnsLines.append("Error: \(error)") | 102 | dnsLines.append("Error: \(error)") |
| 81 | } | 103 | } |
| @@ -104,6 +126,15 @@ enum DomainReportExporter { | |||
| 104 | "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))", | 126 | "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))", |
| 105 | "Header Count: \(report.web.headerCount)" | 127 | "Header Count: \(report.web.headerCount)" |
| 106 | ] | 128 | ] |
| 129 | if let provenance = report.sectionProvenance[.ssl] { | ||
| 130 | webLines.append("TLS Provenance: \(provenanceLabel(provenance))") | ||
| 131 | } | ||
| 132 | if let provenance = report.sectionProvenance[.httpHeaders] { | ||
| 133 | webLines.append("HTTP Provenance: \(provenanceLabel(provenance))") | ||
| 134 | } | ||
| 135 | if let provenance = report.sectionProvenance[.redirectChain] { | ||
| 136 | webLines.append("Redirect Provenance: \(provenanceLabel(provenance))") | ||
| 137 | } | ||
| 107 | if let tlsError = report.web.tlsError { | 138 | if let tlsError = report.web.tlsError { |
| 108 | webLines.append("TLS Error: \(tlsError)") | 139 | webLines.append("TLS Error: \(tlsError)") |
| 109 | } | 140 | } |
| @@ -130,6 +161,10 @@ enum DomainReportExporter { | |||
| 130 | 161 | ||
| 131 | appendSection("Email", to: &lines) { | 162 | appendSection("Email", to: &lines) { |
| 132 | var emailLines = [report.email.summary] | 163 | var emailLines = [report.email.summary] |
| 164 | emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")") | ||
| 165 | if let provenance = report.sectionProvenance[.emailSecurity] { | ||
| 166 | emailLines.append("Provenance: \(provenanceLabel(provenance))") | ||
| 167 | } | ||
| 133 | if let records = report.email.records { | 168 | if let records = report.email.records { |
| 134 | emailLines.append("SPF: \(recordLabel(records.spf))") | 169 | emailLines.append("SPF: \(recordLabel(records.spf))") |
| 135 | emailLines.append("DMARC: \(recordLabel(records.dmarc))") | 170 | emailLines.append("DMARC: \(recordLabel(records.dmarc))") |
| @@ -147,8 +182,12 @@ enum DomainReportExporter { | |||
| 147 | var networkLines = [ | 182 | var networkLines = [ |
| 148 | "Reachability: \(report.network.reachabilitySummary)", | 183 | "Reachability: \(report.network.reachabilitySummary)", |
| 149 | "Geolocation: \(report.network.geolocationSummary)", | 184 | "Geolocation: \(report.network.geolocationSummary)", |
| 185 | "Geolocation Confidence: \(report.geolocationConfidence?.title ?? "N/A")", | ||
| 150 | "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")" | 186 | "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")" |
| 151 | ] | 187 | ] |
| 188 | if let provenance = report.sectionProvenance[.ipGeolocation] { | ||
| 189 | networkLines.append("Geolocation Provenance: \(provenanceLabel(provenance))") | ||
| 190 | } | ||
| 152 | if let error = report.network.reachabilityError { | 191 | if let error = report.network.reachabilityError { |
| 153 | networkLines.append("Reachability Error: \(error)") | 192 | networkLines.append("Reachability Error: \(error)") |
| 154 | } | 193 | } |
| @@ -170,10 +209,16 @@ enum DomainReportExporter { | |||
| 170 | } | 209 | } |
| 171 | 210 | ||
| 172 | appendSection("Subdomains", to: &lines) { | 211 | appendSection("Subdomains", to: &lines) { |
| 212 | var values = ["Confidence: \(report.subdomainConfidence?.title ?? "N/A")"] | ||
| 213 | if let provenance = report.sectionProvenance[.subdomains] { | ||
| 214 | values.append("Provenance: \(provenanceLabel(provenance))") | ||
| 215 | } | ||
| 173 | if report.subdomains.isEmpty { | 216 | if report.subdomains.isEmpty { |
| 174 | return ["None"] | 217 | values.append("None") |
| 218 | return values | ||
| 175 | } | 219 | } |
| 176 | return report.subdomains.map { "- \($0)" } | 220 | values.append(contentsOf: report.subdomains.map { "- \($0)" }) |
| 221 | return values | ||
| 177 | } | 222 | } |
| 178 | 223 | ||
| 179 | appendSection("Changes", to: &lines) { | 224 | appendSection("Changes", to: &lines) { |
| @@ -181,12 +226,19 @@ enum DomainReportExporter { | |||
| 181 | return ["No comparison available"] | 226 | return ["No comparison available"] |
| 182 | } | 227 | } |
| 183 | 228 | ||
| 184 | return [ | 229 | var values = [ |
| 185 | "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")", | 230 | "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")", |
| 186 | "Severity: \(changeSummary.severity.title)", | 231 | "Severity: \(changeSummary.severity.title)", |
| 187 | "Summary: \(changeSummary.message)", | 232 | "Inferred Summary: \(changeSummary.message)", |
| 188 | "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))" | 233 | "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))" |
| 189 | ] | 234 | ] |
| 235 | if !changeSummary.observedFacts.isEmpty { | ||
| 236 | values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))") | ||
| 237 | } | ||
| 238 | if let contextNote = changeSummary.contextNote { | ||
| 239 | values.append("Context: \(contextNote)") | ||
| 240 | } | ||
| 241 | return values | ||
| 190 | } | 242 | } |
| 191 | 243 | ||
| 192 | return lines.joined(separator: "\n") | 244 | return lines.joined(separator: "\n") |
| @@ -213,9 +265,13 @@ enum DomainReportExporter { | |||
| 213 | let headers = [ | 265 | let headers = [ |
| 214 | "domain", | 266 | "domain", |
| 215 | "timestamp", | 267 | "timestamp", |
| 268 | "app_version", | ||
| 216 | "result_source", | 269 | "result_source", |
| 270 | "resolver", | ||
| 217 | "availability", | 271 | "availability", |
| 272 | "availability_confidence", | ||
| 218 | "registrar", | 273 | "registrar", |
| 274 | "ownership_confidence", | ||
| 219 | "ownership_expires", | 275 | "ownership_expires", |
| 220 | "nameservers", | 276 | "nameservers", |
| 221 | "primary_ip", | 277 | "primary_ip", |
| @@ -228,11 +284,17 @@ enum DomainReportExporter { | |||
| 228 | "http_security_grade", | 284 | "http_security_grade", |
| 229 | "final_url", | 285 | "final_url", |
| 230 | "email_summary", | 286 | "email_summary", |
| 287 | "email_confidence", | ||
| 231 | "subdomain_count", | 288 | "subdomain_count", |
| 289 | "subdomain_confidence", | ||
| 232 | "subdomains", | 290 | "subdomains", |
| 233 | "open_ports", | 291 | "open_ports", |
| 234 | "reachability_summary", | 292 | "reachability_summary", |
| 235 | "geolocation_summary", | 293 | "geolocation_summary", |
| 294 | "geolocation_confidence", | ||
| 295 | "data_sources", | ||
| 296 | "audit_note", | ||
| 297 | "partial_snapshot", | ||
| 236 | "change_summary" | 298 | "change_summary" |
| 237 | ] | 299 | ] |
| 238 | 300 | ||
| @@ -249,9 +311,13 @@ enum DomainReportExporter { | |||
| 249 | return [ | 311 | return [ |
| 250 | report.domain, | 312 | report.domain, |
| 251 | csvDateFormatter.string(from: report.timestamp), | 313 | csvDateFormatter.string(from: report.timestamp), |
| 314 | report.appVersion, | ||
| 252 | report.resultSource.rawValue, | 315 | report.resultSource.rawValue, |
| 316 | report.resolverDisplayName, | ||
| 253 | availabilityLabel(report.availability), | 317 | availabilityLabel(report.availability), |
| 318 | report.availabilityConfidence?.rawValue ?? "", | ||
| 254 | report.ownership?.registrar ?? "", | 319 | report.ownership?.registrar ?? "", |
| 320 | report.ownershipConfidence?.rawValue ?? "", | ||
| 255 | expirationDate, | 321 | expirationDate, |
| 256 | nameservers, | 322 | nameservers, |
| 257 | report.dns.primaryIP ?? "", | 323 | report.dns.primaryIP ?? "", |
| @@ -264,11 +330,17 @@ enum DomainReportExporter { | |||
| 264 | report.web.securityGrade ?? "", | 330 | report.web.securityGrade ?? "", |
| 265 | report.web.finalURL ?? "", | 331 | report.web.finalURL ?? "", |
| 266 | report.email.summary, | 332 | report.email.summary, |
| 333 | report.emailConfidence?.rawValue ?? "", | ||
| 267 | subdomainCount, | 334 | subdomainCount, |
| 335 | report.subdomainConfidence?.rawValue ?? "", | ||
| 268 | subdomains, | 336 | subdomains, |
| 269 | openPorts, | 337 | openPorts, |
| 270 | report.network.reachabilitySummary, | 338 | report.network.reachabilitySummary, |
| 271 | report.network.geolocationSummary, | 339 | report.network.geolocationSummary, |
| 340 | report.geolocationConfidence?.rawValue ?? "", | ||
| 341 | report.dataSources.joined(separator: " | "), | ||
| 342 | report.auditNote ?? "", | ||
| 343 | report.isPartialSnapshot ? "true" : "false", | ||
| 272 | report.changeSummary?.message ?? "" | 344 | report.changeSummary?.message ?? "" |
| 273 | ] | 345 | ] |
| 274 | } | 346 | } |
| @@ -338,6 +410,18 @@ enum DomainReportExporter { | |||
| 338 | return "Unavailable" | 410 | return "Unavailable" |
| 339 | } | 411 | } |
| 340 | 412 | ||
| 413 | private static func provenanceLabel(_ provenance: SectionProvenance) -> String { | ||
| 414 | [ | ||
| 415 | provenance.source, | ||
| 416 | provenance.provider, | ||
| 417 | provenance.resolver.map { "resolver=\($0)" }, | ||
| 418 | provenance.resultSource.label.lowercased(), | ||
| 419 | textDateFormatter.string(from: provenance.collectedAt) | ||
| 420 | ] | ||
| 421 | .compactMap { $0 } | ||
| 422 | .joined(separator: " | ") | ||
| 423 | } | ||
| 424 | |||
| 341 | private static let textDateFormatter: DateFormatter = { | 425 | private static let textDateFormatter: DateFormatter = { |
| 342 | let formatter = DateFormatter() | 426 | let formatter = DateFormatter() |
| 343 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" | 427 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" |
LookupSnapshot.swift +25 −1
| @@ -5,8 +5,20 @@ struct LookupSnapshot { | |||
| 5 | let domain: String | 5 | let domain: String |
| 6 | let timestamp: Date | 6 | let timestamp: Date |
| 7 | let trackedDomainID: UUID? | 7 | let trackedDomainID: UUID? |
| 8 | let note: String? | ||
| 9 | let appVersion: String | ||
| 8 | let resolverDisplayName: String | 10 | let resolverDisplayName: String |
| 9 | let resolverURLString: String | 11 | let resolverURLString: String |
| 12 | let dataSources: [String] | ||
| 13 | let provenanceBySection: [LookupSectionKind: SectionProvenance] | ||
| 14 | let availabilityConfidence: ConfidenceLevel? | ||
| 15 | let ownershipConfidence: ConfidenceLevel? | ||
| 16 | let subdomainConfidence: ConfidenceLevel? | ||
| 17 | let emailSecurityConfidence: ConfidenceLevel? | ||
| 18 | let geolocationConfidence: ConfidenceLevel? | ||
| 19 | let errorDetails: [LookupSectionKind: InspectionFailure] | ||
| 20 | let isPartialSnapshot: Bool | ||
| 21 | let validationIssues: [String] | ||
| 10 | let totalLookupDurationMs: Int? | 22 | let totalLookupDurationMs: Int? |
| 11 | let dnsSections: [DNSSection] | 23 | let dnsSections: [DNSSection] |
| 12 | let dnsError: String? | 24 | let dnsError: String? |
| @@ -55,8 +67,20 @@ extension HistoryEntry { | |||
| 55 | domain: domain, | 67 | domain: domain, |
| 56 | timestamp: timestamp, | 68 | timestamp: timestamp, |
| 57 | trackedDomainID: trackedDomainID, | 69 | trackedDomainID: trackedDomainID, |
| 70 | note: note, | ||
| 71 | appVersion: appVersion, | ||
| 58 | resolverDisplayName: resolverDisplayName, | 72 | resolverDisplayName: resolverDisplayName, |
| 59 | resolverURLString: resolverURLString, | 73 | resolverURLString: resolverURLString, |
| 74 | dataSources: dataSources, | ||
| 75 | provenanceBySection: provenanceBySection, | ||
| 76 | availabilityConfidence: availabilityConfidence, | ||
| 77 | ownershipConfidence: ownershipConfidence, | ||
| 78 | subdomainConfidence: subdomainConfidence, | ||
| 79 | emailSecurityConfidence: emailSecurityConfidence, | ||
| 80 | geolocationConfidence: geolocationConfidence, | ||
| 81 | errorDetails: errorDetails, | ||
| 82 | isPartialSnapshot: isPartialSnapshot, | ||
| 83 | validationIssues: validationIssues, | ||
| 60 | totalLookupDurationMs: totalLookupDurationMs, | 84 | totalLookupDurationMs: totalLookupDurationMs, |
| 61 | dnsSections: dnsSections, | 85 | dnsSections: dnsSections, |
| 62 | dnsError: nil, | 86 | dnsError: nil, |
| @@ -89,7 +113,7 @@ extension HistoryEntry { | |||
| 89 | portScanResults: portScanResults, | 113 | portScanResults: portScanResults, |
| 90 | portScanError: portScanError, | 114 | portScanError: portScanError, |
| 91 | changeSummary: changeSummary, | 115 | changeSummary: changeSummary, |
| 92 | resultSource: .snapshot, | 116 | resultSource: resultSource, |
| 93 | cachedSections: [], | 117 | cachedSections: [], |
| 94 | statusMessage: nil | 118 | statusMessage: nil |
| 95 | ) | 119 | ) |