1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
|
import Foundation
enum DiffChangeType: String, Codable {
case added
case removed
case changed
case unchanged
}
struct DomainDiffItem: Identifiable, Equatable {
let id = UUID()
let label: String
let changeType: DiffChangeType
let oldValue: String?
let newValue: String?
var hasChanges: Bool {
changeType != .unchanged
}
}
struct DomainDiffSection: Identifiable, Equatable {
let id = UUID()
let title: String
let items: [DomainDiffItem]
var hasChanges: Bool {
items.contains(where: \.hasChanges)
}
}
enum DomainDiffService {
static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] {
[
section(title: "Availability", item: compare(
label: "Status",
oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
newValue: availabilityLabel(newSnapshot.availabilityResult?.status)
)),
section(title: "Primary IP", item: compare(
label: "Address",
oldValue: primaryIP(from: oldSnapshot),
newValue: primaryIP(from: newSnapshot)
)),
dnsSection(from: oldSnapshot, to: newSnapshot),
section(title: "Redirect", item: compare(
label: "Final Target",
oldValue: finalRedirectURL(from: oldSnapshot),
newValue: finalRedirectURL(from: newSnapshot)
)),
tlsSection(from: oldSnapshot, to: newSnapshot),
httpSection(from: oldSnapshot, to: newSnapshot),
emailSection(from: oldSnapshot, to: newSnapshot)
]
.filter { !$0.items.isEmpty }
}
static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary {
let changedSections = diff(from: oldSnapshot, to: newSnapshot)
.filter { $0.items.contains(where: { $0.changeType != .unchanged }) }
.map(\.title)
return DomainChangeSummary(
hasChanges: !changedSections.isEmpty,
changedSections: changedSections,
generatedAt: generatedAt
)
}
private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
DomainDiffSection(title: title, items: item.map { [$0] } ?? [])
}
private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
let oldValue = normalizedDNSSummary(from: oldSnapshot)
let newValue = normalizedDNSSummary(from: newSnapshot)
return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue))
}
private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
var items: [DomainDiffItem] = []
if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) {
items.append(item)
}
if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
items.append(item)
}
return DomainDiffSection(title: "TLS Certificate", items: items)
}
private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
var items: [DomainDiffItem] = []
if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) {
items.append(item)
}
if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
items.append(item)
}
return DomainDiffSection(title: "HTTP", items: items)
}
private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
section(
title: "Email Security",
item: compare(
label: "Summary",
oldValue: normalized(emailSummary(from: oldSnapshot)),
newValue: normalized(emailSummary(from: newSnapshot))
)
)
}
private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
let oldValue = normalized(oldValue)
let newValue = normalized(newValue)
let normalizedOldValue = comparisonValue(oldValue)
let normalizedNewValue = comparisonValue(newValue)
guard oldValue != nil || newValue != nil else {
return nil
}
let changeType: DiffChangeType
switch (normalizedOldValue, normalizedNewValue) {
case let (old?, new?) where old == new:
changeType = .unchanged
case (nil, _?):
changeType = .added
case (_?, nil):
changeType = .removed
default:
changeType = .changed
}
return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
}
private static func normalized(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
return value
}
private static func comparisonValue(_ value: String?) -> String? {
value?.lowercased()
}
private static func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String? {
switch status {
case .available:
return "available"
case .registered:
return "registered"
case .unknown:
return "unknown"
case .none:
return nil
}
}
private static func primaryIP(from snapshot: LookupSnapshot) -> String? {
snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
}
private static func finalRedirectURL(from snapshot: LookupSnapshot) -> String? {
snapshot.redirectChain.last?.url
}
private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
if let sslInfo = snapshot.sslInfo {
return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))"
}
return snapshot.sslError
}
private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
if let httpStatusCode = snapshot.httpStatusCode {
return "\(httpStatusCode)"
}
return snapshot.httpHeadersError
}
private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
if let emailSecurity = snapshot.emailSecurity {
return [
"spf:\(emailSecurity.spf.found)",
"dmarc:\(emailSecurity.dmarc.found)",
"dkim:\(emailSecurity.dkim.found)",
"bimi:\(emailSecurity.bimi.found)",
"mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
].joined(separator: "|")
}
return snapshot.emailSecurityError
}
private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? {
let parts = snapshot.dnsSections
.sorted { $0.recordType.rawValue < $1.recordType.rawValue }
.map { section in
let values = (section.records + section.wildcardRecords)
.map(\.value)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.sorted()
.joined(separator: ",")
return "\(section.recordType.rawValue):\(values)"
}
.filter { !$0.hasSuffix(":") }
return parts.isEmpty ? nil : parts.joined(separator: "|")
}
}
|