summaryrefslogtreecommitdiff
path: root/DomainDig/DomainDiffService.swift
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig/DomainDiffService.swift')
-rw-r--r--DomainDig/DomainDiffService.swift337
1 files changed, 276 insertions, 61 deletions
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
index 65ea5a7..667802a 100644
--- a/DomainDig/DomainDiffService.swift
+++ b/DomainDig/DomainDiffService.swift
@@ -13,10 +13,15 @@ struct DomainDiffItem: Identifiable, Equatable {
let changeType: DiffChangeType
let oldValue: String?
let newValue: String?
+ let severity: ChangeSeverity
var hasChanges: Bool {
changeType != .unchanged
}
+
+ var isMeaningful: Bool {
+ hasChanges && severity >= .medium
+ }
}
struct DomainDiffSection: Identifiable, Equatable {
@@ -27,27 +32,19 @@ struct DomainDiffSection: Identifiable, Equatable {
var hasChanges: Bool {
items.contains(where: \.hasChanges)
}
+
+ var severity: ChangeSeverity {
+ items.map(\.severity).max() ?? .low
+ }
}
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)
- )),
+ availabilitySection(from: oldSnapshot, to: newSnapshot),
+ primaryIPSection(from: oldSnapshot, to: newSnapshot),
dnsSection(from: oldSnapshot, to: newSnapshot),
- section(title: "Redirect", item: compare(
- label: "Final Target",
- oldValue: finalRedirectURL(from: oldSnapshot),
- newValue: finalRedirectURL(from: newSnapshot)
- )),
+ redirectSection(from: oldSnapshot, to: newSnapshot),
tlsSection(from: oldSnapshot, to: newSnapshot),
httpSection(from: oldSnapshot, to: newSnapshot),
emailSection(from: oldSnapshot, to: newSnapshot)
@@ -55,62 +52,212 @@ enum DomainDiffService {
.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)
+ static func summary(
+ from oldSnapshot: LookupSnapshot,
+ to newSnapshot: LookupSnapshot,
+ generatedAt: Date = Date()
+ ) -> DomainChangeSummary {
+ let sections = diff(from: oldSnapshot, to: newSnapshot)
+ let meaningfulItems = sections
+ .flatMap(\.items)
+ .filter(\.isMeaningful)
+ let allChangedItems = sections
+ .flatMap(\.items)
+ .filter(\.hasChanges)
+
+ let highlights = summaryHighlights(from: meaningfulItems)
+ let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low)
+ let message = summaryMessage(from: meaningfulItems, highlights: highlights)
return DomainChangeSummary(
- hasChanges: !changedSections.isEmpty,
- changedSections: changedSections,
+ hasChanges: !meaningfulItems.isEmpty,
+ changedSections: highlights,
+ message: message,
+ severity: severity,
generatedAt: generatedAt
)
}
- private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection {
- DomainDiffSection(title: title, items: item.map { [$0] } ?? [])
+ static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel {
+ guard let days = snapshot.sslInfo?.daysUntilExpiry else {
+ return .none
+ }
+ if days < 14 {
+ return .critical
+ }
+ if days < 30 {
+ return .warning
+ }
+ return .none
+ }
+
+ private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ DomainDiffSection(
+ title: "Availability",
+ items: [
+ compare(
+ label: "Availability",
+ oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status),
+ newValue: availabilityLabel(newSnapshot.availabilityResult?.status),
+ severity: .high
+ )
+ ].compactMap { $0 }
+ )
+ }
+
+ private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ DomainDiffSection(
+ title: "Primary IP",
+ items: [
+ compare(
+ label: "Primary IP",
+ oldValue: primaryIP(from: oldSnapshot),
+ newValue: primaryIP(from: newSnapshot),
+ severity: .high
+ )
+ ].compactMap { $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))
+ let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) })
+ let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) })
+ let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue }
+
+ var items: [DomainDiffItem] = []
+ for type in types {
+ let oldSection = oldSections[type]
+ let newSection = newSections[type]
+
+ if let recordChange = compare(
+ label: "\(type.rawValue) Records",
+ oldValue: normalizedRecordValues(for: oldSection),
+ newValue: normalizedRecordValues(for: newSection),
+ severity: .medium
+ ) {
+ items.append(recordChange)
+ }
+
+ if let ttlChange = compare(
+ label: "\(type.rawValue) TTL",
+ oldValue: normalizedTTLValues(for: oldSection),
+ newValue: normalizedTTLValues(for: newSection),
+ severity: .low
+ ), let oldSection, let newSection,
+ normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) {
+ items.append(ttlChange)
+ }
+ }
+
+ return DomainDiffSection(title: "DNS", items: items)
+ }
+
+ private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ DomainDiffSection(
+ title: "Redirect",
+ items: [
+ compare(
+ label: "Redirect Target",
+ oldValue: finalRedirectURL(from: oldSnapshot),
+ newValue: finalRedirectURL(from: newSnapshot),
+ severity: .high
+ )
+ ].compactMap { $0 }
+ )
}
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 issuerChange = compare(
+ label: "TLS Issuer",
+ oldValue: normalized(oldSnapshot.sslInfo?.issuer),
+ newValue: normalized(newSnapshot.sslInfo?.issuer),
+ severity: .medium
+ ) {
+ items.append(issuerChange)
+ }
+
+ if let expiryChange = compare(
+ label: "TLS Expiration",
+ oldValue: expirationLabel(oldSnapshot.sslInfo),
+ newValue: expirationLabel(newSnapshot.sslInfo),
+ severity: .medium
+ ) {
+ items.append(expiryChange)
}
- if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) {
- items.append(item)
+
+ let oldWarning = certificateWarningLevel(for: oldSnapshot)
+ let newWarning = certificateWarningLevel(for: newSnapshot)
+ if oldWarning != newWarning, newWarning != .none {
+ let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0
+ items.append(
+ DomainDiffItem(
+ label: "Certificate Warning",
+ changeType: .changed,
+ oldValue: oldWarning.title,
+ newValue: "Certificate expires in \(days) days",
+ severity: newWarning == .critical ? .high : .medium
+ )
+ )
}
- return DomainDiffSection(title: "TLS Certificate", items: items)
+
+ return DomainDiffSection(title: "TLS", 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 statusChange = compare(
+ label: "HTTP Status",
+ oldValue: httpStatusSummary(from: oldSnapshot),
+ newValue: httpStatusSummary(from: newSnapshot),
+ severity: .medium
+ ) {
+ items.append(statusChange)
}
- if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) {
- items.append(item)
+
+ if let gradeChange = compare(
+ label: "Security Grade",
+ oldValue: normalized(oldSnapshot.httpSecurityGrade),
+ newValue: normalized(newSnapshot.httpSecurityGrade),
+ severity: .low
+ ) {
+ items.append(gradeChange)
+ }
+
+ if let headerChange = compare(
+ label: "Headers",
+ oldValue: normalizedHeaders(from: oldSnapshot),
+ newValue: normalizedHeaders(from: newSnapshot),
+ severity: .low
+ ) {
+ items.append(headerChange)
}
+
return DomainDiffSection(title: "HTTP", items: items)
}
private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
- section(
+ DomainDiffSection(
title: "Email Security",
- item: compare(
- label: "Summary",
- oldValue: normalized(emailSummary(from: oldSnapshot)),
- newValue: normalized(emailSummary(from: newSnapshot))
- )
+ items: [
+ compare(
+ label: "Email Security",
+ oldValue: normalized(emailSummary(from: oldSnapshot)),
+ newValue: normalized(emailSummary(from: newSnapshot)),
+ severity: .medium
+ )
+ ].compactMap { $0 }
)
}
- private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? {
+ private static func compare(
+ label: String,
+ oldValue: String?,
+ newValue: String?,
+ severity: ChangeSeverity
+ ) -> DomainDiffItem? {
let oldValue = normalized(oldValue)
let newValue = normalized(newValue)
let normalizedOldValue = comparisonValue(oldValue)
@@ -132,7 +279,68 @@ enum DomainDiffService {
changeType = .changed
}
- return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue)
+ return DomainDiffItem(
+ label: label,
+ changeType: changeType,
+ oldValue: oldValue,
+ newValue: newValue,
+ severity: severity
+ )
+ }
+
+ private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] {
+ var highlights: [String] = []
+
+ let labels = Set(items.map(\.label))
+ if labels.contains("Availability") {
+ highlights.append("Availability changed")
+ }
+ if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) {
+ highlights.append("IP changed")
+ highlights.append("DNS changed")
+ return highlights
+ }
+ if labels.contains("Primary IP") {
+ highlights.append("IP changed")
+ }
+ if labels.contains("Redirect Target") {
+ highlights.append("Redirect target changed")
+ }
+ if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
+ let message = certificateItem.newValue {
+ highlights.append(message)
+ } else if labels.contains("TLS Issuer") {
+ highlights.append("TLS issuer changed")
+ } else if labels.contains("TLS Expiration") {
+ highlights.append("Certificate expiration changed")
+ }
+ if labels.contains(where: { $0.hasSuffix("Records") }) {
+ highlights.append("DNS changed")
+ }
+ if labels.contains("HTTP Status") {
+ highlights.append("HTTP status changed")
+ }
+ if labels.contains("Email Security") {
+ highlights.append("Email security changed")
+ }
+
+ var deduplicated: [String] = []
+ for highlight in highlights where !deduplicated.contains(highlight) {
+ deduplicated.append(highlight)
+ }
+ return deduplicated
+ }
+
+ private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String {
+ guard !items.isEmpty, !highlights.isEmpty else {
+ return "No meaningful changes"
+ }
+
+ if highlights.count == 1 {
+ return highlights[0]
+ }
+
+ return "\(highlights[0]) and \(highlights[1].lowercased())"
}
private static func normalized(_ value: String?) -> String? {
@@ -167,11 +375,9 @@ enum DomainDiffService {
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 expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? {
+ guard let sslInfo else { return nil }
+ return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
}
private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
@@ -194,18 +400,27 @@ enum DomainDiffService {
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: "|")
+ private static func normalizedRecordValues(for section: DNSSection?) -> String? {
+ guard let section else { return nil }
+ let values = (section.records + section.wildcardRecords)
+ .map(\.value)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .sorted()
+ return values.isEmpty ? nil : values.joined(separator: ",")
+ }
+
+ private static func normalizedTTLValues(for section: DNSSection?) -> String? {
+ guard let section else { return nil }
+ let values = (section.records + section.wildcardRecords)
+ .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" }
+ .sorted()
+ return values.isEmpty ? nil : values.joined(separator: ",")
+ }
+
+ private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? {
+ let headers = snapshot.httpHeaders
+ .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" }
+ .sorted()
+ return headers.isEmpty ? nil : headers.joined(separator: "|")
}
}