krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.4.1: DomainReportBuilder.swift · raw

  1import Foundation
  2
  3struct DomainReport: Codable {
  4    let domain: String
  5    let timestamp: Date
  6    let provenance: DomainReportProvenance
  7    let appVersion: String
  8    let resolverDisplayName: String
  9    let resolverURLString: String
 10    let dataSources: [String]
 11    let resultSource: LookupResultSource
 12    let sectionProvenance: [LookupSectionKind: SectionProvenance]
 13    let errorDetails: [LookupSectionKind: InspectionFailure]
 14    let isPartialSnapshot: Bool
 15    let validationIssues: [String]
 16    let auditNote: String?
 17    let availability: DomainAvailabilityStatus
 18    let availabilityConfidence: ConfidenceLevel?
 19    let ownershipConfidence: ConfidenceLevel?
 20    let subdomainConfidence: ConfidenceLevel?
 21    let emailConfidence: ConfidenceLevel?
 22    let geolocationConfidence: ConfidenceLevel?
 23    let ownership: DomainOwnership?
 24    let ownershipHistory: [DomainOwnershipHistoryEvent]
 25    let inferredProvider: InferredProviderFingerprint?
 26    let priorProviders: [String]
 27    let domainClassification: DomainClassificationSummary?
 28    let ownershipTransitions: [OwnershipTransitionEvent]
 29    let hostingTransitions: [HostingTransitionEvent]
 30    let subdomainHistory: [SubdomainHistoryEntry]
 31    let riskSignals: [IntelligenceRiskSignal]
 32    let intelligenceTimeline: [IntelligenceTimelineEvent]
 33    let dns: DNSResultSummary
 34    let web: WebResultSummary
 35    let email: EmailSecuritySummary
 36    let network: NetworkSummary
 37    let subdomains: [String]
 38    let extendedSubdomains: [String]
 39    let dnsHistory: [DNSHistoryEvent]
 40    let domainPricing: DomainPricingInsight?
 41    let subdomainGroups: [SubdomainGroup]
 42    let riskAssessment: DomainRiskAssessment
 43    let insights: [String]
 44    let changeSummary: DomainChangeSummary?
 45    let lastChangeDate: Date?
 46    let health: DomainHealth
 47    let lastMonitoringFailure: Date?
 48    let instabilityScore: Int
 49    let certificateExpiryState: CertificateWarningLevel
 50    let workflowContext: DomainWorkflowContext?
 51    let metadata: DomainReportMetadata
 52}
 53
 54struct DomainReportProvenance: Codable {
 55    let collectedAt: Date
 56    let source: LookupResultSource
 57    let sections: [LookupSectionKind: SectionProvenance]
 58    let dataSources: [String]
 59}
 60
 61struct DomainWorkflowContext: Codable {
 62    let workflowID: UUID?
 63    let workflowName: String?
 64    let source: String
 65}
 66
 67struct DomainReportMetadata: Codable {
 68    let schemaVersion: String
 69    let resolverDisplayName: String
 70    let resolverURLString: String
 71    let appVersion: String
 72    let cachedSections: [LookupSectionKind]
 73    let auditNote: String?
 74    let validationIssues: [String]
 75    let isPartialSnapshot: Bool
 76    let errorDetails: [LookupSectionKind: InspectionFailure]
 77    let statusMessage: String?
 78    let snapshotIndex: Int?
 79    let previousSnapshotID: UUID?
 80    let changeCount: Int
 81    let severitySummary: ChangeSeverity?
 82}
 83
 84struct DNSResultSummary: Codable {
 85    let resolverDisplayName: String
 86    let resolverURLString: String
 87    let lookupDurationMs: Int?
 88    let recordSections: [DNSSection]
 89    let primaryIP: String?
 90    let ptrRecord: String?
 91    let dnssecSigned: Bool?
 92    let patternSummary: DNSPatternSummary
 93    let error: String?
 94    let ptrError: String?
 95}
 96
 97struct WebResultSummary: Codable {
 98    let tls: SSLCertificateInfo?
 99    let tlsStatus: String
100    let tlsGrade: TLSGrade
101    let tlsHighlights: [String]
102    let certificateWarningLevel: CertificateWarningLevel
103    let hstsPreloaded: Bool?
104    let headers: [HTTPHeader]
105    let headerCount: Int
106    let securityGrade: String?
107    let statusCode: Int?
108    let responseTimeMs: Int?
109    let httpProtocol: String?
110    let http3Advertised: Bool
111    let redirectChain: [RedirectHop]
112    let finalURL: String?
113    let tlsError: String?
114    let headersError: String?
115    let redirectError: String?
116}
117
118struct EmailSecuritySummary: Codable {
119    let records: EmailSecurityResult?
120    let grade: EmailSecurityGrade?
121    let reasons: [String]
122    let summary: String
123    let error: String?
124}
125
126struct NetworkSummary: Codable {
127    let primaryIP: String?
128    let reachability: [PortReachability]
129    let reachabilitySummary: String
130    let reachabilityError: String?
131    let geolocation: IPGeolocation?
132    let geolocationSummary: String
133    let geolocationError: String?
134    let portScan: [PortScanResult]
135    let openPorts: [UInt16]
136    let portScanError: String?
137}
138
139struct DomainReportBuilder {
140    func build(
141        from snapshot: LookupSnapshot,
142        previousSnapshot: LookupSnapshot? = nil,
143        workflowContext: DomainWorkflowContext? = nil,
144        historyEntries: [HistoryEntry] = [],
145        deriveChangeSummary: Bool = true
146    ) -> DomainReport {
147        let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain)
148        let primaryIP = primaryIPAddress(from: snapshot)
149        let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
150        let changeSummary: DomainChangeSummary?
151        if let existingChangeSummary = snapshot.changeSummary, existingChangeSummary.riskAssessment != nil {
152            changeSummary = existingChangeSummary
153        } else if deriveChangeSummary, let previousSnapshot {
154            let previousReport = build(
155                from: previousSnapshot,
156                workflowContext: workflowContext,
157                historyEntries: historyEntries,
158                deriveChangeSummary: false
159            )
160            let currentReport = buildBaseReport(
161                from: snapshot,
162                previousSnapshot: previousSnapshot,
163                workflowContext: workflowContext,
164                historyEntries: historyEntries,
165                analysis: analysis,
166                primaryIP: primaryIP,
167                changeSummary: nil as DomainChangeSummary?
168            )
169            let diff = DiffService.compare(from: previousReport, to: currentReport)
170            let changedItems = diff.sections.flatMap { $0.items }.filter { $0.hasChanges }
171            let highlights = diff.changedSectionTitles
172            let severity = changedItems.map { $0.severity }.max() ?? .low
173            let message = DiffService.summaryMessage(from: highlights, changeCount: changedItems.count)
174            let observedFacts = changedItems.prefix(4).map { item in
175                "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
176            }
177            let previousRiskScore = previousReport.riskAssessment.score
178            let riskScoreDelta = analysis.riskAssessment.score - previousRiskScore
179            let impactClassification = DomainInsightEngine.impactClassification(
180                severity: severity,
181                riskDelta: riskScoreDelta,
182                changedSections: highlights
183            )
184
185            changeSummary = DomainChangeSummary(
186                hasChanges: !changedItems.isEmpty,
187                changedSections: highlights,
188                message: message,
189                severity: severity,
190                impactClassification: impactClassification,
191                generatedAt: snapshot.timestamp,
192                observedFacts: observedFacts,
193                inferredConclusions: highlights.isEmpty ? [] : [message],
194                contextNote: diff.contextNote,
195                riskAssessment: analysis.riskAssessment,
196                insights: analysis.insights,
197                riskScoreDelta: riskScoreDelta
198            )
199        } else {
200            changeSummary = nil
201        }
202
203        let report = buildBaseReport(
204            from: snapshot,
205            previousSnapshot: previousSnapshot,
206            workflowContext: workflowContext,
207            historyEntries: historyEntries,
208            analysis: analysis,
209            primaryIP: primaryIP,
210            changeSummary: changeSummary
211        )
212        DomainDebugLog.signpostEnd(
213            "DomainReportBuilder.build",
214            start: buildStartedAt,
215            domain: snapshot.domain,
216            extra: "risk=\(report.riskAssessment.score) insights=\(report.insights.count)"
217        )
218        return report
219    }
220
221    func build(
222        from entry: HistoryEntry,
223        previousSnapshot: LookupSnapshot? = nil,
224        workflowContext: DomainWorkflowContext? = nil,
225        historyEntries: [HistoryEntry] = [],
226        deriveChangeSummary: Bool = true
227    ) -> DomainReport {
228        build(
229            from: entry.snapshot,
230            previousSnapshot: previousSnapshot,
231            workflowContext: workflowContext,
232            historyEntries: historyEntries,
233            deriveChangeSummary: deriveChangeSummary
234        )
235    }
236
237    private func buildBaseReport(
238        from snapshot: LookupSnapshot,
239        previousSnapshot: LookupSnapshot?,
240        workflowContext: DomainWorkflowContext?,
241        historyEntries: [HistoryEntry],
242        analysis: DomainAnalysisBundle,
243        primaryIP: String?,
244        changeSummary: DomainChangeSummary?
245    ) -> DomainReport {
246        let intelligence = DomainIntelligenceService.derive(
247            snapshot: snapshot,
248            previousSnapshot: previousSnapshot,
249            historyEntries: historyEntries
250        )
251        let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot)
252        let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0
253        let instabilityScore = DomainHealth.instabilityScore(
254            recentChangeCount: recentChangeCount,
255            recentFailureCount: 0,
256            pendingAlertCount: 0,
257            hasRecentDNSChange: changeSummary?.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) == true
258        )
259        let hasInvalidTLS = snapshot.sslInfo == nil && snapshot.sslError != nil
260        let isReachable = !snapshot.reachabilityResults.isEmpty
261            ? snapshot.reachabilityResults.contains(where: \.reachable)
262            : snapshot.reachabilityError == nil
263        let health = DomainHealth.classify(
264            certificateExpiryState: certificateExpiryState,
265            isReachable: isReachable,
266            recentMonitoringFailureCount: 0,
267            hasRecentDNSChange: changeSummary?.changedSections.contains(where: { $0.localizedCaseInsensitiveContains("dns") }) == true,
268            instabilityScore: instabilityScore,
269            hasRecentCriticalChange: changeSummary?.impactClassification == .critical,
270            hasInvalidTLS: hasInvalidTLS
271        )
272
273        return DomainReport(
274            domain: snapshot.domain,
275            timestamp: snapshot.timestamp,
276            provenance: DomainReportProvenance(
277                collectedAt: snapshot.timestamp,
278                source: snapshot.resultSource,
279                sections: snapshot.provenanceBySection,
280                dataSources: snapshot.dataSources
281            ),
282            appVersion: snapshot.appVersion,
283            resolverDisplayName: snapshot.resolverDisplayName,
284            resolverURLString: snapshot.resolverURLString,
285            dataSources: snapshot.dataSources,
286            resultSource: snapshot.resultSource,
287            sectionProvenance: snapshot.provenanceBySection,
288            errorDetails: snapshot.errorDetails,
289            isPartialSnapshot: snapshot.isPartialSnapshot,
290            validationIssues: snapshot.validationIssues,
291            auditNote: snapshot.note,
292            availability: snapshot.availabilityResult?.status ?? .unknown,
293            availabilityConfidence: snapshot.availabilityConfidence,
294            ownershipConfidence: snapshot.ownershipConfidence,
295            subdomainConfidence: snapshot.subdomainConfidence,
296            emailConfidence: snapshot.emailSecurityConfidence,
297            geolocationConfidence: snapshot.geolocationConfidence,
298            ownership: snapshot.ownership,
299            ownershipHistory: snapshot.ownershipHistory,
300            inferredProvider: intelligence.inferredProvider,
301            priorProviders: intelligence.priorProviders,
302            domainClassification: intelligence.domainClassification,
303            ownershipTransitions: intelligence.ownershipTransitions,
304            hostingTransitions: intelligence.hostingTransitions,
305            subdomainHistory: intelligence.subdomainHistory,
306            riskSignals: intelligence.riskSignals,
307            intelligenceTimeline: intelligence.timelineEvents,
308            dns: DNSResultSummary(
309                resolverDisplayName: snapshot.resolverDisplayName,
310                resolverURLString: snapshot.resolverURLString,
311                lookupDurationMs: snapshot.totalLookupDurationMs,
312                recordSections: snapshot.dnsSections,
313                primaryIP: primaryIP,
314                ptrRecord: snapshot.ptrRecord,
315                dnssecSigned: dnssecSigned(from: snapshot),
316                patternSummary: analysis.dnsPatterns,
317                error: snapshot.dnsError,
318                ptrError: snapshot.ptrError
319            ),
320            web: WebResultSummary(
321                tls: snapshot.sslInfo,
322                tlsStatus: tlsStatus(from: snapshot),
323                tlsGrade: analysis.tlsAssessment.grade,
324                tlsHighlights: analysis.tlsAssessment.highlights,
325                certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
326                hstsPreloaded: snapshot.hstsPreloaded,
327                headers: snapshot.httpHeaders,
328                headerCount: snapshot.httpHeaders.count,
329                securityGrade: snapshot.httpSecurityGrade,
330                statusCode: snapshot.httpStatusCode,
331                responseTimeMs: snapshot.httpResponseTimeMs,
332                httpProtocol: snapshot.httpProtocol,
333                http3Advertised: snapshot.http3Advertised,
334                redirectChain: snapshot.redirectChain,
335                finalURL: snapshot.redirectChain.last?.url,
336                tlsError: snapshot.sslError,
337                headersError: snapshot.httpHeadersError,
338                redirectError: snapshot.redirectChainError
339            ),
340            email: EmailSecuritySummary(
341                records: snapshot.emailSecurity,
342                grade: analysis.emailAssessment?.grade,
343                reasons: analysis.emailAssessment?.reasons ?? [],
344                summary: emailSummary(from: snapshot, assessment: analysis.emailAssessment),
345                error: snapshot.emailSecurityError
346            ),
347            network: NetworkSummary(
348                primaryIP: primaryIP,
349                reachability: snapshot.reachabilityResults,
350                reachabilitySummary: reachabilitySummary(from: snapshot),
351                reachabilityError: snapshot.reachabilityError,
352                geolocation: snapshot.ipGeolocation,
353                geolocationSummary: geolocationSummary(from: snapshot),
354                geolocationError: snapshot.ipGeolocationError,
355                portScan: snapshot.portScanResults,
356                openPorts: snapshot.portScanResults.filter(\.open).map(\.port),
357                portScanError: snapshot.portScanError
358            ),
359            subdomains: snapshot.subdomains.map(\.hostname),
360            extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname),
361            dnsHistory: snapshot.dnsHistory,
362            domainPricing: snapshot.domainPricing,
363            subdomainGroups: analysis.subdomainGroups,
364            riskAssessment: analysis.riskAssessment,
365            insights: analysis.insights,
366            changeSummary: changeSummary,
367            lastChangeDate: changeSummary?.hasChanges == true ? snapshot.timestamp : nil,
368            health: health,
369            lastMonitoringFailure: nil,
370            instabilityScore: instabilityScore,
371            certificateExpiryState: certificateExpiryState,
372            workflowContext: workflowContext,
373            metadata: DomainReportMetadata(
374                schemaVersion: "4.3.0",
375                resolverDisplayName: snapshot.resolverDisplayName,
376                resolverURLString: snapshot.resolverURLString,
377                appVersion: snapshot.appVersion,
378                cachedSections: snapshot.cachedSections,
379                auditNote: snapshot.note,
380                validationIssues: snapshot.validationIssues,
381                isPartialSnapshot: snapshot.isPartialSnapshot,
382                errorDetails: snapshot.errorDetails,
383                statusMessage: snapshot.statusMessage,
384                snapshotIndex: snapshot.snapshotIndex,
385                previousSnapshotID: snapshot.previousSnapshotID ?? previousSnapshot?.historyEntryID,
386                changeCount: snapshot.changeCount == 0 ? (changeSummary?.changedSections.count ?? 0) : snapshot.changeCount,
387                severitySummary: snapshot.severitySummary ?? changeSummary?.severity
388            )
389        )
390    }
391
392    private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
393        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
394    }
395
396    private func dnssecSigned(from snapshot: LookupSnapshot) -> Bool? {
397        snapshot.dnsSections.compactMap(\.dnssecSigned).first
398    }
399
400    private func tlsStatus(from snapshot: LookupSnapshot) -> String {
401        if snapshot.sslInfo != nil {
402            return "valid"
403        }
404        if let sslError = snapshot.sslError {
405            return sslError.localizedCaseInsensitiveContains("certificate") ? "invalid" : "failed"
406        }
407        return "unavailable"
408    }
409
410    private func emailSummary(from snapshot: LookupSnapshot, assessment: EmailSecurityAssessment?) -> String {
411        guard let emailSecurity = snapshot.emailSecurity else {
412            return snapshot.emailSecurityError ?? "Unavailable"
413        }
414
415        return [
416            "Grade \(assessment?.grade.rawValue ?? "?")",
417            "SPF \(emailSecurity.spf.found ? "Yes" : "No")",
418            "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")",
419            "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")",
420            "BIMI \(emailSecurity.bimi.found ? "Yes" : "No")",
421            "MTA-STS \(emailSecurity.mtaSts?.txtFound == true ? "Yes" : "No")"
422        ].joined(separator: " / ")
423    }
424
425    private func reachabilitySummary(from snapshot: LookupSnapshot) -> String {
426        guard !snapshot.reachabilityResults.isEmpty else {
427            return snapshot.reachabilityError ?? "Unavailable"
428        }
429
430        return snapshot.reachabilityResults
431            .sorted { $0.port < $1.port }
432            .map { "\($0.port):\($0.reachable ? "open" : "closed")" }
433            .joined(separator: ", ")
434    }
435
436    private func geolocationSummary(from snapshot: LookupSnapshot) -> String {
437        guard let geolocation = snapshot.ipGeolocation else {
438            return snapshot.ipGeolocationError ?? "Unavailable"
439        }
440
441        let parts = [geolocation.city, geolocation.region, geolocation.country_name]
442            .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
443            .filter { !$0.isEmpty }
444
445        if !parts.isEmpty {
446            return parts.joined(separator: ", ")
447        }
448
449        return geolocation.ip
450    }
451}
452
453struct DerivedDomainIntelligence {
454    let inferredProvider: InferredProviderFingerprint?
455    let priorProviders: [String]
456    let domainClassification: DomainClassificationSummary?
457    let ownershipTransitions: [OwnershipTransitionEvent]
458    let hostingTransitions: [HostingTransitionEvent]
459    let subdomainHistory: [SubdomainHistoryEntry]
460    let riskSignals: [IntelligenceRiskSignal]
461    let timelineEvents: [IntelligenceTimelineEvent]
462}
463
464enum DomainIntelligenceService {
465    static func derive(
466        snapshot: LookupSnapshot,
467        previousSnapshot: LookupSnapshot? = nil,
468        historyEntries: [HistoryEntry]
469    ) -> DerivedDomainIntelligence {
470        let orderedHistory = historyEntries
471            .filter { $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame }
472            .sorted { $0.timestamp < $1.timestamp }
473        let observationSnapshots = mergeObservations(historyEntries: orderedHistory, currentSnapshot: snapshot)
474        let providerObservations = observationSnapshots.compactMap { observation -> (Date, InferredProviderFingerprint)? in
475            inferProvider(from: observation).map { (observation.timestamp, $0) }
476        }
477        let currentProvider = inferProvider(from: snapshot)
478        let priorProviders = Array(Set(providerObservations.dropLast().map { $0.1.name })).sorted()
479        let ownershipTransitions = ownershipTransitions(from: observationSnapshots)
480        let hostingTransitions = hostingTransitions(from: providerObservations)
481        let currentClassification = classify(snapshot: snapshot)
482        let subdomainHistory = buildSubdomainHistory(from: observationSnapshots)
483        let riskSignals = buildRiskSignals(
484            snapshot: snapshot,
485            previousSnapshot: previousSnapshot,
486            ownershipTransitions: ownershipTransitions,
487            hostingTransitions: hostingTransitions,
488            subdomainHistory: subdomainHistory
489        )
490        let timelineEvents = buildTimelineEvents(
491            snapshot: snapshot,
492            observations: observationSnapshots,
493            providerObservations: providerObservations,
494            ownershipTransitions: ownershipTransitions,
495            hostingTransitions: hostingTransitions,
496            riskSignals: riskSignals
497        )
498        return DerivedDomainIntelligence(
499            inferredProvider: currentProvider,
500            priorProviders: priorProviders,
501            domainClassification: currentClassification,
502            ownershipTransitions: ownershipTransitions,
503            hostingTransitions: hostingTransitions,
504            subdomainHistory: subdomainHistory,
505            riskSignals: riskSignals,
506            timelineEvents: timelineEvents
507        )
508    }
509
510    private static func mergeObservations(historyEntries: [HistoryEntry], currentSnapshot: LookupSnapshot) -> [LookupSnapshot] {
511        var snapshots = historyEntries.map(\.snapshot)
512        let alreadyIncluded = snapshots.contains {
513            $0.timestamp == currentSnapshot.timestamp && $0.domain.caseInsensitiveCompare(currentSnapshot.domain) == .orderedSame
514        }
515        if !alreadyIncluded {
516            snapshots.append(currentSnapshot)
517        }
518        return snapshots.sorted { $0.timestamp < $1.timestamp }
519    }
520
521    private static func inferProvider(from snapshot: LookupSnapshot) -> InferredProviderFingerprint? {
522        let headerMap = Dictionary(uniqueKeysWithValues: snapshot.httpHeaders.map { ($0.name.lowercased(), $0.value.lowercased()) })
523        let dnsProviders = dnsValues(for: .NS, in: snapshot.dnsSections) + dnsValues(for: .CNAME, in: snapshot.dnsSections)
524        let issuer = snapshot.sslInfo?.issuer.lowercased() ?? ""
525        let org = snapshot.ipGeolocation?.org?.lowercased() ?? ""
526
527        if headerMap["cf-ray"] != nil || containsAny(in: dnsProviders, matching: ["cloudflare"]) || issuer.contains("cloudflare") {
528            return provider("Cloudflare", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["cf-ray", "cloudflare"]))
529        }
530        if headerMap["x-vercel-id"] != nil || containsAny(in: dnsProviders, matching: ["vercel"]) {
531            return provider("Vercel", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-vercel-id", "vercel"]))
532        }
533        if containsHeaderValue(headerMap, value: "netlify") || containsAny(in: dnsProviders, matching: ["netlify"]) {
534            return provider("Netlify", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["netlify"]))
535        }
536        if containsHeaderValue(headerMap, value: "fastly") || headerMap["x-served-by"]?.contains("cache") == true {
537            return provider("Fastly", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["fastly", "x-served-by"]))
538        }
539        if headerMap["x-amz-cf-id"] != nil || containsHeaderValue(headerMap, value: "cloudfront") || containsAny(in: dnsProviders, matching: ["cloudfront.net"]) {
540            return provider("CloudFront", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-amz-cf-id", "cloudfront"]))
541        }
542        if containsAny(in: dnsProviders, matching: ["awsdns", "amazonaws.com"]) || org.contains("amazon") {
543            return provider("AWS", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["awsdns", "amazon"]))
544        }
545        if containsAny(in: dnsProviders, matching: ["github.io"]) || containsHeaderValue(headerMap, value: "github") {
546            return provider("GitHub Pages", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["github"]))
547        }
548        return nil
549    }
550
551    private static func classify(snapshot: LookupSnapshot) -> DomainClassificationSummary? {
552        let host = snapshot.domain.lowercased()
553        let headerValues = snapshot.httpHeaders.map { "\($0.name.lowercased()):\($0.value.lowercased())" }
554        let finalURL = snapshot.redirectChain.last?.url.lowercased() ?? ""
555
556        if host.hasPrefix("api.") || host.contains(".api.") {
557            return .init(kind: .api, confidence: .high, reasons: ["Hostname pattern"])
558        }
559        if containsAny(in: [host, finalURL], matching: ["auth", "login", "sso", "oauth"]) {
560            return .init(kind: .auth, confidence: .high, reasons: ["Auth-oriented host or redirect"])
561        }
562        if containsAny(in: [host, finalURL], matching: ["docs", "developer", "developers", "help"]) {
563            return .init(kind: .docs, confidence: .high, reasons: ["Docs-oriented host or redirect"])
564        }
565        if containsAny(in: [host, finalURL], matching: ["status", "statuspage", "health"]) {
566            return .init(kind: .status, confidence: .medium, reasons: ["Status-oriented host or redirect"])
567        }
568        if containsAny(in: [host], matching: ["cdn.", "static.", "assets.", "img."]) {
569            return .init(kind: .staticSite, confidence: .medium, reasons: ["Static asset hostname"])
570        }
571        if containsAny(in: [host], matching: ["app.", "portal.", "admin.", "dashboard."]) {
572            return .init(kind: .app, confidence: .medium, reasons: ["Application hostname"])
573        }
574        if containsAny(in: [host], matching: ["vpn.", "internal.", "infra."]) || headerValues.contains(where: { $0.contains("x-envoy") }) {
575            return .init(kind: .infrastructure, confidence: .medium, reasons: ["Infrastructure-oriented hostname or headers"])
576        }
577        if host == apexDomain(for: host) || host.hasPrefix("www.") {
578            return .init(kind: .marketing, confidence: .low, reasons: ["Apex or www host"])
579        }
580        return nil
581    }
582
583    private static func ownershipTransitions(from snapshots: [LookupSnapshot]) -> [OwnershipTransitionEvent] {
584        zip(snapshots, snapshots.dropFirst()).compactMap { previous, current in
585            guard let previousOwnership = previous.ownership, let currentOwnership = current.ownership else {
586                return nil
587            }
588            var changeParts: [String] = []
589            if previousOwnership.registrar != currentOwnership.registrar {
590                changeParts.append("registrar")
591            }
592            if previousOwnership.registrant != currentOwnership.registrant {
593                changeParts.append("ownership")
594            }
595            if normalized(previousOwnership.nameservers) != normalized(currentOwnership.nameservers) {
596                changeParts.append("nameservers")
597            }
598            guard !changeParts.isEmpty else { return nil }
599            return OwnershipTransitionEvent(
600                date: current.timestamp,
601                summary: "Changed \(changeParts.joined(separator: ", "))",
602                previousRegistrar: previousOwnership.registrar,
603                currentRegistrar: currentOwnership.registrar,
604                previousRegistrant: previousOwnership.registrant,
605                currentRegistrant: currentOwnership.registrant,
606                previousNameservers: previousOwnership.nameservers,
607                currentNameservers: currentOwnership.nameservers
608            )
609        }
610        .sorted { $0.date > $1.date }
611    }
612
613    private static func hostingTransitions(from observations: [(Date, InferredProviderFingerprint)]) -> [HostingTransitionEvent] {
614        zip(observations, observations.dropFirst()).compactMap { previous, current in
615            guard previous.1.name != current.1.name else { return nil }
616            return HostingTransitionEvent(
617                date: current.0,
618                fromProvider: previous.1.name,
619                toProvider: current.1.name,
620                summary: "Hosting moved from \(previous.1.name) to \(current.1.name)"
621            )
622        }
623        .sorted { $0.date > $1.date }
624    }
625
626    private static func buildSubdomainHistory(from snapshots: [LookupSnapshot]) -> [SubdomainHistoryEntry] {
627        struct WorkingState {
628            var firstSeen: Date
629            var lastSeen: Date
630            var recurrenceCount: Int
631            var statusChangeCount: Int
632            var lastSeenInPreviousSnapshot: Bool
633        }
634
635        var states: [String: WorkingState] = [:]
636        for snapshot in snapshots {
637            let currentHosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() })
638            let knownHosts = Set(states.keys).union(currentHosts)
639            for host in knownHosts {
640                let isPresent = currentHosts.contains(host)
641                if var state = states[host] {
642                    if isPresent {
643                        state.lastSeen = snapshot.timestamp
644                        state.recurrenceCount += 1
645                    }
646                    if state.lastSeenInPreviousSnapshot != isPresent {
647                        state.statusChangeCount += 1
648                    }
649                    state.lastSeenInPreviousSnapshot = isPresent
650                    states[host] = state
651                } else if isPresent {
652                    states[host] = WorkingState(
653                        firstSeen: snapshot.timestamp,
654                        lastSeen: snapshot.timestamp,
655                        recurrenceCount: 1,
656                        statusChangeCount: 0,
657                        lastSeenInPreviousSnapshot: true
658                    )
659                }
660            }
661        }
662
663        return states.map { host, state in
664            let isEphemeral = state.recurrenceCount <= 2 || state.statusChangeCount >= 2
665            return SubdomainHistoryEntry(
666                hostname: host,
667                firstSeen: state.firstSeen,
668                lastSeen: state.lastSeen,
669                recurrenceCount: state.recurrenceCount,
670                statusChangeCount: state.statusChangeCount,
671                lastKnownStatus: state.lastSeenInPreviousSnapshot ? "Active" : "Inactive",
672                isEphemeral: isEphemeral
673            )
674        }
675        .sorted { lhs, rhs in
676            if lhs.isEphemeral != rhs.isEphemeral {
677                return lhs.isEphemeral && !rhs.isEphemeral
678            }
679            return lhs.hostname < rhs.hostname
680        }
681    }
682
683    private static func buildRiskSignals(
684        snapshot: LookupSnapshot,
685        previousSnapshot: LookupSnapshot?,
686        ownershipTransitions: [OwnershipTransitionEvent],
687        hostingTransitions: [HostingTransitionEvent],
688        subdomainHistory: [SubdomainHistoryEntry]
689    ) -> [IntelligenceRiskSignal] {
690        var signals: [IntelligenceRiskSignal] = []
691        let dnsInstabilityCount = snapshot.dnsHistory.filter { !$0.changedRecordTypes.isEmpty }.count
692        let ephemeralSubdomains = subdomainHistory.filter(\.isEphemeral)
693
694        if ownershipTransitions.count >= 2 {
695            signals.append(.init(
696                id: "ownership-churn",
697                title: "Ownership churn",
698                detail: "Observed \(ownershipTransitions.count) ownership transitions in local history.",
699                severity: .high,
700                firstObserved: ownershipTransitions.last?.date,
701                lastObserved: ownershipTransitions.first?.date
702            ))
703        }
704        if dnsInstabilityCount >= 3 {
705            signals.append(.init(
706                id: "unstable-dns",
707                title: "Unstable DNS",
708                detail: "DNS history shows \(dnsInstabilityCount) recorded change events.",
709                severity: .medium,
710                firstObserved: snapshot.dnsHistory.last?.date,
711                lastObserved: snapshot.dnsHistory.first?.date
712            ))
713        }
714        if hostingTransitions.count >= 2 {
715            signals.append(.init(
716                id: "repeated-hosting-moves",
717                title: "Repeated hosting moves",
718                detail: "Infrastructure provider changed \(hostingTransitions.count) times across observations.",
719                severity: .medium,
720                firstObserved: hostingTransitions.last?.date,
721                lastObserved: hostingTransitions.first?.date
722            ))
723        }
724        if !ephemeralSubdomains.isEmpty {
725            signals.append(.init(
726                id: "ephemeral-subdomains",
727                title: "Ephemeral subdomains",
728                detail: "\(ephemeralSubdomains.count) subdomains appear short-lived or unstable.",
729                severity: ephemeralSubdomains.count >= 3 ? .medium : .low,
730                firstObserved: ephemeralSubdomains.map(\.firstSeen).min(),
731                lastObserved: ephemeralSubdomains.map(\.lastSeen).max()
732            ))
733        }
734        if let createdDate = snapshot.ownership?.createdDate {
735            let ageDays = Calendar.current.dateComponents([.day], from: createdDate, to: snapshot.timestamp).day ?? 0
736            if ageDays <= 180 {
737                signals.append(.init(
738                    id: "young-registration",
739                    title: "Short registration age",
740                    detail: "Domain registration is \(ageDays) days old.",
741                    severity: ageDays <= 90 ? .high : .medium,
742                    firstObserved: createdDate,
743                    lastObserved: snapshot.timestamp
744                ))
745            }
746        }
747        if let previousSnapshot,
748           let previousProvider = inferProvider(from: previousSnapshot)?.name,
749           let currentProvider = inferProvider(from: snapshot)?.name,
750           previousProvider != currentProvider {
751            signals.append(.init(
752                id: "recent-hosting-move",
753                title: "Recent hosting move",
754                detail: "Latest snapshot moved from \(previousProvider) to \(currentProvider).",
755                severity: .medium,
756                firstObserved: snapshot.timestamp,
757                lastObserved: snapshot.timestamp
758            ))
759        }
760        return signals.sorted { ($0.lastObserved ?? .distantPast) > ($1.lastObserved ?? .distantPast) }
761    }
762
763    private static func buildTimelineEvents(
764        snapshot: LookupSnapshot,
765        observations: [LookupSnapshot],
766        providerObservations: [(Date, InferredProviderFingerprint)],
767        ownershipTransitions: [OwnershipTransitionEvent],
768        hostingTransitions: [HostingTransitionEvent],
769        riskSignals: [IntelligenceRiskSignal]
770    ) -> [IntelligenceTimelineEvent] {
771        var events: [IntelligenceTimelineEvent] = []
772
773        events += ownershipTransitions.map {
774            .init(date: $0.date, category: .ownership, title: "Ownership transition", detail: $0.summary, severity: .high)
775        }
776        events += snapshot.dnsHistory.map {
777            .init(date: $0.date, category: .dns, title: "DNS change", detail: $0.summary, severity: $0.changedRecordTypes.contains(.A) || $0.changedRecordTypes.contains(.NS) ? .high : .medium)
778        }
779        events += hostingTransitions.map {
780            .init(date: $0.date, category: .hosting, title: "Hosting transition", detail: $0.summary, severity: .medium)
781        }
782        events += buildClassificationEvents(from: observations)
783        events += buildSubdomainDiscoveryEvents(from: observations)
784        events += riskSignals.compactMap {
785            guard let date = $0.lastObserved ?? $0.firstObserved else { return nil }
786            return IntelligenceTimelineEvent(date: date, category: .risk, title: $0.title, detail: $0.detail, severity: $0.severity)
787        }
788        if let latestProvider = providerObservations.last?.1 {
789            events.append(.init(
790                date: snapshot.timestamp,
791                category: .hosting,
792                title: "Current infrastructure",
793                detail: "Likely running on \(latestProvider.name)",
794                severity: .low
795            ))
796        }
797        return events.sorted { $0.date > $1.date }
798    }
799
800    private static func buildClassificationEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] {
801        let classifications = observations.compactMap { snapshot -> (Date, DomainClassificationSummary)? in
802            classify(snapshot: snapshot).map { (snapshot.timestamp, $0) }
803        }
804        return zip(classifications, classifications.dropFirst()).compactMap { previous, current in
805            guard previous.1.kind != current.1.kind else { return nil }
806            return .init(
807                date: current.0,
808                category: .classification,
809                title: "Classification changed",
810                detail: "\(previous.1.kind.title) -> \(current.1.kind.title)",
811                severity: .medium
812            )
813        }
814    }
815
816    private static func buildSubdomainDiscoveryEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] {
817        var seen = Set<String>()
818        var events: [IntelligenceTimelineEvent] = []
819        for snapshot in observations {
820            let hosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() })
821            for host in hosts where seen.insert(host).inserted {
822                events.append(.init(
823                    date: snapshot.timestamp,
824                    category: .subdomain,
825                    title: "Subdomain observed",
826                    detail: host,
827                    severity: .low
828                ))
829            }
830        }
831        return events
832    }
833
834    private static func provider(_ name: String, confidence: ConfidenceLevel, evidence: [String]) -> InferredProviderFingerprint {
835        .init(name: name, confidence: confidence, evidence: evidence)
836    }
837
838    private static func providerEvidence(headerMap: [String: String], dnsProviders: [String], matches: [String]) -> [String] {
839        var evidence: [String] = []
840        for match in matches {
841            if headerMap.keys.contains(match) || headerMap.values.contains(where: { $0.contains(match) }) {
842                evidence.append("HTTP \(match)")
843            }
844            if dnsProviders.contains(where: { $0.lowercased().contains(match) }) {
845                evidence.append("DNS \(match)")
846            }
847        }
848        return Array(Set(evidence)).sorted()
849    }
850
851    private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] {
852        sections
853            .first(where: { $0.recordType == type })?
854            .records
855            .map(\.value) ?? []
856    }
857
858    private static func normalized(_ values: [String]) -> [String] {
859        values.map { $0.lowercased() }.sorted()
860    }
861
862    private static func containsAny(in values: [String], matching patterns: [String]) -> Bool {
863        values.contains { value in
864            let normalized = value.lowercased()
865            return patterns.contains { normalized.contains($0) }
866        }
867    }
868
869    private static func containsHeaderValue(_ headerMap: [String: String], value: String) -> Bool {
870        headerMap.values.contains(where: { $0.contains(value) })
871    }
872
873    private static func apexDomain(for host: String) -> String {
874        let parts = host.split(separator: ".")
875        guard parts.count > 2 else { return host }
876        return parts.suffix(2).joined(separator: ".")
877    }
878}