krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.5.0: DomainInspectionService.swift · raw

  1import Foundation
  2
  3struct DomainInspectionService {
  4    private let reportBuilder = DomainReportBuilder()
  5    private let runtime: LookupRuntime
  6
  7    init(runtime: LookupRuntime = .shared) {
  8        self.runtime = runtime
  9    }
 10
 11    func inspect(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> DomainReport {
 12        let snapshot = await inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot)
 13        return reportBuilder.build(from: snapshot)
 14    }
 15
 16    func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot {
 17        let normalizedDomain = normalize(domain)
 18        let inspectionStartedAt = DomainDebugLog.signpostStart("Inspection.inspectSnapshot", domain: normalizedDomain)
 19        let startedAt = Date()
 20        let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
 21        let resolverURLString = DNSLookupService.currentResolverURLString()
 22        var cachedSections = Set<LookupSectionKind>()
 23        var sectionSources: [LookupResultSource] = []
 24        var provenanceBySection: [LookupSectionKind: SectionProvenance] = [:]
 25        var dataSources = Set<String>()
 26        var errorDetails: [LookupSectionKind: InspectionFailure] = [:]
 27
 28        async let dnsFetch = runtime.dns(domain: normalizedDomain)
 29        async let availabilityFetch = runtime.availability(domain: normalizedDomain)
 30        async let sslFetch = runtime.ssl(domain: normalizedDomain)
 31        async let hstsFetch = runtime.hsts(domain: normalizedDomain)
 32        async let httpFetch = runtime.http(domain: normalizedDomain)
 33        async let ownershipFetch = runtime.ownership(domain: normalizedDomain)
 34        async let redirectFetch = runtime.redirectChain(domain: normalizedDomain)
 35        async let subdomainFetch = runtime.subdomains(domain: normalizedDomain)
 36
 37        let resolvedDNS = await dnsFetch
 38        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=dns source=\(resolvedDNS.source.rawValue)")
 39        let dnsResult = normalizeErrors(in: resolvedDNS.value)
 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)
 50
 51        let availability = await availabilityFetch
 52        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=availability source=\(availability.source.rawValue)")
 53        track(
 54            .availability,
 55            source: availability.source,
 56            provenance: provenance(for: .availability, source: availability.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 57            cachedSections: &cachedSections,
 58            sectionSources: &sectionSources,
 59            provenanceBySection: &provenanceBySection,
 60            dataSources: &dataSources
 61        )
 62
 63        let resolvedSSL = await sslFetch
 64        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ssl source=\(resolvedSSL.source.rawValue)")
 65        let sslResult = normalizeErrors(in: resolvedSSL.value)
 66        track(
 67            .ssl,
 68            source: resolvedSSL.source,
 69            provenance: provenance(for: .ssl, source: resolvedSSL.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 70            cachedSections: &cachedSections,
 71            sectionSources: &sectionSources,
 72            provenanceBySection: &provenanceBySection,
 73            dataSources: &dataSources
 74        )
 75        captureFailure(for: .ssl, result: sslResult, into: &errorDetails)
 76
 77        let hsts = await hstsFetch
 78        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=hsts source=\(hsts.source.rawValue)")
 79        track(
 80            .hsts,
 81            source: hsts.source,
 82            provenance: provenance(for: .hsts, source: hsts.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 83            cachedSections: &cachedSections,
 84            sectionSources: &sectionSources,
 85            provenanceBySection: &provenanceBySection,
 86            dataSources: &dataSources
 87        )
 88
 89        let http = await httpFetch
 90        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=http source=\(http.source.rawValue)")
 91        let httpResult = normalizeErrors(in: http.value)
 92        track(
 93            .httpHeaders,
 94            source: http.source,
 95            provenance: provenance(for: .httpHeaders, source: http.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
 96            cachedSections: &cachedSections,
 97            sectionSources: &sectionSources,
 98            provenanceBySection: &provenanceBySection,
 99            dataSources: &dataSources
100        )
101        captureFailure(for: .httpHeaders, result: httpResult, into: &errorDetails)
102
103        let resolvedOwnership = await ownershipFetch
104        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=ownership source=\(resolvedOwnership.source.rawValue)")
105        let ownershipResult = normalizeErrors(in: resolvedOwnership.value)
106        track(
107            .ownership,
108            source: resolvedOwnership.source,
109            provenance: provenance(for: .ownership, source: resolvedOwnership.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
110            cachedSections: &cachedSections,
111            sectionSources: &sectionSources,
112            provenanceBySection: &provenanceBySection,
113            dataSources: &dataSources
114        )
115        captureFailure(for: .ownership, result: ownershipResult, into: &errorDetails)
116
117        let redirects = await redirectFetch
118        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=redirect source=\(redirects.source.rawValue)")
119        let redirectResult = normalizeErrors(in: redirects.value)
120        track(
121            .redirectChain,
122            source: redirects.source,
123            provenance: provenance(for: .redirectChain, source: redirects.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
124            cachedSections: &cachedSections,
125            sectionSources: &sectionSources,
126            provenanceBySection: &provenanceBySection,
127            dataSources: &dataSources
128        )
129        captureFailure(for: .redirectChain, result: redirectResult, into: &errorDetails)
130
131        let resolvedSubdomains = await subdomainFetch
132        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=subdomains source=\(resolvedSubdomains.source.rawValue)")
133        let subdomainResult = normalizeErrors(in: resolvedSubdomains.value)
134        track(
135            .subdomains,
136            source: resolvedSubdomains.source,
137            provenance: provenance(for: .subdomains, source: resolvedSubdomains.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
138            cachedSections: &cachedSections,
139            sectionSources: &sectionSources,
140            provenanceBySection: &provenanceBySection,
141            dataSources: &dataSources
142        )
143        captureFailure(for: .subdomains, result: subdomainResult, into: &errorDetails)
144
145        let dnsSections = mapServiceResult(dnsResult, emptyValue: [])
146        let sslInfo = mapOptionalValueServiceResult(sslResult)
147        let httpHeadersResult = mapHTTPResult(httpResult)
148        let redirectChain = mapServiceResult(redirectResult, emptyValue: [])
149        let ownership = mapOptionalValueServiceResult(ownershipResult)
150        let subdomains = mapServiceResult(subdomainResult, emptyValue: [])
151
152        let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
153        let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
154        let hasNetworkTarget = dnsSections.value.contains { section in
155            (section.recordType == .A || section.recordType == .AAAA)
156                && (!section.records.isEmpty || !section.wildcardRecords.isEmpty)
157        }
158        let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value)
159        let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP)
160
161        let reachabilityOutcome: CachedLookupResult<ServiceResult<[PortReachability]>>
162        if hasNetworkTarget {
163            reachabilityOutcome = await runtime.reachability(domain: normalizedDomain)
164        } else {
165            reachabilityOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live)
166        }
167        let reachabilityResult = normalizeErrors(in: reachabilityOutcome.value)
168        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=reachability hasNetworkTarget=\(hasNetworkTarget) source=\(reachabilityOutcome.source.rawValue)")
169        track(
170            .reachability,
171            source: reachabilityOutcome.source,
172            provenance: provenance(for: .reachability, source: reachabilityOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
173            cachedSections: &cachedSections,
174            sectionSources: &sectionSources,
175            provenanceBySection: &provenanceBySection,
176            dataSources: &dataSources
177        )
178        if hasNetworkTarget {
179            captureFailure(for: .reachability, result: reachabilityResult, into: &errorDetails)
180        }
181        let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
182
183        let portScanOutcome: CachedLookupResult<ServiceResult<[PortScanResult]>>
184        if hasNetworkTarget {
185            portScanOutcome = await runtime.portScan(domain: normalizedDomain)
186        } else {
187            portScanOutcome = CachedLookupResult(value: .empty("No routable address available"), source: .live)
188        }
189        let portScanResult = normalizeErrors(in: portScanOutcome.value)
190        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=portScan hasNetworkTarget=\(hasNetworkTarget) source=\(portScanOutcome.source.rawValue)")
191        track(
192            .portScan,
193            source: portScanOutcome.source,
194            provenance: provenance(for: .portScan, source: portScanOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
195            cachedSections: &cachedSections,
196            sectionSources: &sectionSources,
197            provenanceBySection: &provenanceBySection,
198            dataSources: &dataSources
199        )
200        if hasNetworkTarget {
201            captureFailure(for: .portScan, result: portScanResult, into: &errorDetails)
202        }
203        let portScanResults = hasNetworkTarget
204            ? await mapPortScanResult(portScanResult, domain: normalizedDomain)
205            : (value: [], message: nil)
206
207        let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>>
208        if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity {
209            emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached)
210        } else if canReuseDNSDependents, let previousSnapshot, let error = previousSnapshot.emailSecurityError {
211            emailOutcome = CachedLookupResult(value: .error(error), source: .cached)
212        } else {
213            emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords)
214        }
215        let normalizedEmailResult = normalizeErrors(in: emailOutcome.value)
216        DomainDebugLog.debug("Inspection.sectionComplete domain=\(normalizedDomain) section=email source=\(emailOutcome.source.rawValue)")
217        track(
218            .emailSecurity,
219            source: emailOutcome.source,
220            provenance: provenance(for: .emailSecurity, source: emailOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
221            cachedSections: &cachedSections,
222            sectionSources: &sectionSources,
223            provenanceBySection: &provenanceBySection,
224            dataSources: &dataSources
225        )
226        captureFailure(for: .emailSecurity, result: normalizedEmailResult, into: &errorDetails)
227
228        let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]>
229        if availability.value.status == .registered {
230            suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain)
231            track(
232                .suggestions,
233                source: suggestionsOutcome.source,
234                provenance: provenance(for: .suggestions, source: suggestionsOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
235                cachedSections: &cachedSections,
236                sectionSources: &sectionSources,
237                provenanceBySection: &provenanceBySection,
238                dataSources: &dataSources
239            )
240        } else {
241            suggestionsOutcome = CachedLookupResult(value: [], source: .live)
242        }
243
244        let ptrOutcome: CachedLookupResult<ServiceResult<String>>?
245        let geoOutcome: CachedLookupResult<ServiceResult<IPGeolocation>>?
246        if let primaryIP {
247            if canReuseIPDependents, let previousSnapshot, let ptrRecord = previousSnapshot.ptrRecord {
248                ptrOutcome = CachedLookupResult(value: .success(ptrRecord), source: .cached)
249            } else if canReuseIPDependents, let previousSnapshot, let ptrError = previousSnapshot.ptrError {
250                ptrOutcome = CachedLookupResult(value: .error(ptrError), source: .cached)
251            } else {
252                ptrOutcome = await runtime.ptr(ip: primaryIP, resolverURLString: resolverURLString)
253            }
254
255            if canReuseIPDependents, let previousSnapshot, let ipGeolocation = previousSnapshot.ipGeolocation {
256                geoOutcome = CachedLookupResult(value: .success(ipGeolocation), source: .cached)
257            } else if canReuseIPDependents, let previousSnapshot, let ipGeolocationError = previousSnapshot.ipGeolocationError {
258                geoOutcome = CachedLookupResult(value: .error(ipGeolocationError), source: .cached)
259            } else {
260                geoOutcome = await runtime.ipGeolocation(ip: primaryIP)
261            }
262
263            if let ptrOutcome {
264                let normalizedPTRResult = normalizeErrors(in: ptrOutcome.value)
265                track(
266                    .ptr,
267                    source: ptrOutcome.source,
268                    provenance: provenance(for: .ptr, source: ptrOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
269                    cachedSections: &cachedSections,
270                    sectionSources: &sectionSources,
271                    provenanceBySection: &provenanceBySection,
272                    dataSources: &dataSources
273                )
274                captureFailure(for: .ptr, result: normalizedPTRResult, into: &errorDetails)
275            }
276            if let geoOutcome {
277                let normalizedGeoResult = normalizeErrors(in: geoOutcome.value)
278                track(
279                    .ipGeolocation,
280                    source: geoOutcome.source,
281                    provenance: provenance(for: .ipGeolocation, source: geoOutcome.source, collectedAt: Date(), resolverDisplayName: resolverDisplayName),
282                    cachedSections: &cachedSections,
283                    sectionSources: &sectionSources,
284                    provenanceBySection: &provenanceBySection,
285                    dataSources: &dataSources
286                )
287                captureFailure(for: .ipGeolocation, result: normalizedGeoResult, into: &errorDetails)
288            }
289        } else {
290            ptrOutcome = nil
291            geoOutcome = nil
292        }
293
294        let emailSecurity = mapOptionalValueServiceResult(normalizedEmailResult)
295        let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
296        let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
297        let availabilityConfidence = confidenceForAvailability(result: availability.value, provenance: provenanceBySection[.availability])
298        let ownershipConfidence = confidenceForOwnership(result: ownership.value, error: ownership.message)
299        let subdomainConfidence = confidenceForSubdomains(results: subdomains.value, error: subdomains.message)
300        let emailConfidence = confidenceForEmail(result: emailSecurity.value, error: emailSecurity.message)
301        let geolocationConfidence = confidenceForGeolocation(result: geolocation.value, error: geolocation.message)
302        let validationIssues = validationIssues(for: normalizedDomain, snapshotTimestamp: startedAt, availability: availability.value, dnsSections: dnsSections.value, provenanceBySection: provenanceBySection)
303
304        let snapshot = LookupSnapshot(
305            historyEntryID: nil,
306            domain: availability.value.domain,
307            timestamp: Date(),
308            trackedDomainID: previousSnapshot?.trackedDomainID,
309            note: previousSnapshot?.note,
310            appVersion: AppVersion.current,
311            resolverDisplayName: resolverDisplayName,
312            resolverURLString: resolverURLString,
313            dataSources: Array(dataSources).sorted(),
314            provenanceBySection: provenanceBySection,
315            availabilityConfidence: availabilityConfidence,
316            ownershipConfidence: ownershipConfidence,
317            subdomainConfidence: subdomainConfidence,
318            emailSecurityConfidence: emailConfidence,
319            geolocationConfidence: geolocationConfidence,
320            errorDetails: errorDetails,
321            isPartialSnapshot: !validationIssues.isEmpty,
322            validationIssues: validationIssues,
323            totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
324            snapshotIndex: nil,
325            previousSnapshotID: previousSnapshot?.historyEntryID,
326            changeCount: 0,
327            severitySummary: nil,
328            dnsSections: dnsSections.value,
329            dnsError: dnsSections.message,
330            availabilityResult: availability.value,
331            suggestions: suggestionsOutcome.value,
332            sslInfo: sslInfo.value,
333            sslError: sslInfo.message,
334            hstsPreloaded: hsts.value,
335            httpHeaders: httpHeadersResult.headers,
336            httpSecurityGrade: httpHeadersResult.securityGrade,
337            httpStatusCode: httpHeadersResult.statusCode,
338            httpResponseTimeMs: httpHeadersResult.responseTimeMs,
339            httpProtocol: httpHeadersResult.httpProtocol,
340            http3Advertised: httpHeadersResult.http3Advertised,
341            httpHeadersError: httpHeadersResult.error,
342            reachabilityResults: reachabilityResultValue.value,
343            reachabilityError: reachabilityResultValue.message,
344            ipGeolocation: geolocation.value,
345            ipGeolocationError: geolocation.message,
346            emailSecurity: emailSecurity.value,
347            emailSecurityError: emailSecurity.message,
348            ownership: ownership.value,
349            ownershipError: ownership.message,
350            ownershipHistory: [],
351            ownershipHistoryError: nil,
352            inferredProvider: nil,
353            priorProviders: [],
354            domainClassification: nil,
355            ownershipTransitions: [],
356            hostingTransitions: [],
357            subdomainHistory: [],
358            riskSignals: [],
359            intelligenceTimeline: [],
360            ptrRecord: ptrRecord.value,
361            ptrError: ptrRecord.message,
362            redirectChain: redirectChain.value,
363            redirectChainError: redirectChain.message,
364            subdomains: subdomains.value,
365            subdomainsError: subdomains.message,
366            extendedSubdomains: [],
367            extendedSubdomainsError: nil,
368            dnsHistory: [],
369            dnsHistoryError: nil,
370            domainPricing: nil,
371            domainPricingError: nil,
372            portScanResults: portScanResults.value,
373            portScanError: portScanResults.message,
374            changeSummary: nil,
375            resultSource: aggregateSource(sectionSources),
376            cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue },
377            statusMessage: nil
378        )
379        DomainDebugLog.signpostEnd(
380            "Inspection.inspectSnapshot",
381            start: inspectionStartedAt,
382            domain: normalizedDomain,
383            extra: "resultSource=\(snapshot.resultSource.rawValue) cachedSections=\(snapshot.cachedSections.count) partial=\(snapshot.isPartialSnapshot)"
384        )
385        return snapshot
386    }
387
388    private func normalize(_ domain: String) -> String {
389        domain
390            .trimmingCharacters(in: .whitespacesAndNewlines)
391            .replacingOccurrences(of: "https://", with: "")
392            .replacingOccurrences(of: "http://", with: "")
393            .components(separatedBy: "/").first?
394            .lowercased() ?? domain.lowercased()
395    }
396
397    private func track(
398        _ section: LookupSectionKind,
399        source: LookupResultSource,
400        provenance: SectionProvenance,
401        cachedSections: inout Set<LookupSectionKind>,
402        sectionSources: inout [LookupResultSource],
403        provenanceBySection: inout [LookupSectionKind: SectionProvenance],
404        dataSources: inout Set<String>
405    ) {
406        sectionSources.append(source)
407        provenanceBySection[section] = provenance
408        dataSources.insert(provenance.provider ?? provenance.source)
409        if source != .live {
410            cachedSections.insert(section)
411        }
412    }
413
414    private func provenance(
415        for section: LookupSectionKind,
416        source: LookupResultSource,
417        collectedAt: Date,
418        resolverDisplayName: String
419    ) -> SectionProvenance {
420        switch section {
421        case .dns, .ptr:
422            return SectionProvenance(
423                source: "DNS-over-HTTPS query",
424                collectedAt: collectedAt,
425                provider: "Selected DoH resolver",
426                resolver: resolverDisplayName,
427                resultSource: source
428            )
429        case .availability:
430            return SectionProvenance(
431                source: "RDAP lookup with DNS fallback",
432                collectedAt: collectedAt,
433                provider: "rdap.org / selected resolver",
434                resolver: resolverDisplayName,
435                resultSource: source
436            )
437        case .ssl:
438            return SectionProvenance(source: "Direct TLS handshake", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
439        case .hsts, .httpHeaders, .redirectChain:
440            return SectionProvenance(source: "HTTP request", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
441        case .reachability:
442            return SectionProvenance(source: "TCP reachability probe", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
443        case .ipGeolocation:
444            return SectionProvenance(source: "IP geolocation lookup", collectedAt: collectedAt, provider: "ipapi.co", resolver: nil, resultSource: source)
445        case .emailSecurity:
446            return SectionProvenance(source: "DNS TXT inspection", collectedAt: collectedAt, provider: "Selected DoH resolver", resolver: resolverDisplayName, resultSource: source)
447        case .ownership:
448            return SectionProvenance(source: "RDAP domain lookup", collectedAt: collectedAt, provider: "rdap.org", resolver: nil, resultSource: source)
449        case .subdomains:
450            return SectionProvenance(source: "Certificate transparency search", collectedAt: collectedAt, provider: "crt.sh", resolver: nil, resultSource: source)
451        case .portScan:
452            return SectionProvenance(source: "TCP port scan", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
453        case .suggestions:
454            return SectionProvenance(source: "Availability suggestions", collectedAt: collectedAt, provider: "DomainDig heuristic", resolver: resolverDisplayName, resultSource: source)
455        }
456    }
457
458    private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource {
459        let normalizedSources = sectionSources.map { source -> LookupResultSource in
460            source == .mixed ? .cached : source
461        }
462
463        let hasLive = normalizedSources.contains(.live)
464        let hasCached = normalizedSources.contains(.cached)
465
466        switch (hasLive, hasCached) {
467        case (true, true):
468            return .mixed
469        case (false, true):
470            return .cached
471        default:
472            return .live
473        }
474    }
475
476    private func canReuseDependentSections(from previousSnapshot: LookupSnapshot?, dnsSections: [DNSSection]) -> Bool {
477        guard let previousSnapshot else { return false }
478        return dnsSignature(for: previousSnapshot.dnsSections) == dnsSignature(for: dnsSections)
479    }
480
481    private func canReuseIPBasedSections(from previousSnapshot: LookupSnapshot?, primaryIP: String?) -> Bool {
482        guard let previousSnapshot else { return false }
483        let previousIP = previousSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
484        return primaryIP == previousIP
485    }
486
487    private func dnsSignature(for sections: [DNSSection]) -> String {
488        sections
489            .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
490            .map { section in
491                let records = section.records
492                    .sorted { $0.value < $1.value }
493                    .map { "\($0.value)|\($0.ttl)" }
494                    .joined(separator: ",")
495                let wildcardRecords = section.wildcardRecords
496                    .sorted { $0.value < $1.value }
497                    .map { "\($0.value)|\($0.ttl)" }
498                    .joined(separator: ",")
499                return [
500                    section.recordType.rawValue,
501                    records,
502                    wildcardRecords,
503                    section.dnssecSigned.map { $0 ? "signed" : "unsigned" } ?? "unknown",
504                    section.error ?? ""
505                ].joined(separator: "#")
506            }
507            .joined(separator: "||")
508    }
509
510    private func normalizeErrors<Value>(in result: ServiceResult<Value>) -> ServiceResult<Value> {
511        switch result {
512        case let .success(value):
513            return .success(value)
514        case let .empty(message):
515            return .empty(classifyFailure(from: message, defaultKind: .unavailable).message)
516        case let .error(message):
517            return .error(classifyFailure(from: message).message)
518        }
519    }
520
521    private func classifyFailure(from message: String, defaultKind: InspectionErrorKind = .unknown) -> InspectionFailure {
522        let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
523        let lowercasedMessage = normalizedMessage.lowercased()
524        if lowercasedMessage.contains("timed out") {
525            return InspectionFailure(kind: .timeout, message: "Timed out", details: normalizedMessage)
526        }
527        if lowercasedMessage.contains("429")
528            || lowercasedMessage.contains("too many requests")
529            || lowercasedMessage.contains("rate limit") {
530            return InspectionFailure(kind: .rateLimited, message: "Rate limited", details: normalizedMessage)
531        }
532        if lowercasedMessage.contains("cannot parse")
533            || lowercasedMessage.contains("decoding")
534            || lowercasedMessage.contains("json") {
535            return InspectionFailure(kind: .parsing, message: "Could not parse response", details: normalizedMessage)
536        }
537        if lowercasedMessage.contains("offline")
538            || lowercasedMessage.contains("internet connection")
539            || lowercasedMessage.contains("not connected")
540            || lowercasedMessage.contains("network connection") {
541            return InspectionFailure(kind: .network, message: "Network unavailable", details: normalizedMessage)
542        }
543        if lowercasedMessage.contains("unsupported") {
544            return InspectionFailure(kind: .unsupported, message: "Unsupported for this target", details: normalizedMessage)
545        }
546        if lowercasedMessage == "unavailable" || lowercasedMessage.contains("no a record available") {
547            return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil)
548        }
549        if defaultKind == .unavailable {
550            return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil)
551        }
552        return InspectionFailure(kind: .unknown, message: normalizedMessage.isEmpty ? defaultKind.title : normalizedMessage, details: normalizedMessage)
553    }
554
555    private func captureFailure<Value>(
556        for section: LookupSectionKind,
557        result: ServiceResult<Value>,
558        into errorDetails: inout [LookupSectionKind: InspectionFailure]
559    ) {
560        switch result {
561        case .success:
562            return
563        case let .empty(message):
564            errorDetails[section] = classifyFailure(from: message, defaultKind: .unavailable)
565        case let .error(message):
566            errorDetails[section] = classifyFailure(from: message)
567        }
568    }
569
570    private func confidenceForAvailability(result: DomainAvailabilityResult, provenance: SectionProvenance?) -> ConfidenceLevel {
571        guard result.status != .unknown else { return .low }
572        if provenance?.provider?.localizedCaseInsensitiveContains("rdap.org") == true, result.status == .registered {
573            return .high
574        }
575        if result.status == .registered {
576            return .medium
577        }
578        return .low
579    }
580
581    private func confidenceForOwnership(result: DomainOwnership?, error: String?) -> ConfidenceLevel {
582        guard let result else { return error == nil ? .low : .low }
583        let hasDirectRegistrationData = result.registrar != nil || result.createdDate != nil || result.expirationDate != nil
584        return hasDirectRegistrationData ? .high : .medium
585    }
586
587    private func confidenceForSubdomains(results: [DiscoveredSubdomain], error: String?) -> ConfidenceLevel {
588        if !results.isEmpty {
589            return .medium
590        }
591        return error == nil ? .low : .low
592    }
593
594    private func confidenceForEmail(result: EmailSecurityResult?, error: String?) -> ConfidenceLevel {
595        guard let result else { return error == nil ? .low : .low }
596        let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found, result.bimi.found, result.mtaSts?.txtFound == true]
597            .filter { $0 }
598            .count
599        if foundCount >= 3 {
600            return .high
601        }
602        if foundCount >= 1 {
603            return .medium
604        }
605        return .low
606    }
607
608    private func confidenceForGeolocation(result: IPGeolocation?, error: String?) -> ConfidenceLevel {
609        guard let result else { return error == nil ? .low : .low }
610        if result.city != nil && result.country_name != nil && result.latitude != nil && result.longitude != nil {
611            return .high
612        }
613        if result.country_name != nil || result.org != nil {
614            return .medium
615        }
616        return .low
617    }
618
619    private func validationIssues(
620        for domain: String,
621        snapshotTimestamp: Date,
622        availability: DomainAvailabilityResult,
623        dnsSections: [DNSSection],
624        provenanceBySection: [LookupSectionKind: SectionProvenance]
625    ) -> [String] {
626        var issues: [String] = []
627        if domain.isEmpty {
628            issues.append("Missing normalized domain")
629        }
630        if availability.domain.isEmpty {
631            issues.append("Missing normalized availability domain")
632        }
633        if dnsSections.isEmpty && provenanceBySection[.dns] == nil {
634            issues.append("Missing DNS provenance")
635        }
636        if snapshotTimestamp > Date().addingTimeInterval(5) {
637            issues.append("Snapshot timestamp is in the future")
638        }
639        return issues
640    }
641
642    private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
643        switch result {
644        case let .success(value):
645            return (value, nil)
646        case let .empty(message), let .error(message):
647            return (emptyValue, message)
648        }
649    }
650
651    private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) {
652        switch result {
653        case let .success(value):
654            return (value, nil)
655        case let .empty(message), let .error(message):
656            return (nil, message)
657        }
658    }
659
660    private func mapOptionalServiceResult<Value>(
661        _ result: ServiceResult<Value>?,
662        missingMessage: String
663    ) -> (value: Value?, message: String?) {
664        guard let result else {
665            return (nil, missingMessage)
666        }
667
668        switch result {
669        case let .success(value):
670            return (value, nil)
671        case let .empty(message), let .error(message):
672            return (nil, message)
673        }
674    }
675
676    private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) {
677        switch result {
678        case let .success(results):
679            return (await enrichOpenPortBanners(in: results, domain: domain), nil)
680        case let .empty(message), let .error(message):
681            return ([], message)
682        }
683    }
684
685    private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> (
686        headers: [HTTPHeader],
687        securityGrade: String?,
688        statusCode: Int?,
689        responseTimeMs: Int?,
690        httpProtocol: String?,
691        http3Advertised: Bool,
692        error: String?
693    ) {
694        switch result {
695        case let .success(value):
696            return (
697                headers: value.headers,
698                securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue,
699                statusCode: value.statusCode,
700                responseTimeMs: value.responseTimeMs,
701                httpProtocol: value.httpProtocol,
702                http3Advertised: value.http3Advertised,
703                error: nil
704            )
705        case let .empty(message), let .error(message):
706            return (
707                headers: [],
708                securityGrade: nil,
709                statusCode: nil,
710                responseTimeMs: nil,
711                httpProtocol: nil,
712                http3Advertised: false,
713                error: message
714            )
715        }
716    }
717
718    private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
719        let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
720            for result in results where result.open {
721                group.addTask {
722                    let banner = await PortScanService.grabBanner(host: domain, port: result.port)
723                    return (result.port, banner)
724                }
725            }
726
727            var collected: [UInt16: String] = [:]
728            for await (port, banner) in group {
729                if let banner {
730                    collected[port] = banner
731                }
732            }
733            return collected
734        }
735
736        return results.map { result in
737            var updated = result
738            updated.banner = banners[result.port]
739            return updated
740        }
741    }
742}