Commit 7b41195620

7b41195620eadd54d25fa98dcd36735cba906ba3

parent: 36ea668d7f

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 14:47 UTC

feat(v2.7.0): add provenance, confidence, and reproducibility metadata

* add result provenance across major sections
* introduce confidence levels for ambiguous outputs
* distinguish observed facts from inferred summaries
* expand snapshot metadata for reproducibility
* improve error classification and partial snapshot handling
* include provenance and confidence in export
* add local notes for tracked domains and history

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 @@
1import Foundation
2
3enum 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
638struct 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
639struct LookupStatusBannerView: View { 661struct 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 {
686struct DomainChangeSummaryView: View { 708struct 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 {
727struct DomainDiffView: View { 784struct 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
1711struct 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
1612struct LabeledValueRow: View { 1767struct 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 {
87struct DomainSuggestionViewData: Identifiable { 87struct 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
9enum ConfidenceLevel: String, Codable {
10 case high
11 case medium
12 case low
13
14 var title: String {
15 rawValue.capitalized
16 }
17}
18
19enum 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
48struct InspectionFailure: Codable, Equatable {
49 let kind: InspectionErrorKind
50 let message: String
51 let details: String?
52}
53
54struct 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
9enum LookupResultSource: String, Codable { 62enum 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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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: &sectionSources) 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: &sectionSources,
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
3struct DomainReport: Codable { 3struct 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 )