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 @@
134134 };
135135 8BF124FB2F70000100933221 /* DomainDigCLI */ = {
136136 isa = PBXNativeTarget;
137 buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list */;
137 buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */;
138138 buildPhases = (
139139 8BF124FC2F70000100933221 /* Sources */,
140140 8BF124FD2F70000100933221 /* Frameworks */,
@@ -354,7 +354,7 @@
354354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
355355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
356356 CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = 19;
357 CURRENT_PROJECT_VERSION = 20;
358358 DEVELOPMENT_TEAM = ZCNAX3VL9D;
359359 ENABLE_PREVIEWS = YES;
360360 GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +371,7 @@
371371 "$(inherited)",
372372 "@executable_path/Frameworks",
373373 );
374 MARKETING_VERSION = 2.6.0;
374 MARKETING_VERSION = 2.7.0;
375375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
376376 PRODUCT_NAME = "$(TARGET_NAME)";
377377 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +390,7 @@
390390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392392 CODE_SIGN_STYLE = Automatic;
393 CURRENT_PROJECT_VERSION = 19;
393 CURRENT_PROJECT_VERSION = 20;
394394 DEVELOPMENT_TEAM = ZCNAX3VL9D;
395395 ENABLE_PREVIEWS = YES;
396396 GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +407,7 @@
407407 "$(inherited)",
408408 "@executable_path/Frameworks",
409409 );
410 MARKETING_VERSION = 2.6.0;
410 MARKETING_VERSION = 2.7.0;
411411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
412412 PRODUCT_NAME = "$(TARGET_NAME)";
413413 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -460,7 +460,7 @@
460460 defaultConfigurationIsVisible = 0;
461461 defaultConfigurationName = Release;
462462 };
463 8BBFF0292F987EF700E8E144 /* Build configuration list */ = {
463 8BBFF0292F987EF700E8E144 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */ = {
464464 isa = XCConfigurationList;
465465 buildConfigurations = (
466466 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 {
4242 }
4343 if viewModel.hasRun {
4444 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 {
4649 LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
4750 .padding(.top, appDensity.metrics.cardSpacing)
4851 }
49 SummaryView(fields: viewModel.summaryFields)
50 .padding(.top, appDensity.metrics.cardSpacing)
51 if let changeSummary = viewModel.currentChangeSummary {
52 DomainChangeSummaryView(summary: changeSummary)
52 if viewModel.resultsLoaded {
53 SummaryView(fields: viewModel.summaryFields)
5354 .padding(.top, appDensity.metrics.cardSpacing)
55 if let changeSummary = viewModel.currentChangeSummary {
56 DomainChangeSummaryView(summary: changeSummary)
57 .padding(.top, appDensity.metrics.cardSpacing)
58 }
5459 }
5560 DomainSectionView(
5661 isCollapsed: sectionCollapsedBinding(.domain),
@@ -59,6 +64,9 @@ struct ContentView: View {
5964 showSuggestions: viewModel.availabilityResult?.status == .registered || viewModel.suggestionsLoading,
6065 availabilityLoading: viewModel.availabilityLoading,
6166 suggestionsLoading: viewModel.suggestionsLoading,
67 provenance: viewModel.currentSnapshot.provenanceBySection[.availability],
68 confidence: viewModel.currentSnapshot.availabilityConfidence,
69 snapshotNote: viewModel.currentSnapshot.note,
6270 trackedDomain: viewModel.currentTrackedDomain,
6371 trackingLimitMessage: viewModel.trackingLimitMessage,
6472 onTrack: {
@@ -82,6 +90,8 @@ struct ContentView: View {
8290 rows: viewModel.ownershipRows,
8391 loading: viewModel.ownershipLoading,
8492 error: viewModel.ownershipError,
93 provenance: viewModel.currentSnapshot.provenanceBySection[.ownership],
94 confidence: viewModel.currentSnapshot.ownershipConfidence,
8595 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
8696 )
8797 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -90,6 +100,8 @@ struct ContentView: View {
90100 rows: viewModel.subdomainRows,
91101 loading: viewModel.subdomainsLoading,
92102 error: viewModel.subdomainsError,
103 provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
104 confidence: viewModel.currentSnapshot.subdomainConfidence,
93105 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
94106 )
95107 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -97,6 +109,7 @@ struct ContentView: View {
97109 DomainDiffView(
98110 title: "Latest Changes",
99111 sections: viewModel.currentDiffSections,
112 contextNote: viewModel.currentChangeSummary?.contextNote,
100113 showsUnchanged: false
101114 )
102115 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -107,6 +120,8 @@ struct ContentView: View {
107120 sections: viewModel.dnsRows,
108121 ptrMessage: viewModel.ptrMessage,
109122 loading: viewModel.dnsLoading || viewModel.ptrLoading,
123 dnsProvenance: viewModel.currentSnapshot.provenanceBySection[.dns],
124 ptrProvenance: viewModel.currentSnapshot.provenanceBySection[.ptr],
110125 sectionError: viewModel.dnsError
111126 )
112127 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -116,13 +131,16 @@ struct ContentView: View {
116131 sslInfo: viewModel.sslInfo,
117132 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
118133 sslError: viewModel.sslError,
134 tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl],
119135 responseRows: viewModel.webResponseRows,
120136 headers: viewModel.httpHeaders,
121137 headersLoading: viewModel.httpHeadersLoading,
122138 headersError: viewModel.httpHeadersError,
139 httpProvenance: viewModel.currentSnapshot.provenanceBySection[.httpHeaders],
123140 redirects: viewModel.redirectRows,
124141 redirectLoading: viewModel.redirectChainLoading,
125142 redirectError: viewModel.redirectChainError,
143 redirectProvenance: viewModel.currentSnapshot.provenanceBySection[.redirectChain],
126144 finalURL: viewModel.currentSnapshot.redirectChain.last?.url
127145 )
128146 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -130,6 +148,8 @@ struct ContentView: View {
130148 isCollapsed: sectionCollapsedBinding(.email),
131149 rows: viewModel.emailRows,
132150 loading: viewModel.emailSecurityLoading,
151 provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity],
152 confidence: viewModel.currentSnapshot.emailSecurityConfidence,
133153 error: viewModel.emailSecurityError
134154 )
135155 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -138,14 +158,18 @@ struct ContentView: View {
138158 reachabilityRows: viewModel.reachabilityRows,
139159 reachabilityLoading: viewModel.reachabilityLoading,
140160 reachabilityError: viewModel.reachabilityError,
161 reachabilityProvenance: viewModel.currentSnapshot.provenanceBySection[.reachability],
141162 locationRows: viewModel.locationRows,
142163 geolocation: viewModel.ipGeolocation,
143164 geolocationLoading: viewModel.ipGeolocationLoading,
144165 geolocationError: viewModel.ipGeolocationError,
166 geolocationProvenance: viewModel.currentSnapshot.provenanceBySection[.ipGeolocation],
167 geolocationConfidence: viewModel.currentSnapshot.geolocationConfidence,
145168 standardPortRows: viewModel.standardPortRows,
146169 customPortRows: viewModel.customPortRows,
147170 portScanLoading: viewModel.portScanLoading,
148171 portScanError: viewModel.portScanError,
172 portScanProvenance: viewModel.currentSnapshot.provenanceBySection[.portScan],
149173 customPortScanLoading: viewModel.customPortScanLoading,
150174 customPortScanError: viewModel.customPortScanError,
151175 isCloudflareProxied: viewModel.isCloudflareProxied,
@@ -161,27 +185,6 @@ struct ContentView: View {
161185 .padding(.horizontal)
162186 .padding(.bottom, 32)
163187 }
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 }
185188 .background(
186189 LinearGradient(
187190 colors: [Color.black, Color(.systemGray6).opacity(0.12)],
@@ -521,11 +524,7 @@ struct ContentView: View {
521524 }
522525
523526 private var defaultCollapsedSections: Set<ResultSection> {
524 var sections: Set<ResultSection> = []
525 if viewModel.standardPortRows.count + viewModel.customPortRows.count > 6 || currentPrimaryIP == nil {
526 sections.insert(.network)
527 }
528 return sections
527 []
529528 }
530529
531530 private var currentPrimaryIP: String? {
@@ -636,6 +635,29 @@ struct StickyLookupSummaryView: View {
636635 }
637636}
638637
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
639661struct LookupStatusBannerView: View {
640662 @Environment(\.appDensity) private var appDensity
641663 let message: String
@@ -686,6 +708,7 @@ struct LookupStatusBannerView: View {
686708struct DomainChangeSummaryView: View {
687709 @Environment(\.appDensity) private var appDensity
688710 let summary: DomainChangeSummary
711 @State private var showsDetails = false
689712
690713 var body: some View {
691714 CardView(allowsHorizontalScroll: false) {
@@ -706,9 +729,43 @@ struct DomainChangeSummaryView: View {
706729 .foregroundStyle(.secondary)
707730 }
708731
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 }
710766 .font(appDensity.font(.caption))
711 .foregroundStyle(.primary)
767 .tint(.secondary)
768 }
712769 }
713770 }
714771
@@ -727,6 +784,7 @@ struct DomainChangeSummaryView: View {
727784struct DomainDiffView: View {
728785 let title: String
729786 let sections: [DomainDiffSection]
787 let contextNote: String?
730788 let showsUnchanged: Bool
731789
732790 @State private var collapsedSections = Set<UUID>()
@@ -766,6 +824,9 @@ struct DomainDiffView: View {
766824 .font(.system(.caption, design: .monospaced))
767825 }
768826 }
827 if let contextNote {
828 MessageCardView(text: contextNote, isError: false)
829 }
769830 if filteredSections.isEmpty {
770831 MessageCardView(text: "No comparison data available", isError: false)
771832 } else {
@@ -917,6 +978,9 @@ struct DomainSectionView: View {
917978 let showSuggestions: Bool
918979 let availabilityLoading: Bool
919980 let suggestionsLoading: Bool
981 let provenance: SectionProvenance?
982 let confidence: ConfidenceLevel?
983 let snapshotNote: String?
920984 let trackedDomain: TrackedDomain?
921985 let trackingLimitMessage: String?
922986 let onTrack: () -> Void
@@ -953,6 +1017,11 @@ struct DomainSectionView: View {
9531017 }
9541018 } content: {
9551019 CardView(allowsHorizontalScroll: false) {
1020 SectionTrustMetadataView(
1021 provenance: provenance,
1022 confidence: confidence,
1023 note: snapshotNote == nil ? nil : "Audit note present"
1024 )
9561025 ForEach(rows) { row in
9571026 LabeledValueRow(row: row)
9581027 }
@@ -986,7 +1055,7 @@ struct DomainSectionView: View {
9861055 .foregroundStyle(.primary)
9871056 .textSelection(.enabled)
9881057 Spacer()
989 AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.status == "Available" ? .available : .registered))
1058 AppStatusBadgeView(model: AppStatusFactory.availability(suggestion.availabilityStatus))
9901059 }
9911060 }
9921061 }
@@ -1001,11 +1070,14 @@ struct OwnershipSectionView: View {
10011070 let rows: [InfoRowViewData]
10021071 let loading: Bool
10031072 let error: String?
1073 let provenance: SectionProvenance?
1074 let confidence: ConfidenceLevel?
10041075 let showsHistoryPlaceholder: Bool
10051076
10061077 var body: some View {
10071078 CollapsibleSectionView(title: "Ownership", isCollapsed: $isCollapsed) {
10081079 CardView(allowsHorizontalScroll: false) {
1080 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
10091081 if loading {
10101082 ProgressView("Fetching RDAP ownership…")
10111083 .appLoadingStyle()
@@ -1033,11 +1105,14 @@ struct SubdomainsSectionView: View {
10331105 let rows: [SubdomainRowViewData]
10341106 let loading: Bool
10351107 let error: String?
1108 let provenance: SectionProvenance?
1109 let confidence: ConfidenceLevel?
10361110 let showsExtendedPlaceholder: Bool
10371111
10381112 var body: some View {
10391113 CollapsibleSectionView(title: "Subdomains", isCollapsed: $isCollapsed, subtitle: "\(rows.count) found") {
10401114 CardView(allowsHorizontalScroll: false) {
1115 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
10411116 if loading {
10421117 ProgressView("Checking certificate transparency…")
10431118 .appLoadingStyle()
@@ -1082,6 +1157,8 @@ struct DNSSectionView: View {
10821157 let sections: [DNSRecordSectionViewData]
10831158 let ptrMessage: SectionMessageViewData?
10841159 let loading: Bool
1160 let dnsProvenance: SectionProvenance?
1161 let ptrProvenance: SectionProvenance?
10851162 let sectionError: String?
10861163
10871164 var body: some View {
@@ -1091,6 +1168,11 @@ struct DNSSectionView: View {
10911168 } else if let sectionError, sections.isEmpty {
10921169 MessageCardView(text: sectionError, isError: true)
10931170 } else {
1171 if dnsProvenance != nil {
1172 CardView(allowsHorizontalScroll: false) {
1173 SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
1174 }
1175 }
10941176 ForEach(sections) { section in
10951177 CardView {
10961178 Text(section.title)
@@ -1124,6 +1206,7 @@ struct DNSSectionView: View {
11241206 .font(.system(.subheadline, design: .monospaced))
11251207 .fontWeight(.semibold)
11261208 .foregroundStyle(.cyan)
1209 SectionTrustMetadataView(provenance: ptrProvenance, confidence: nil)
11271210 MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError)
11281211 }
11291212 }
@@ -1139,13 +1222,16 @@ struct WebSectionView: View {
11391222 let sslInfo: SSLCertificateInfo?
11401223 let sslLoading: Bool
11411224 let sslError: String?
1225 let tlsProvenance: SectionProvenance?
11421226 let responseRows: [InfoRowViewData]
11431227 let headers: [HTTPHeader]
11441228 let headersLoading: Bool
11451229 let headersError: String?
1230 let httpProvenance: SectionProvenance?
11461231 let redirects: [RedirectHopViewData]
11471232 let redirectLoading: Bool
11481233 let redirectError: String?
1234 let redirectProvenance: SectionProvenance?
11491235 let finalURL: String?
11501236
11511237 var body: some View {
@@ -1158,6 +1244,7 @@ struct WebSectionView: View {
11581244 Spacer()
11591245 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
11601246 }
1247 SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
11611248 if sslLoading {
11621249 ProgressView("Checking certificate…")
11631250 .appLoadingStyle()
@@ -1188,6 +1275,7 @@ struct WebSectionView: View {
11881275 Text("Headers")
11891276 .font(appDensity.font(.subheadline, weight: .semibold))
11901277 .foregroundStyle(.cyan)
1278 SectionTrustMetadataView(provenance: httpProvenance, confidence: nil)
11911279 if headersLoading {
11921280 ProgressView("Fetching headers…")
11931281 .appLoadingStyle()
@@ -1225,6 +1313,7 @@ struct WebSectionView: View {
12251313 AppCopyButton(value: finalURL, label: "Copy redirect URL")
12261314 }
12271315 }
1316 SectionTrustMetadataView(provenance: redirectProvenance, confidence: nil)
12281317 if redirectLoading {
12291318 ProgressView("Tracing redirects…")
12301319 .appLoadingStyle()
@@ -1268,11 +1357,14 @@ struct EmailSectionView: View {
12681357 @Binding var isCollapsed: Bool
12691358 let rows: [EmailRowViewData]
12701359 let loading: Bool
1360 let provenance: SectionProvenance?
1361 let confidence: ConfidenceLevel?
12711362 let error: String?
12721363
12731364 var body: some View {
12741365 CollapsibleSectionView(title: "Email", isCollapsed: $isCollapsed) {
12751366 CardView {
1367 SectionTrustMetadataView(provenance: provenance, confidence: confidence)
12761368 HStack {
12771369 Spacer()
12781370 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
@@ -1331,14 +1423,18 @@ struct NetworkSectionView: View {
13311423 let reachabilityRows: [ReachabilityRowViewData]
13321424 let reachabilityLoading: Bool
13331425 let reachabilityError: String?
1426 let reachabilityProvenance: SectionProvenance?
13341427 let locationRows: [InfoRowViewData]
13351428 let geolocation: IPGeolocation?
13361429 let geolocationLoading: Bool
13371430 let geolocationError: String?
1431 let geolocationProvenance: SectionProvenance?
1432 let geolocationConfidence: ConfidenceLevel?
13381433 let standardPortRows: [PortScanRowViewData]
13391434 let customPortRows: [PortScanRowViewData]
13401435 let portScanLoading: Bool
13411436 let portScanError: String?
1437 let portScanProvenance: SectionProvenance?
13421438 let customPortScanLoading: Bool
13431439 let customPortScanError: String?
13441440 let isCloudflareProxied: Bool
@@ -1352,6 +1448,7 @@ struct NetworkSectionView: View {
13521448 Text("Reachability")
13531449 .font(appDensity.font(.subheadline, weight: .semibold))
13541450 .foregroundStyle(.cyan)
1451 SectionTrustMetadataView(provenance: reachabilityProvenance, confidence: nil)
13551452 if reachabilityLoading {
13561453 ProgressView("Checking ports…")
13571454 .appLoadingStyle()
@@ -1376,6 +1473,7 @@ struct NetworkSectionView: View {
13761473 Text("Location")
13771474 .font(appDensity.font(.subheadline, weight: .semibold))
13781475 .foregroundStyle(.cyan)
1476 SectionTrustMetadataView(provenance: geolocationProvenance, confidence: geolocationConfidence)
13791477 if geolocationLoading {
13801478 ProgressView("Looking up location…")
13811479 .appLoadingStyle()
@@ -1407,6 +1505,7 @@ struct NetworkSectionView: View {
14071505 Text("Port Scan")
14081506 .font(appDensity.font(.subheadline, weight: .semibold))
14091507 .foregroundStyle(.cyan)
1508 SectionTrustMetadataView(provenance: portScanProvenance, confidence: nil)
14101509
14111510 if isCloudflareProxied {
14121511 Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.")
@@ -1609,6 +1708,62 @@ struct MessageRowView: View {
16091708 }
16101709}
16111710
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
16121767struct LabeledValueRow: View {
16131768 @Environment(\.appDensity) private var appDensity
16141769 let row: InfoRowViewData
@@ -1737,7 +1892,6 @@ private struct SettingsView: View {
17371892 Section("About") {
17381893 LabeledContent("Version", value: appVersion)
17391894 LabeledContent("Storage", value: "Local-only")
1740 LabeledContent("Focus", value: "Readable domain inspection")
17411895 }
17421896 }
17431897 .navigationTitle("Settings")
@@ -1776,7 +1930,7 @@ private struct SettingsView: View {
17761930 }
17771931
17781932 private var appVersion: String {
1779 Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.6.0"
1933 AppVersion.current
17801934 }
17811935}
17821936
DomainDig/DomainAvailabilityService.swift +6 −2
@@ -15,8 +15,12 @@ struct DomainAvailabilityService {
1515 }
1616
1717 let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain)
18 let method = fallbackStatus == .registered ? "dns" : "fallback"
19 debugLog(method, domain: normalizedDomain, status: fallbackStatus)
18 if fallbackStatus == .registered {
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)
2024 return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus)
2125 }
2226
DomainDig/DomainDiffService.swift +26 −1
@@ -67,16 +67,33 @@ enum DomainDiffService {
6767 let highlights = summaryHighlights(from: allChangedItems)
6868 let severity = allChangedItems.map(\.severity).max() ?? .low
6969 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)
7073
7174 return DomainChangeSummary(
7275 hasChanges: !allChangedItems.isEmpty,
7376 changedSections: highlights,
7477 message: message,
7578 severity: severity,
76 generatedAt: generatedAt
79 generatedAt: generatedAt,
80 observedFacts: observedFacts,
81 inferredConclusions: inferredConclusions,
82 contextNote: contextNote
7783 )
7884 }
7985
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
8097 static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
8198 guard let days = snapshot.sslInfo?.daysUntilExpiry else {
8299 return .none
@@ -410,6 +427,14 @@ enum DomainDiffService {
410427 return "\(highlights[0]) and \(highlights[1].lowercased())"
411428 }
412429
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
413438 private static func normalized(_ value: String?) -> String? {
414439 guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
415440 return nil
DomainDig/DomainViewModel.swift +169 −18
@@ -87,6 +87,7 @@ struct SubdomainRowViewData: Identifiable {
8787struct DomainSuggestionViewData: Identifiable {
8888 let id: UUID
8989 let domain: String
90 let availabilityStatus: DomainAvailabilityStatus
9091 let status: String
9192 let tone: ResultTone
9293}
@@ -204,13 +205,7 @@ final class DomainViewModel {
204205
205206 private static let historyKey = "lookupHistory"
206207 private static let maxHistory = 250
207 var history: [HistoryEntry] = {
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 }()
208 var history: [HistoryEntry] = DomainViewModel.loadHistoryEntries()
214209 var historySearchText = ""
215210 var historyDateFilter: HistoryDateFilter = .all
216211 var historyChangeFilter: ChangeFilterOption = .all
@@ -246,6 +241,24 @@ final class DomainViewModel {
246241 !customPortScanLoading
247242 }
248243
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
249262 var isCloudflareProxied: Bool {
250263 httpHeaders.contains { $0.name.lowercased() == "cf-ray" }
251264 }
@@ -365,8 +378,20 @@ final class DomainViewModel {
365378 domain: searchedDomain,
366379 timestamp: currentSnapshotTimestamp,
367380 trackedDomainID: currentTrackedDomain?.id,
381 note: currentHistoryEntry?.note ?? currentTrackedDomain?.note,
382 appVersion: AppVersion.current,
368383 resolverDisplayName: resolverDisplayName,
369384 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 ?? [],
370395 totalLookupDurationMs: lastLookupDurationMs,
371396 dnsSections: dnsSections,
372397 dnsError: dnsError,
@@ -405,6 +430,11 @@ final class DomainViewModel {
405430 )
406431 }
407432
433 private var currentHistoryEntry: HistoryEntry? {
434 guard let currentHistoryEntryID else { return nil }
435 return history.first(where: { $0.id == currentHistoryEntryID })
436 }
437
408438 var currentReport: DomainReport? {
409439 guard !searchedDomain.isEmpty else { return nil }
410440 return reportBuilder.build(
@@ -543,9 +573,7 @@ final class DomainViewModel {
543573 }
544574
545575 func rerunInspection(for trackedDomain: TrackedDomain) {
546 domain = trackedDomain.domain
547 run()
548 rerunNavigationToken = UUID()
576 rerunInspection(for: trackedDomain, useSnapshotResolver: false)
549577 }
550578
551579 func deleteTrackedDomains(at offsets: IndexSet) {
@@ -609,13 +637,24 @@ final class DomainViewModel {
609637 UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
610638 }
611639
612 func rerunLookup(from entry: HistoryEntry) {
613 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
640 func rerunLookup(from entry: HistoryEntry, useSnapshotResolver: Bool) {
641 if useSnapshotResolver {
642 UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey)
643 }
614644 domain = entry.domain
615645 run()
616646 rerunNavigationToken = UUID()
617647 }
618648
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
619658 func reset() {
620659 lookupTask?.cancel()
621660 customPortScanTask?.cancel()
@@ -853,8 +892,20 @@ final class DomainViewModel {
853892 domain: previousSnapshot.domain,
854893 timestamp: previousSnapshot.timestamp,
855894 trackedDomainID: previousSnapshot.trackedDomainID,
895 note: previousSnapshot.note,
896 appVersion: previousSnapshot.appVersion,
856897 resolverDisplayName: previousSnapshot.resolverDisplayName,
857898 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,
858909 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
859910 dnsSections: previousSnapshot.dnsSections,
860911 dnsError: previousSnapshot.dnsError,
@@ -1232,6 +1283,7 @@ final class DomainViewModel {
12321283 domain: snapshot.domain,
12331284 timestamp: snapshot.timestamp,
12341285 trackedDomainID: trackedDomainID,
1286 note: currentHistoryEntry?.note,
12351287 dnsSections: snapshot.dnsSections,
12361288 sslInfo: snapshot.sslInfo,
12371289 httpHeaders: snapshot.httpHeaders,
@@ -1247,6 +1299,18 @@ final class DomainViewModel {
12471299 hstsPreloaded: snapshot.hstsPreloaded,
12481300 availabilityResult: snapshot.availabilityResult,
12491301 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,
12501314 resolverDisplayName: snapshot.resolverDisplayName,
12511315 resolverURLString: snapshot.resolverURLString,
12521316 totalLookupDurationMs: snapshot.totalLookupDurationMs,
@@ -1302,6 +1366,12 @@ final class DomainViewModel {
13021366 }
13031367 }
13041368
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
13051375 private func persistTrackedDomains() {
13061376 if let data = try? JSONEncoder().encode(trackedDomains) {
13071377 UserDefaults.standard.set(data, forKey: Self.trackedDomainsKey)
@@ -1777,6 +1847,22 @@ final class DomainViewModel {
17771847 trackedDomain.lastChangeSummary ?? recentSnapshots(for: trackedDomain, limit: 1).first?.changeSummary
17781848 }
17791849
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
17801866 func comparisonSnapshot(for entry: HistoryEntry) -> LookupSnapshot? {
17811867 let siblings = history.filter { candidate in
17821868 if let trackedDomainID = entry.trackedDomainID {
@@ -1834,8 +1920,20 @@ final class DomainViewModel {
18341920 domain: trackedDomain.domain,
18351921 timestamp: trackedDomain.updatedAt,
18361922 trackedDomainID: trackedDomain.id,
1923 note: trackedDomain.note,
1924 appVersion: AppVersion.current,
18371925 resolverDisplayName: resolverDisplayName,
18381926 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"],
18391937 totalLookupDurationMs: nil,
18401938 dnsSections: [],
18411939 dnsError: nil,
@@ -1874,6 +1972,31 @@ final class DomainViewModel {
18741972 )
18751973 }
18761974
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
18772000 private static func loadTrackedDomains() -> [TrackedDomain] {
18782001 let defaults = UserDefaults.standard
18792002 let decoder = JSONDecoder()
@@ -1953,10 +2076,11 @@ final class DomainViewModel {
19532076 static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] {
19542077 [
19552078 SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary),
1956 SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
1957 SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
2079 SummaryFieldViewData(label: "Observed IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
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)),
19582083 SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
1959 SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
19602084 SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot))
19612085 ]
19622086 }
@@ -1965,17 +2089,32 @@ final class DomainViewModel {
19652089 var rows = [
19662090 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
19672091 InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
2092 InfoRowViewData(label: "Collected", value: snapshot.timestamp.formatted(date: .abbreviated, time: .shortened), tone: .secondary),
19682093 InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)),
19692094 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
19702095 ]
19712096 rows.insert(
19722097 InfoRowViewData(
1973 label: "Availability",
1974 value: availabilityLabel(snapshot.availabilityResult?.status),
1975 tone: availabilityTone(snapshot.availabilityResult?.status)
2098 label: "Observed Availability",
2099 value: snapshot.availabilityResult?.status == .unknown ? "No direct registration proof" : "Status collected",
2100 tone: .secondary
19762101 ),
19772102 at: 1
19782103 )
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 }
19792118 if let certificateStatus = certificateBadgeLabel(from: snapshot) {
19802119 rows.insert(
19812120 InfoRowViewData(
@@ -1994,6 +2133,7 @@ final class DomainViewModel {
19942133 DomainSuggestionViewData(
19952134 id: $0.id,
19962135 domain: $0.domain,
2136 availabilityStatus: $0.status,
19972137 status: availabilityLabel($0.status),
19982138 tone: availabilityTone($0.status)
19992139 )
@@ -2562,6 +2702,17 @@ final class DomainViewModel {
25622702 }
25632703 }
25642704
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
25652716 private static func availabilityTone(_ status: DomainAvailabilityStatus?) -> ResultTone {
25662717 switch status {
25672718 case .available:
DomainDig/HistoryView.swift +101 −11
@@ -39,6 +39,9 @@ struct HistoryView: View {
3939 HStack(spacing: 8) {
4040 AppStatusBadgeView(model: AppStatusFactory.availability(entry.availabilityResult?.status))
4141 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 }
4245 }
4346
4447 HStack(spacing: 8) {
@@ -51,6 +54,13 @@ struct HistoryView: View {
5154 }
5255 .font(appDensity.font(.caption2))
5356 .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 }
5464 }
5565 }
5666 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
@@ -128,6 +138,9 @@ struct HistoryDetailView: View {
128138 @Bindable var viewModel: DomainViewModel
129139 let entry: HistoryEntry
130140 @Environment(\.dismiss) private var dismiss
141 @State private var noteDraft = ""
142 @State private var isEditingNote = false
143 @State private var showRerunOptions = false
131144
132145 private let dateFormatter: DateFormatter = {
133146 let formatter = DateFormatter()
@@ -153,6 +166,9 @@ struct HistoryDetailView: View {
153166 showSuggestions: entry.availabilityResult?.status == .registered && !entry.suggestions.isEmpty,
154167 availabilityLoading: false,
155168 suggestionsLoading: false,
169 provenance: snapshot.provenanceBySection[.availability],
170 confidence: snapshot.availabilityConfidence,
171 snapshotNote: entry.note,
156172 trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
157173 trackingLimitMessage: nil,
158174 onTrack: {
@@ -170,6 +186,8 @@ struct HistoryDetailView: View {
170186 rows: DomainViewModel.ownershipRows(from: snapshot),
171187 loading: false,
172188 error: snapshot.ownershipError,
189 provenance: snapshot.provenanceBySection[.ownership],
190 confidence: snapshot.ownershipConfidence,
173191 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
174192 )
175193 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -178,6 +196,8 @@ struct HistoryDetailView: View {
178196 rows: DomainViewModel.subdomainRows(from: snapshot),
179197 loading: false,
180198 error: snapshot.subdomainsError,
199 provenance: snapshot.provenanceBySection[.subdomains],
200 confidence: snapshot.subdomainConfidence,
181201 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
182202 )
183203 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -189,6 +209,7 @@ struct HistoryDetailView: View {
189209 DomainDiffView(
190210 title: "Compared With Previous Snapshot",
191211 sections: DomainDiffService.diff(from: comparisonSnapshot, to: snapshot),
212 contextNote: DomainDiffService.comparisonContextNote(from: comparisonSnapshot, to: snapshot),
192213 showsUnchanged: false
193214 )
194215 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -199,6 +220,8 @@ struct HistoryDetailView: View {
199220 sections: DomainViewModel.dnsRows(from: snapshot),
200221 ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
201222 loading: false,
223 dnsProvenance: snapshot.provenanceBySection[.dns],
224 ptrProvenance: snapshot.provenanceBySection[.ptr],
202225 sectionError: snapshot.dnsError
203226 )
204227 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -208,13 +231,16 @@ struct HistoryDetailView: View {
208231 sslInfo: snapshot.sslInfo,
209232 sslLoading: false,
210233 sslError: snapshot.sslError,
234 tlsProvenance: snapshot.provenanceBySection[.ssl],
211235 responseRows: DomainViewModel.webResponseRows(from: snapshot),
212236 headers: snapshot.httpHeaders,
213237 headersLoading: false,
214238 headersError: snapshot.httpHeadersError,
239 httpProvenance: snapshot.provenanceBySection[.httpHeaders],
215240 redirects: DomainViewModel.redirectRows(from: snapshot),
216241 redirectLoading: false,
217242 redirectError: snapshot.redirectChainError,
243 redirectProvenance: snapshot.provenanceBySection[.redirectChain],
218244 finalURL: snapshot.redirectChain.last?.url
219245 )
220246 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -222,6 +248,8 @@ struct HistoryDetailView: View {
222248 isCollapsed: .constant(false),
223249 rows: DomainViewModel.emailRows(from: snapshot),
224250 loading: false,
251 provenance: snapshot.provenanceBySection[.emailSecurity],
252 confidence: snapshot.emailSecurityConfidence,
225253 error: snapshot.emailSecurityError
226254 )
227255 .padding(.top, appDensity.metrics.sectionSpacing)
@@ -230,14 +258,18 @@ struct HistoryDetailView: View {
230258 reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot),
231259 reachabilityLoading: false,
232260 reachabilityError: snapshot.reachabilityError,
261 reachabilityProvenance: snapshot.provenanceBySection[.reachability],
233262 locationRows: DomainViewModel.locationRows(from: snapshot),
234263 geolocation: snapshot.ipGeolocation,
235264 geolocationLoading: false,
236265 geolocationError: snapshot.ipGeolocationError,
266 geolocationProvenance: snapshot.provenanceBySection[.ipGeolocation],
267 geolocationConfidence: snapshot.geolocationConfidence,
237268 standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard),
238269 customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom),
239270 portScanLoading: false,
240271 portScanError: snapshot.portScanError,
272 portScanProvenance: snapshot.provenanceBySection[.portScan],
241273 customPortScanLoading: false,
242274 customPortScanError: nil,
243275 isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }),
@@ -253,26 +285,84 @@ struct HistoryDetailView: View {
253285 .background(Color.black)
254286 .navigationTitle(entry.domain)
255287 .toolbar {
256 Button("Re-run") {
257 viewModel.rerunLookup(from: entry)
288 ToolbarItemGroup(placement: .topBarTrailing) {
289 Button("Note") {
290 noteDraft = entry.note ?? ""
291 isEditingNote = true
292 }
293 Button("Re-run") {
294 showRerunOptions = true
295 }
258296 }
259297 }
260298 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
261299 dismiss()
262300 }
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 }
263336 .preferredColorScheme(.dark)
264337 }
265338
266339 private var snapshotBanner: some View {
267 HStack(spacing: 8) {
268 Image(systemName: "archivebox")
269 .font(.caption)
270 Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
271 .font(appDensity.font(.caption))
272 Spacer()
273 Text("Live re-run available")
274 .font(appDensity.font(.caption2))
275 .foregroundStyle(.secondary)
340 VStack(alignment: .leading, spacing: 8) {
341 HStack(spacing: 8) {
342 Image(systemName: "archivebox")
343 .font(.caption)
344 Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))")
345 .font(appDensity.font(.caption))
346 Spacer()
347 Text("Live re-run available")
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 }
276366 }
277367 .foregroundStyle(.secondary)
278368 .padding(8)
DomainDig/LookupRuntime.swift +3 −9
@@ -79,15 +79,9 @@ actor LookupRuntime {
7979 }
8080
8181 func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> {
82 await execute(
83 key: .domain(domain, .availability),
84 extract: { payload in
85 guard case let .availability(result) = payload else { return nil }
86 return result
87 },
88 operation: {
89 .availability(await DomainAvailabilityService.check(domain: domain))
90 }
82 CachedLookupResult(
83 value: await DomainAvailabilityService.check(domain: domain),
84 source: .live
9185 )
9286 }
9387
DomainDig/Models.swift +130 −8
@@ -6,6 +6,59 @@ enum ServiceResult<Value> {
66 case error(String)
77}
88
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
962enum LookupResultSource: String, Codable {
1063 case live
1164 case cached
@@ -129,19 +182,28 @@ struct DomainChangeSummary: Codable, Equatable {
129182 let message: String
130183 let severity: ChangeSeverity
131184 let generatedAt: Date
185 let observedFacts: [String]
186 let inferredConclusions: [String]
187 let contextNote: String?
132188
133189 init(
134190 hasChanges: Bool,
135191 changedSections: [String],
136192 message: String,
137193 severity: ChangeSeverity,
138 generatedAt: Date
194 generatedAt: Date,
195 observedFacts: [String] = [],
196 inferredConclusions: [String] = [],
197 contextNote: String? = nil
139198 ) {
140199 self.hasChanges = hasChanges
141200 self.changedSections = changedSections
142201 self.message = message
143202 self.severity = severity
144203 self.generatedAt = generatedAt
204 self.observedFacts = observedFacts
205 self.inferredConclusions = inferredConclusions
206 self.contextNote = contextNote
145207 }
146208
147209 init(from decoder: Decoder) throws {
@@ -152,6 +214,9 @@ struct DomainChangeSummary: Codable, Equatable {
152214 severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low)
153215 message = try container.decodeIfPresent(String.self, forKey: .message)
154216 ?? (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)
155220 }
156221}
157222
@@ -709,6 +774,7 @@ struct HistoryEntry: Identifiable, Codable {
709774 let domain: String
710775 let timestamp: Date
711776 var trackedDomainID: UUID?
777 var note: String?
712778 let dnsSections: [DNSSection]
713779 let sslInfo: SSLCertificateInfo?
714780 let httpHeaders: [HTTPHeader]
@@ -724,6 +790,18 @@ struct HistoryEntry: Identifiable, Codable {
724790 var hstsPreloaded: Bool?
725791 var availabilityResult: DomainAvailabilityResult?
726792 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]
727805 var resolverDisplayName: String
728806 var resolverURLString: String
729807 var totalLookupDurationMs: Int?
@@ -744,14 +822,21 @@ struct HistoryEntry: Identifiable, Codable {
744822 var subdomainsError: String?
745823 var portScanError: String?
746824
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],
748826 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
749827 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
750828 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
751829 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
752830 portScanResults: [PortScanResult] = [],
753831 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,
755840 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
756841 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
757842 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
@@ -761,6 +846,7 @@ struct HistoryEntry: Identifiable, Codable {
761846 self.domain = domain
762847 self.timestamp = timestamp
763848 self.trackedDomainID = trackedDomainID
849 self.note = note
764850 self.dnsSections = dnsSections
765851 self.sslInfo = sslInfo
766852 self.httpHeaders = httpHeaders
@@ -776,6 +862,18 @@ struct HistoryEntry: Identifiable, Codable {
776862 self.hstsPreloaded = hstsPreloaded
777863 self.availabilityResult = availabilityResult
778864 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
779877 self.resolverDisplayName = resolverDisplayName
780878 self.resolverURLString = resolverURLString
781879 self.totalLookupDurationMs = totalLookupDurationMs
@@ -800,13 +898,14 @@ struct HistoryEntry: Identifiable, Codable {
800898 init(from decoder: Decoder) throws {
801899 let container = try decoder.container(keyedBy: CodingKeys.self)
802900 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
803 domain = try container.decode(String.self, forKey: .domain)
804 timestamp = try container.decode(Date.self, forKey: .timestamp)
901 domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? "unknown-domain"
902 timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp) ?? .distantPast
805903 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) ?? []
807906 sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
808 httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders)
809 reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults)
907 httpHeaders = try container.decodeIfPresent([HTTPHeader].self, forKey: .httpHeaders) ?? []
908 reachabilityResults = try container.decodeIfPresent([PortReachability].self, forKey: .reachabilityResults) ?? []
810909 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
811910 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
812911 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
@@ -818,6 +917,18 @@ struct HistoryEntry: Identifiable, Codable {
818917 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
819918 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
820919 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
821932 resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare"
822933 resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString
823934 totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs)
@@ -838,6 +949,17 @@ struct HistoryEntry: Identifiable, Codable {
838949 subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
839950 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
840951 }
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 }
841963}
842964
843965// MARK: - Cloudflare DNS-over-HTTPS Response
DomainDig/WatchlistView.swift +23 −2
@@ -334,6 +334,7 @@ struct TrackedDomainDetailView: View {
334334
335335 @State private var noteDraft = ""
336336 @State private var isEditingNote = false
337 @State private var showRerunOptions = false
337338
338339 private var liveTrackedDomain: TrackedDomain {
339340 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
@@ -365,7 +366,7 @@ struct TrackedDomainDetailView: View {
365366 }
366367
367368 Button {
368 viewModel.rerunInspection(for: liveTrackedDomain)
369 showRerunOptions = true
369370 } label: {
370371 Label("Re-run Inspection", systemImage: "magnifyingglass")
371372 }
@@ -394,7 +395,14 @@ struct TrackedDomainDetailView: View {
394395
395396 if !latestDiffSections.isEmpty {
396397 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 )
398406 }
399407 .listRowBackground(Color.clear)
400408 }
@@ -430,6 +438,19 @@ struct TrackedDomainDetailView: View {
430438 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
431439 dismiss()
432440 }
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 }
433454 .sheet(isPresented: $isEditingNote) {
434455 NavigationStack {
435456 Form {
DomainInspectionService.swift +315 −32
@@ -20,6 +20,9 @@ struct DomainInspectionService {
2020 let resolverURLString = DNSLookupService.currentResolverURLString()
2121 var cachedSections = Set<LookupSectionKind>()
2222 var sectionSources: [LookupResultSource] = []
23 var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:]
24 var dataSources = Set<String>()
25 var errorDetails: [LookupSectionKind: InspectionFailure] = [:]
2326
2427 async let dnsFetch = runtime.dns(domain: normalizedDomain)
2528 async let availabilityFetch = runtime.availability(domain: normalizedDomain)
@@ -34,41 +37,129 @@ struct DomainInspectionService {
3437
3538 let resolvedDNS = await dnsFetch
3639 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)
3850
3951 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 )
4161
4262 let resolvedSSL = await sslFetch
4363 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)
4574
4675 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 )
4885
4986 let http = await httpFetch
5087 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)
5298
5399 let reachability = await reachabilityFetch
54100 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)
56111
57112 let resolvedOwnership = await ownershipFetch
58113 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)
60124
61125 let redirects = await redirectFetch
62126 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)
64137
65138 let resolvedSubdomains = await subdomainFetch
66139 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)
68150
69151 let ports = await portScanFetch
70152 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)
72163
73164 let dnsSections = mapServiceResult(dnsResult, emptyValue: [])
74165 let sslInfo = mapOptionalValueServiceResult(sslResult)
@@ -92,12 +183,30 @@ struct DomainInspectionService {
92183 } else {
93184 emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords)
94185 }
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)
96197
97198 let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]>
98199 if availability.value.status == .registered {
99200 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 )
101210 } else {
102211 suggestionsOutcome = CachedLookupResult(value: [], source: .live)
103212 }
@@ -122,27 +231,65 @@ struct DomainInspectionService {
122231 }
123232
124233 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)
126245 }
127246 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)
129258 }
130259 } else {
131260 ptrOutcome = nil
132261 geoOutcome = nil
133262 }
134263
135 let emailSecurity = mapOptionalValueServiceResult(normalizeErrors(in: emailOutcome.value))
264 let emailSecurity = mapOptionalValueServiceResult(normalizedEmailResult)
136265 let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
137266 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)
138273
139274 return LookupSnapshot(
140275 historyEntryID: nil,
141276 domain: availability.value.domain,
142277 timestamp: Date(),
143278 trackedDomainID: previousSnapshot?.trackedDomainID,
279 note: previousSnapshot?.note,
280 appVersion: AppVersion.current,
144281 resolverDisplayName: resolverDisplayName,
145282 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,
146293 totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
147294 dnsSections: dnsSections.value,
148295 dnsError: dnsSections.message,
@@ -193,15 +340,64 @@ struct DomainInspectionService {
193340 private func track(
194341 _ section: LookupSectionKind,
195342 source: LookupResultSource,
343 provenance: SectionProvenance,
196344 cachedSections: inout Set<LookupSectionKind>,
197 sectionSources: inout [LookupResultSource]
345 sectionSources: inout [LookupResultSource],
346 provenanceBySection: inout [LookupSectionKind: SectionProvenance],
347 dataSources: inout Set<String>
198348 ) {
199349 sectionSources.append(source)
350 provenanceBySection[section] = provenance
351 dataSources.insert(provenance.provider ?? provenance.source)
200352 if source != .live {
201353 cachedSections.insert(section)
202354 }
203355 }
204356
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
205401 private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource {
206402 let normalizedSources = sectionSources.map { source -> LookupResultSource in
207403 source == .mixed ? .cached : source
@@ -259,44 +455,131 @@ struct DomainInspectionService {
259455 case let .success(value):
260456 return .success(value)
261457 case let .empty(message):
262 return .empty(message)
458 return .empty(classifyFailure(from: message, defaultKind: .unavailable).message)
263459 case let .error(message):
264 return .error(classifiedMessage(from: message))
460 return .error(classifyFailure(from: message).message)
265461 }
266462 }
267463
268 private func classifiedMessage(from message: String) -> String {
464 private func classifyFailure(from message: String, defaultKind: InspectionErrorKind = .unknown) -> InspectionFailure {
269465 let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
270466 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
279467 if lowercasedMessage.contains("timed out") {
280 return "Timeout: Request timed out"
468 return InspectionFailure(kind: .timeout, message: "Timed out", details: normalizedMessage)
281469 }
282470 if lowercasedMessage.contains("429")
283471 || lowercasedMessage.contains("too many requests")
284472 || lowercasedMessage.contains("rate limit") {
285 return "Rate limit: Try again shortly"
473 return InspectionFailure(kind: .rateLimited, message: "Rate limited", details: normalizedMessage)
286474 }
287475 if lowercasedMessage.contains("cannot parse")
288476 || lowercasedMessage.contains("decoding")
289477 || lowercasedMessage.contains("json") {
290 return "Parsing error: Invalid server response"
478 return InspectionFailure(kind: .parsing, message: "Could not parse response", details: normalizedMessage)
291479 }
292480 if lowercasedMessage.contains("offline")
293481 || lowercasedMessage.contains("internet connection")
294482 || lowercasedMessage.contains("not connected")
295483 || lowercasedMessage.contains("network connection") {
296 return "Network error: Offline"
484 return InspectionFailure(kind: .network, message: "Network unavailable", details: normalizedMessage)
297485 }
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 }
298497
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
300583 }
301584
302585 private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
DomainReportBuilder.swift +28
@@ -3,8 +3,22 @@ import Foundation
33struct DomainReport: Codable {
44 let domain: String
55 let timestamp: Date
6 let appVersion: String
7 let resolverDisplayName: String
8 let resolverURLString: String
9 let dataSources: [String]
610 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?
716 let availability: DomainAvailabilityStatus
17 let availabilityConfidence: ConfidenceLevel?
18 let ownershipConfidence: ConfidenceLevel?
19 let subdomainConfidence: ConfidenceLevel?
20 let emailConfidence: ConfidenceLevel?
21 let geolocationConfidence: ConfidenceLevel?
822 let ownership: DomainOwnership?
923 let dns: DNSResultSummary
1024 let web: WebResultSummary
@@ -71,8 +85,22 @@ struct DomainReportBuilder {
7185 return DomainReport(
7286 domain: snapshot.domain,
7387 timestamp: snapshot.timestamp,
88 appVersion: snapshot.appVersion,
89 resolverDisplayName: snapshot.resolverDisplayName,
90 resolverURLString: snapshot.resolverURLString,
91 dataSources: snapshot.dataSources,
7492 resultSource: snapshot.resultSource,
93 sectionProvenance: snapshot.provenanceBySection,
94 errorDetails: snapshot.errorDetails,
95 isPartialSnapshot: snapshot.isPartialSnapshot,
96 validationIssues: snapshot.validationIssues,
97 auditNote: snapshot.note,
7598 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,
76104 ownership: snapshot.ownership,
77105 dns: DNSResultSummary(
78106 resolverDisplayName: snapshot.resolverDisplayName,
DomainReportExporter.swift +91 −7
@@ -36,10 +36,27 @@ enum DomainReportExporter {
3636 "DomainDig Report",
3737 "Domain: \(report.domain)",
3838 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
39 "App Version: \(report.appVersion)",
40 "Resolver: \(report.resolverDisplayName)",
41 "Resolver URL: \(report.resolverURLString)",
3942 "Source: \(report.resultSource.label)",
40 "Availability: \(availabilityLabel(report.availability))"
43 "Availability: \(availabilityLabel(report.availability))",
44 "Availability Confidence: \(report.availabilityConfidence?.title ?? "N/A")"
4145 ]
4246
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
4360 appendSection("Summary", to: &lines) {
4461 [
4562 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
@@ -53,12 +70,16 @@ enum DomainReportExporter {
5370 appendSection("Ownership", to: &lines) {
5471 var ownershipLines = [
5572 "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
73 "Confidence: \(report.ownershipConfidence?.title ?? "N/A")",
5674 "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
5775 "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
5876 "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
5977 "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
6078 "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
6179 ]
80 if let provenance = report.sectionProvenance[.ownership] {
81 ownershipLines.append("Provenance: \(provenanceLabel(provenance))")
82 }
6283 if let error = report.dns.error, report.ownership == nil {
6384 ownershipLines.append("Error: \(error)")
6485 } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
@@ -69,13 +90,14 @@ enum DomainReportExporter {
6990
7091 appendSection("DNS", to: &lines) {
7192 var dnsLines = [
72 "Resolver: \(report.dns.resolverDisplayName)",
73 "Resolver URL: \(report.dns.resolverURLString)",
7493 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
7594 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
7695 "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
7796 "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))"
7897 ]
98 if let provenance = report.sectionProvenance[.dns] {
99 dnsLines.append("Provenance: \(provenanceLabel(provenance))")
100 }
79101 if let error = report.dns.error {
80102 dnsLines.append("Error: \(error)")
81103 }
@@ -104,6 +126,15 @@ enum DomainReportExporter {
104126 "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
105127 "Header Count: \(report.web.headerCount)"
106128 ]
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 }
107138 if let tlsError = report.web.tlsError {
108139 webLines.append("TLS Error: \(tlsError)")
109140 }
@@ -130,6 +161,10 @@ enum DomainReportExporter {
130161
131162 appendSection("Email", to: &lines) {
132163 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 }
133168 if let records = report.email.records {
134169 emailLines.append("SPF: \(recordLabel(records.spf))")
135170 emailLines.append("DMARC: \(recordLabel(records.dmarc))")
@@ -147,8 +182,12 @@ enum DomainReportExporter {
147182 var networkLines = [
148183 "Reachability: \(report.network.reachabilitySummary)",
149184 "Geolocation: \(report.network.geolocationSummary)",
185 "Geolocation Confidence: \(report.geolocationConfidence?.title ?? "N/A")",
150186 "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
151187 ]
188 if let provenance = report.sectionProvenance[.ipGeolocation] {
189 networkLines.append("Geolocation Provenance: \(provenanceLabel(provenance))")
190 }
152191 if let error = report.network.reachabilityError {
153192 networkLines.append("Reachability Error: \(error)")
154193 }
@@ -170,10 +209,16 @@ enum DomainReportExporter {
170209 }
171210
172211 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 }
173216 if report.subdomains.isEmpty {
174 return ["None"]
217 values.append("None")
218 return values
175219 }
176 return report.subdomains.map { "- \($0)" }
220 values.append(contentsOf: report.subdomains.map { "- \($0)" })
221 return values
177222 }
178223
179224 appendSection("Changes", to: &lines) {
@@ -181,12 +226,19 @@ enum DomainReportExporter {
181226 return ["No comparison available"]
182227 }
183228
184 return [
229 var values = [
185230 "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
186231 "Severity: \(changeSummary.severity.title)",
187 "Summary: \(changeSummary.message)",
232 "Inferred Summary: \(changeSummary.message)",
188233 "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
189234 ]
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
190242 }
191243
192244 return lines.joined(separator: "\n")
@@ -213,9 +265,13 @@ enum DomainReportExporter {
213265 let headers = [
214266 "domain",
215267 "timestamp",
268 "app_version",
216269 "result_source",
270 "resolver",
217271 "availability",
272 "availability_confidence",
218273 "registrar",
274 "ownership_confidence",
219275 "ownership_expires",
220276 "nameservers",
221277 "primary_ip",
@@ -228,11 +284,17 @@ enum DomainReportExporter {
228284 "http_security_grade",
229285 "final_url",
230286 "email_summary",
287 "email_confidence",
231288 "subdomain_count",
289 "subdomain_confidence",
232290 "subdomains",
233291 "open_ports",
234292 "reachability_summary",
235293 "geolocation_summary",
294 "geolocation_confidence",
295 "data_sources",
296 "audit_note",
297 "partial_snapshot",
236298 "change_summary"
237299 ]
238300
@@ -249,9 +311,13 @@ enum DomainReportExporter {
249311 return [
250312 report.domain,
251313 csvDateFormatter.string(from: report.timestamp),
314 report.appVersion,
252315 report.resultSource.rawValue,
316 report.resolverDisplayName,
253317 availabilityLabel(report.availability),
318 report.availabilityConfidence?.rawValue ?? "",
254319 report.ownership?.registrar ?? "",
320 report.ownershipConfidence?.rawValue ?? "",
255321 expirationDate,
256322 nameservers,
257323 report.dns.primaryIP ?? "",
@@ -264,11 +330,17 @@ enum DomainReportExporter {
264330 report.web.securityGrade ?? "",
265331 report.web.finalURL ?? "",
266332 report.email.summary,
333 report.emailConfidence?.rawValue ?? "",
267334 subdomainCount,
335 report.subdomainConfidence?.rawValue ?? "",
268336 subdomains,
269337 openPorts,
270338 report.network.reachabilitySummary,
271339 report.network.geolocationSummary,
340 report.geolocationConfidence?.rawValue ?? "",
341 report.dataSources.joined(separator: " | "),
342 report.auditNote ?? "",
343 report.isPartialSnapshot ? "true" : "false",
272344 report.changeSummary?.message ?? ""
273345 ]
274346 }
@@ -338,6 +410,18 @@ enum DomainReportExporter {
338410 return "Unavailable"
339411 }
340412
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
341425 private static let textDateFormatter: DateFormatter = {
342426 let formatter = DateFormatter()
343427 formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
LookupSnapshot.swift +25 −1
@@ -5,8 +5,20 @@ struct LookupSnapshot {
55 let domain: String
66 let timestamp: Date
77 let trackedDomainID: UUID?
8 let note: String?
9 let appVersion: String
810 let resolverDisplayName: String
911 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]
1022 let totalLookupDurationMs: Int?
1123 let dnsSections: [DNSSection]
1224 let dnsError: String?
@@ -55,8 +67,20 @@ extension HistoryEntry {
5567 domain: domain,
5668 timestamp: timestamp,
5769 trackedDomainID: trackedDomainID,
70 note: note,
71 appVersion: appVersion,
5872 resolverDisplayName: resolverDisplayName,
5973 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,
6084 totalLookupDurationMs: totalLookupDurationMs,
6185 dnsSections: dnsSections,
6286 dnsError: nil,
@@ -89,7 +113,7 @@ extension HistoryEntry {
89113 portScanResults: portScanResults,
90114 portScanError: portScanError,
91115 changeSummary: changeSummary,
92 resultSource: .snapshot,
116 resultSource: resultSource,
93117 cachedSections: [],
94118 statusMessage: nil
95119 )