krz/domain-dig

an ios app for DNS & SSL analysis

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

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