krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.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)
299 let subdomainConfidence = confidenceForSubdomains(results: subdomains.value)
300 let emailConfidence = confidenceForEmail(result: emailSecurity.value)
301 let geolocationConfidence = confidenceForGeolocation(result: geolocation.value)
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 reputation: nil,
373 reputationError: nil,
374 portScanResults: portScanResults.value,
375 portScanError: portScanResults.message,
376 changeSummary: nil,
377 resultSource: aggregateSource(sectionSources),
378 cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue },
379 statusMessage: nil
380 )
381 DomainDebugLog.signpostEnd(
382 "Inspection.inspectSnapshot",
383 start: inspectionStartedAt,
384 domain: normalizedDomain,
385 extra: "resultSource=\(snapshot.resultSource.rawValue) cachedSections=\(snapshot.cachedSections.count) partial=\(snapshot.isPartialSnapshot)"
386 )
387 return snapshot
388 }
389
390 private func normalize(_ domain: String) -> String {
391 domain
392 .trimmingCharacters(in: .whitespacesAndNewlines)
393 .replacingOccurrences(of: "https://", with: "")
394 .replacingOccurrences(of: "http://", with: "")
395 .components(separatedBy: "/").first?
396 .lowercased() ?? domain.lowercased()
397 }
398
399 private func track(
400 _ section: LookupSectionKind,
401 source: LookupResultSource,
402 provenance: SectionProvenance,
403 cachedSections: inout Set<LookupSectionKind>,
404 sectionSources: inout [LookupResultSource],
405 provenanceBySection: inout [LookupSectionKind: SectionProvenance],
406 dataSources: inout Set<String>
407 ) {
408 sectionSources.append(source)
409 provenanceBySection[section] = provenance
410 dataSources.insert(provenance.provider ?? provenance.source)
411 if source != .live {
412 cachedSections.insert(section)
413 }
414 }
415
416 private func provenance(
417 for section: LookupSectionKind,
418 source: LookupResultSource,
419 collectedAt: Date,
420 resolverDisplayName: String
421 ) -> SectionProvenance {
422 switch section {
423 case .dns, .ptr:
424 return SectionProvenance(
425 source: "DNS-over-HTTPS query",
426 collectedAt: collectedAt,
427 provider: "Selected DoH resolver",
428 resolver: resolverDisplayName,
429 resultSource: source
430 )
431 case .availability:
432 return SectionProvenance(
433 source: "RDAP lookup with DNS fallback",
434 collectedAt: collectedAt,
435 provider: "rdap.org / selected resolver",
436 resolver: resolverDisplayName,
437 resultSource: source
438 )
439 case .ssl:
440 return SectionProvenance(source: "Direct TLS handshake", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
441 case .hsts, .httpHeaders, .redirectChain:
442 return SectionProvenance(source: "HTTP request", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
443 case .reachability:
444 return SectionProvenance(source: "TCP reachability probe", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
445 case .ipGeolocation:
446 return SectionProvenance(source: "IP geolocation lookup", collectedAt: collectedAt, provider: "ipapi.co", resolver: nil, resultSource: source)
447 case .emailSecurity:
448 return SectionProvenance(source: "DNS TXT inspection", collectedAt: collectedAt, provider: "Selected DoH resolver", resolver: resolverDisplayName, resultSource: source)
449 case .ownership:
450 return SectionProvenance(source: "RDAP domain lookup", collectedAt: collectedAt, provider: "rdap.org", resolver: nil, resultSource: source)
451 case .subdomains:
452 return SectionProvenance(source: "Certificate transparency search", collectedAt: collectedAt, provider: "crt.sh", resolver: nil, resultSource: source)
453 case .portScan:
454 return SectionProvenance(source: "TCP port scan", collectedAt: collectedAt, provider: "Target host", resolver: nil, resultSource: source)
455 case .suggestions:
456 return SectionProvenance(source: "Availability suggestions", collectedAt: collectedAt, provider: "DomainDig heuristic", resolver: resolverDisplayName, resultSource: source)
457 }
458 }
459
460 private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource {
461 let normalizedSources = sectionSources.map { source -> LookupResultSource in
462 source == .mixed ? .cached : source
463 }
464
465 let hasLive = normalizedSources.contains(.live)
466 let hasCached = normalizedSources.contains(.cached)
467
468 switch (hasLive, hasCached) {
469 case (true, true):
470 return .mixed
471 case (false, true):
472 return .cached
473 default:
474 return .live
475 }
476 }
477
478 private func canReuseDependentSections(from previousSnapshot: LookupSnapshot?, dnsSections: [DNSSection]) -> Bool {
479 guard let previousSnapshot else { return false }
480 return dnsSignature(for: previousSnapshot.dnsSections) == dnsSignature(for: dnsSections)
481 }
482
483 private func canReuseIPBasedSections(from previousSnapshot: LookupSnapshot?, primaryIP: String?) -> Bool {
484 guard let previousSnapshot else { return false }
485 let previousIP = previousSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
486 return primaryIP == previousIP
487 }
488
489 private func dnsSignature(for sections: [DNSSection]) -> String {
490 sections
491 .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
492 .map { section in
493 let records = section.records
494 .sorted { $0.value < $1.value }
495 .map { "\($0.value)|\($0.ttl)" }
496 .joined(separator: ",")
497 let wildcardRecords = section.wildcardRecords
498 .sorted { $0.value < $1.value }
499 .map { "\($0.value)|\($0.ttl)" }
500 .joined(separator: ",")
501 return [
502 section.recordType.rawValue,
503 records,
504 wildcardRecords,
505 section.dnssecSigned.map { $0 ? "signed" : "unsigned" } ?? "unknown",
506 section.error ?? ""
507 ].joined(separator: "#")
508 }
509 .joined(separator: "||")
510 }
511
512 private func normalizeErrors<Value>(in result: ServiceResult<Value>) -> ServiceResult<Value> {
513 switch result {
514 case let .success(value):
515 return .success(value)
516 case let .empty(message):
517 return .empty(classifyFailure(from: message, defaultKind: .unavailable).message)
518 case let .error(message):
519 return .error(classifyFailure(from: message).message)
520 }
521 }
522
523 private func classifyFailure(from message: String, defaultKind: InspectionErrorKind = .unknown) -> InspectionFailure {
524 let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
525 let lowercasedMessage = normalizedMessage.lowercased()
526 if lowercasedMessage.contains("timed out") {
527 return InspectionFailure(kind: .timeout, message: "Timed out", details: normalizedMessage)
528 }
529 if lowercasedMessage.contains("429")
530 || lowercasedMessage.contains("too many requests")
531 || lowercasedMessage.contains("rate limit") {
532 return InspectionFailure(kind: .rateLimited, message: "Rate limited", details: normalizedMessage)
533 }
534 if lowercasedMessage.contains("cannot parse")
535 || lowercasedMessage.contains("decoding")
536 || lowercasedMessage.contains("json") {
537 return InspectionFailure(kind: .parsing, message: "Could not parse response", details: normalizedMessage)
538 }
539 if lowercasedMessage.contains("offline")
540 || lowercasedMessage.contains("internet connection")
541 || lowercasedMessage.contains("not connected")
542 || lowercasedMessage.contains("network connection") {
543 return InspectionFailure(kind: .network, message: "Network unavailable", details: normalizedMessage)
544 }
545 if lowercasedMessage.contains("unsupported") {
546 return InspectionFailure(kind: .unsupported, message: "Unsupported for this target", details: normalizedMessage)
547 }
548 if lowercasedMessage == "unavailable" || lowercasedMessage.contains("no a record available") {
549 return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil)
550 }
551 if defaultKind == .unavailable {
552 return InspectionFailure(kind: .unavailable, message: normalizedMessage, details: nil)
553 }
554 return InspectionFailure(kind: .unknown, message: normalizedMessage.isEmpty ? defaultKind.title : normalizedMessage, details: normalizedMessage)
555 }
556
557 private func captureFailure<Value>(
558 for section: LookupSectionKind,
559 result: ServiceResult<Value>,
560 into errorDetails: inout [LookupSectionKind: InspectionFailure]
561 ) {
562 switch result {
563 case .success:
564 return
565 case let .empty(message):
566 errorDetails[section] = classifyFailure(from: message, defaultKind: .unavailable)
567 case let .error(message):
568 errorDetails[section] = classifyFailure(from: message)
569 }
570 }
571
572 private func confidenceForAvailability(result: DomainAvailabilityResult, provenance: SectionProvenance?) -> ConfidenceLevel {
573 guard result.status != .unknown else { return .low }
574 if provenance?.provider?.localizedCaseInsensitiveContains("rdap.org") == true, result.status == .registered {
575 return .high
576 }
577 if result.status == .registered {
578 return .medium
579 }
580 return .low
581 }
582
583 private func confidenceForOwnership(result: DomainOwnership?) -> ConfidenceLevel {
584 guard let result else { return .low }
585 let hasDirectRegistrationData = result.registrar != nil || result.createdDate != nil || result.expirationDate != nil
586 return hasDirectRegistrationData ? .high : .medium
587 }
588
589 private func confidenceForSubdomains(results: [DiscoveredSubdomain]) -> ConfidenceLevel {
590 if !results.isEmpty {
591 return .medium
592 }
593 return .low
594 }
595
596 private func confidenceForEmail(result: EmailSecurityResult?) -> ConfidenceLevel {
597 guard let result else { return .low }
598 let foundCount = [result.spf.found, result.dmarc.found, result.dkim.found, result.bimi.found, result.mtaSts?.txtFound == true]
599 .filter { $0 }
600 .count
601 if foundCount >= 3 {
602 return .high
603 }
604 if foundCount >= 1 {
605 return .medium
606 }
607 return .low
608 }
609
610 private func confidenceForGeolocation(result: IPGeolocation?) -> ConfidenceLevel {
611 guard let result else { return .low }
612 if result.city != nil && result.country_name != nil && result.latitude != nil && result.longitude != nil {
613 return .high
614 }
615 if result.country_name != nil || result.org != nil {
616 return .medium
617 }
618 return .low
619 }
620
621 private func validationIssues(
622 for domain: String,
623 snapshotTimestamp: Date,
624 availability: DomainAvailabilityResult,
625 dnsSections: [DNSSection],
626 provenanceBySection: [LookupSectionKind: SectionProvenance]
627 ) -> [String] {
628 var issues: [String] = []
629 if domain.isEmpty {
630 issues.append("Missing normalized domain")
631 }
632 if availability.domain.isEmpty {
633 issues.append("Missing normalized availability domain")
634 }
635 if dnsSections.isEmpty && provenanceBySection[.dns] == nil {
636 issues.append("Missing DNS provenance")
637 }
638 if snapshotTimestamp > Date().addingTimeInterval(5) {
639 issues.append("Snapshot timestamp is in the future")
640 }
641 return issues
642 }
643
644 private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
645 switch result {
646 case let .success(value):
647 return (value, nil)
648 case let .empty(message), let .error(message):
649 return (emptyValue, message)
650 }
651 }
652
653 private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) {
654 switch result {
655 case let .success(value):
656 return (value, nil)
657 case let .empty(message), let .error(message):
658 return (nil, message)
659 }
660 }
661
662 private func mapOptionalServiceResult<Value>(
663 _ result: ServiceResult<Value>?,
664 missingMessage: String
665 ) -> (value: Value?, message: String?) {
666 guard let result else {
667 return (nil, missingMessage)
668 }
669
670 switch result {
671 case let .success(value):
672 return (value, nil)
673 case let .empty(message), let .error(message):
674 return (nil, message)
675 }
676 }
677
678 private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) {
679 switch result {
680 case let .success(results):
681 return (await enrichOpenPortBanners(in: results, domain: domain), nil)
682 case let .empty(message), let .error(message):
683 return ([], message)
684 }
685 }
686
687 private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> (
688 headers: [HTTPHeader],
689 securityGrade: String?,
690 statusCode: Int?,
691 responseTimeMs: Int?,
692 httpProtocol: String?,
693 http3Advertised: Bool,
694 error: String?
695 ) {
696 switch result {
697 case let .success(value):
698 return (
699 headers: value.headers,
700 securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue,
701 statusCode: value.statusCode,
702 responseTimeMs: value.responseTimeMs,
703 httpProtocol: value.httpProtocol,
704 http3Advertised: value.http3Advertised,
705 error: nil
706 )
707 case let .empty(message), let .error(message):
708 return (
709 headers: [],
710 securityGrade: nil,
711 statusCode: nil,
712 responseTimeMs: nil,
713 httpProtocol: nil,
714 http3Advertised: false,
715 error: message
716 )
717 }
718 }
719
720 private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
721 let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
722 for result in results where result.open {
723 group.addTask {
724 let banner = await PortScanService.grabBanner(host: domain, port: result.port)
725 return (result.port, banner)
726 }
727 }
728
729 var collected: [UInt16: String] = [:]
730 for await (port, banner) in group {
731 if let banner {
732 collected[port] = banner
733 }
734 }
735 return collected
736 }
737
738 return results.map { result in
739 var updated = result
740 updated.banner = banners[result.port]
741 return updated
742 }
743 }
744}