krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.5.0: 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}