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