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