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