krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.6.0: DomainDig/DiffService.swift · raw

  1import Foundation
  2
  3enum DiffChangeType: String, Codable {
  4    case added
  5    case removed
  6    case changed
  7    case unchanged
  8
  9    var marker: String {
 10        switch self {
 11        case .added:
 12            return "+"
 13        case .removed:
 14            return "-"
 15        case .changed:
 16            return "~"
 17        case .unchanged:
 18            return "="
 19        }
 20    }
 21
 22    var title: String {
 23        switch self {
 24        case .added:
 25            return "Added"
 26        case .removed:
 27            return "Removed"
 28        case .changed:
 29            return "Changed"
 30        case .unchanged:
 31            return "Unchanged"
 32        }
 33    }
 34}
 35
 36struct DiffItem: Identifiable, Equatable, Codable {
 37    let id: String
 38    let label: String
 39    let changeType: DiffChangeType
 40    let oldValue: String?
 41    let newValue: String?
 42    let severity: ChangeSeverity
 43
 44    init(
 45        id: String,
 46        label: String,
 47        changeType: DiffChangeType,
 48        oldValue: String?,
 49        newValue: String?,
 50        severity: ChangeSeverity
 51    ) {
 52        self.id = id
 53        self.label = label
 54        self.changeType = changeType
 55        self.oldValue = oldValue
 56        self.newValue = newValue
 57        self.severity = severity
 58    }
 59
 60    var hasChanges: Bool {
 61        changeType != .unchanged
 62    }
 63}
 64
 65struct DiffSection: Identifiable, Equatable, Codable {
 66    let id: String
 67    let title: String
 68    let items: [DiffItem]
 69
 70    var hasChanges: Bool {
 71        items.contains(where: \.hasChanges)
 72    }
 73
 74    var severity: ChangeSeverity {
 75        items.map(\.severity).max() ?? .low
 76    }
 77
 78    var changeCount: Int {
 79        items.filter(\.hasChanges).count
 80    }
 81}
 82
 83struct DomainDiff: Identifiable, Equatable, Codable {
 84    let domain: String
 85    let fromTimestamp: Date
 86    let toTimestamp: Date
 87    let sections: [DiffSection]
 88    let changedSectionIDs: [String]
 89    let changedSectionTitles: [String]
 90    let contextNote: String?
 91
 92    var id: String {
 93        "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)"
 94    }
 95
 96    var changeCount: Int {
 97        sections.reduce(0) { $0 + $1.changeCount }
 98    }
 99
100    var severity: ChangeSeverity {
101        sections.map(\.severity).max() ?? .low
102    }
103}
104
105typealias DomainDiffItem = DiffItem
106typealias DomainDiffSection = DiffSection
107
108enum DiffService {
109    static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff {
110        let sections = [
111            availabilitySection(from: oldReport, to: newReport),
112            ownershipSection(from: oldReport, to: newReport),
113            dnsSection(from: oldReport, to: newReport),
114            webSection(from: oldReport, to: newReport),
115            emailSection(from: oldReport, to: newReport),
116            networkSection(from: oldReport, to: newReport),
117            subdomainsSection(from: oldReport, to: newReport),
118            intelligenceSection(from: oldReport, to: newReport),
119            riskSection(from: oldReport, to: newReport)
120        ]
121
122        let changedSections = sections.filter(\.hasChanges)
123        return DomainDiff(
124            domain: newReport.domain,
125            fromTimestamp: oldReport.timestamp,
126            toTimestamp: newReport.timestamp,
127            sections: sections,
128            changedSectionIDs: changedSections.map(\.id),
129            changedSectionTitles: changedSections.map(\.title),
130            contextNote: comparisonContextNote(from: oldReport, to: newReport)
131        )
132    }
133
134    static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff {
135        let builder = DomainReportBuilder()
136        let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false)
137        let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
138        return compare(from: oldReport, to: newReport)
139    }
140
141    static func summary(
142        from oldSnapshot: LookupSnapshot,
143        to newSnapshot: LookupSnapshot,
144        generatedAt: Date = Date(),
145        riskAssessment: DomainRiskAssessment? = nil,
146        insights: [String]? = nil
147    ) -> DomainChangeSummary {
148        let diff = compare(from: oldSnapshot, to: newSnapshot)
149        let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges)
150        let highlights = diff.changedSectionTitles
151        let severity = changedItems.map(\.severity).max() ?? .low
152        let message = summaryMessage(from: highlights, changeCount: changedItems.count)
153        let observedFacts = changedItems.prefix(4).map { item in
154            "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")"
155        }
156
157        let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
158        let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment
159        let currentInsights = insights ?? analysis.insights
160        let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score
161        let riskScoreDelta = currentRiskAssessment.score - previousRiskScore
162        let impactClassification = DomainInsightEngine.impactClassification(
163            severity: severity,
164            riskDelta: riskScoreDelta,
165            changedSections: highlights
166        )
167
168        return DomainChangeSummary(
169            hasChanges: !changedItems.isEmpty,
170            changedSections: highlights,
171            message: message,
172            severity: severity,
173            impactClassification: impactClassification,
174            generatedAt: generatedAt,
175            observedFacts: observedFacts,
176            inferredConclusions: highlights.isEmpty ? [] : [message],
177            contextNote: diff.contextNote,
178            riskAssessment: currentRiskAssessment,
179            insights: currentInsights,
180            riskScoreDelta: riskScoreDelta
181        )
182    }
183
184    static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? {
185        var notes: [String] = []
186        if oldReport.resolverURLString != newReport.resolverURLString {
187            notes.append("Compared snapshots used different DNS resolvers.")
188        }
189        if oldReport.resultSource != newReport.resultSource {
190            notes.append("Compared snapshots came from different collection modes.")
191        }
192        return notes.isEmpty ? nil : notes.joined(separator: " ")
193    }
194
195    static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
196        comparisonContextNote(
197            from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false),
198            to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false)
199        )
200    }
201
202    static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
203        guard let days = snapshot.sslInfo?.daysUntilExpiry else {
204            return .none
205        }
206        if days < 14 {
207            return .critical
208        }
209        if days < 30 {
210            return .warning
211        }
212        return .none
213    }
214
215    private static func availabilitySection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
216        DiffSection(
217            id: "availability",
218            title: "Domain / Availability",
219            items: [
220                compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low),
221                compare(
222                    id: "availability",
223                    label: "Availability",
224                    oldValue: availabilityLabel(oldReport.availability),
225                    newValue: availabilityLabel(newReport.availability),
226                    severity: .high
227                ),
228                compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high),
229                compare(
230                    id: "tls-status",
231                    label: "TLS Status",
232                    oldValue: oldReport.web.tlsStatus,
233                    newValue: newReport.web.tlsStatus,
234                    severity: .medium
235                )
236            ].compactMap { $0 }
237        )
238    }
239
240    private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
241        DiffSection(
242            id: "ownership",
243            title: "Ownership",
244            items: [
245                compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high),
246                compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium),
247                compare(
248                    id: "ownership-created",
249                    label: "Registration Date",
250                    oldValue: ownershipDateLabel(oldReport.ownership?.createdDate),
251                    newValue: ownershipDateLabel(newReport.ownership?.createdDate),
252                    severity: .low
253                ),
254                compare(
255                    id: "ownership-expires",
256                    label: "Expiration Date",
257                    oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate),
258                    newValue: ownershipDateLabel(newReport.ownership?.expirationDate),
259                    severity: .medium
260                ),
261                compare(
262                    id: "ownership-status",
263                    label: "Status",
264                    oldValue: joined(oldReport.ownership?.status),
265                    newValue: joined(newReport.ownership?.status),
266                    severity: .low
267                ),
268                compare(
269                    id: "ownership-nameservers",
270                    label: "Nameservers",
271                    oldValue: joined(oldReport.ownership?.nameservers),
272                    newValue: joined(newReport.ownership?.nameservers),
273                    severity: .medium
274                ),
275                compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low)
276            ].compactMap { $0 }
277        )
278    }
279
280    private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
281        let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) })
282        let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) })
283        let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
284
285        var items: [DiffItem] = [
286            compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium),
287            compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low)
288        ].compactMap { $0 }
289
290        for type in recordTypes {
291            items.append(
292                compare(
293                    id: "dns-\(type.rawValue.lowercased())-records",
294                    label: "\(type.rawValue) Records",
295                    oldValue: normalizedRecordValues(for: oldSections[type]),
296                    newValue: normalizedRecordValues(for: newSections[type]),
297                    severity: type == .A || type == .NS ? .high : .medium
298                ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low)
299            )
300            if let ttlChange = compare(
301                id: "dns-\(type.rawValue.lowercased())-ttl",
302                label: "\(type.rawValue) TTL",
303                oldValue: normalizedTTLValues(for: oldSections[type]),
304                newValue: normalizedTTLValues(for: newSections[type]),
305                severity: .low
306            ) {
307                items.append(ttlChange)
308            }
309        }
310
311        return DiffSection(
312            id: "dns",
313            title: "DNS",
314            items: items.filter { !$0.id.isEmpty }
315        )
316    }
317
318    private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
319        DiffSection(
320            id: "web",
321            title: "Web",
322            items: [
323                compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium),
324                compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium),
325                compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high),
326                compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium),
327                compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium),
328                compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low),
329                compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium)
330            ].compactMap { $0 }
331        )
332    }
333
334    private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
335        DiffSection(
336            id: "email",
337            title: "Email Security",
338            items: [
339                compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium),
340                compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium),
341                compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium),
342                compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high),
343                compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium),
344                compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low),
345                compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium)
346            ].compactMap { $0 }
347        )
348    }
349
350    private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
351        DiffSection(
352            id: "network",
353            title: "Network",
354            items: [
355                compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium),
356                compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium),
357                compare(id: "network-open-ports", label: "Open Ports", oldValue: joined(oldReport.network.openPorts.map(String.init)), newValue: joined(newReport.network.openPorts.map(String.init)), severity: .high),
358                compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium)
359            ].compactMap { $0 }
360        )
361    }
362
363    private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
364        DiffSection(
365            id: "subdomains",
366            title: "Subdomains",
367            items: [
368                compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low),
369                compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low),
370                compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low)
371            ].compactMap { $0 }
372        )
373    }
374
375    private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
376        DiffSection(
377            id: "risk",
378            title: "Risk / Insights",
379            items: [
380                compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high),
381                compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high),
382                compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium),
383                compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium)
384            ].compactMap { $0 }
385        )
386    }
387
388    private static func intelligenceSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection {
389        DiffSection(
390            id: "intelligence",
391            title: "Data+ Intelligence",
392            items: [
393                compare(id: "intel-provider", label: "Provider", oldValue: oldReport.inferredProvider?.name, newValue: newReport.inferredProvider?.name, severity: .medium),
394                compare(id: "intel-classification", label: "Classification", oldValue: oldReport.domainClassification?.kind.title, newValue: newReport.domainClassification?.kind.title, severity: .medium),
395                compare(id: "intel-hosting-history", label: "Hosting Transitions", oldValue: joined(oldReport.hostingTransitions.map(\.summary)), newValue: joined(newReport.hostingTransitions.map(\.summary)), severity: .medium),
396                compare(id: "intel-ownership-history", label: "Ownership Transitions", oldValue: joined(oldReport.ownershipTransitions.map(\.summary)), newValue: joined(newReport.ownershipTransitions.map(\.summary)), severity: .high),
397                compare(id: "intel-risk-signals", label: "Risk Signals", oldValue: joined(oldReport.riskSignals.map(\.title)), newValue: joined(newReport.riskSignals.map(\.title)), severity: .medium)
398            ].compactMap { $0 }
399        )
400    }
401
402    private static func compare(
403        id: String,
404        label: String,
405        oldValue: String?,
406        newValue: String?,
407        severity: ChangeSeverity
408    ) -> DiffItem? {
409        let oldValue = normalized(oldValue)
410        let newValue = normalized(newValue)
411
412        guard oldValue != nil || newValue != nil else {
413            return nil
414        }
415
416        let changeType: DiffChangeType
417        switch (oldValue?.lowercased(), newValue?.lowercased()) {
418        case let (old?, new?) where old == new:
419            changeType = .unchanged
420        case (nil, _?):
421            changeType = .added
422        case (_?, nil):
423            changeType = .removed
424        default:
425            changeType = .changed
426        }
427
428        return DiffItem(
429            id: id,
430            label: label,
431            changeType: changeType,
432            oldValue: oldValue,
433            newValue: newValue,
434            severity: severity
435        )
436    }
437
438    static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String {
439        guard !sectionTitles.isEmpty else {
440            return "No meaningful changes"
441        }
442        if sectionTitles.count == 1 {
443            return "\(sectionTitles[0]) changed"
444        }
445        return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)"
446    }
447
448    private static func normalized(_ value: String?) -> String? {
449        guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
450            return nil
451        }
452        return value
453    }
454
455    private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
456        switch status {
457        case .available:
458            return "Available"
459        case .registered:
460            return "Registered"
461        case .unknown:
462            return "Unknown"
463        }
464    }
465
466    private static func ownershipDateLabel(_ date: Date?) -> String? {
467        date?.formatted(date: .abbreviated, time: .omitted)
468    }
469
470    private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? {
471        guard let certificate else { return nil }
472        return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)"
473    }
474
475    private static func joined(_ values: [String]?) -> String? {
476        guard let values else { return nil }
477        let normalizedValues = values
478            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
479            .filter { !$0.isEmpty }
480            .sorted()
481        return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ", ")
482    }
483
484    private static func normalizedRecordValues(for section: DNSSection?) -> String? {
485        guard let section else { return nil }
486        let values = (section.records + section.wildcardRecords)
487            .map(\.value)
488            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
489            .sorted()
490        return values.isEmpty ? nil : values.joined(separator: ", ")
491    }
492
493    private static func normalizedTTLValues(for section: DNSSection?) -> String? {
494        guard let section else { return nil }
495        let values = (section.records + section.wildcardRecords)
496            .map { "\($0.value.lowercased()):\($0.ttl)" }
497            .sorted()
498        return values.isEmpty ? nil : values.joined(separator: ", ")
499    }
500
501    private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? {
502        let values = headers
503            .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
504            .sorted()
505        return values.isEmpty ? nil : values.joined(separator: " | ")
506    }
507
508    private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? {
509        let values = redirects.map { "\($0.statusCode) \($0.url)" }
510        return values.isEmpty ? nil : values.joined(separator: " -> ")
511    }
512
513    private static func portScanSummary(_ results: [PortScanResult]) -> String? {
514        let values = results
515            .sorted { $0.port < $1.port }
516            .map { "\($0.port):\($0.open ? "open" : "closed")" }
517        return values.isEmpty ? nil : values.joined(separator: ", ")
518    }
519
520    private static func groupSummary(_ groups: [SubdomainGroup]) -> String? {
521        joined(groups.map { "\($0.label): \($0.subdomains.count)" })
522    }
523
524    private static func recordLabel(_ record: EmailSecurityRecord?) -> String? {
525        guard let record else { return nil }
526        if record.found {
527            return record.value ?? "Present"
528        }
529        return "Missing"
530    }
531
532    private static func mtaStsLabel(_ result: MTASTSResult?) -> String? {
533        guard let result else { return nil }
534        guard result.txtFound else { return "Missing" }
535        return result.policyMode ?? "Present"
536    }
537
538    private static func dnssecLabel(_ value: Bool?) -> String? {
539        switch value {
540        case true:
541            return "Signed"
542        case false:
543            return "Unsigned"
544        case nil:
545            return nil
546        }
547    }
548}
549
550enum DomainDiffService {
551    static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
552        DiffService.compare(from: oldSnapshot, to: newSnapshot).sections
553    }
554
555    static func summary(
556        from oldSnapshot: LookupSnapshot,
557        to newSnapshot: LookupSnapshot,
558        generatedAt: Date = Date(),
559        riskAssessment: DomainRiskAssessment? = nil,
560        insights: [String]? = nil
561    ) -> DomainChangeSummary {
562        DiffService.summary(
563            from: oldSnapshot,
564            to: newSnapshot,
565            generatedAt: generatedAt,
566            riskAssessment: riskAssessment,
567            insights: insights
568        )
569    }
570
571    static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? {
572        DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot)
573    }
574
575    static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
576        DiffService.certificateWarningLevel(for: snapshot)
577    }
578}