From d0b3b7a15b59266b4ae4262fdb0364245092c17c Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 24 Apr 2026 12:38:38 -0500 Subject: feat(v3.7.0): add timeline and advanced diffing system - introduce TimelineView for historical snapshots - allow comparison between any two snapshots - implement DiffService for structured domain diffs - improve diff visualization with clear change indicators - add navigation across changes - optimize history loading for performance - extend CLI and export to support timeline data --- DomainDig/DiffService.swift | 563 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 563 insertions(+) create mode 100644 DomainDig/DiffService.swift (limited to 'DomainDig/DiffService.swift') diff --git a/DomainDig/DiffService.swift b/DomainDig/DiffService.swift new file mode 100644 index 0000000..948f470 --- /dev/null +++ b/DomainDig/DiffService.swift @@ -0,0 +1,563 @@ +import Foundation + +enum DiffChangeType: String, Codable { + case added + case removed + case changed + case unchanged + + var marker: String { + switch self { + case .added: + return "+" + case .removed: + return "-" + case .changed: + return "~" + case .unchanged: + return "=" + } + } + + var title: String { + switch self { + case .added: + return "Added" + case .removed: + return "Removed" + case .changed: + return "Changed" + case .unchanged: + return "Unchanged" + } + } +} + +struct DiffItem: Identifiable, Equatable, Codable { + let id: String + let label: String + let changeType: DiffChangeType + let oldValue: String? + let newValue: String? + let severity: ChangeSeverity + + init( + id: String, + label: String, + changeType: DiffChangeType, + oldValue: String?, + newValue: String?, + severity: ChangeSeverity + ) { + self.id = id + self.label = label + self.changeType = changeType + self.oldValue = oldValue + self.newValue = newValue + self.severity = severity + } + + var hasChanges: Bool { + changeType != .unchanged + } +} + +struct DiffSection: Identifiable, Equatable, Codable { + let id: String + let title: String + let items: [DiffItem] + + var hasChanges: Bool { + items.contains(where: \.hasChanges) + } + + var severity: ChangeSeverity { + items.map(\.severity).max() ?? .low + } + + var changeCount: Int { + items.filter(\.hasChanges).count + } +} + +struct DomainDiff: Identifiable, Equatable, Codable { + let domain: String + let fromTimestamp: Date + let toTimestamp: Date + let sections: [DiffSection] + let changedSectionIDs: [String] + let changedSectionTitles: [String] + let contextNote: String? + + var id: String { + "\(domain)-\(fromTimestamp.timeIntervalSince1970)-\(toTimestamp.timeIntervalSince1970)" + } + + var changeCount: Int { + sections.reduce(0) { $0 + $1.changeCount } + } + + var severity: ChangeSeverity { + sections.map(\.severity).max() ?? .low + } +} + +typealias DomainDiffItem = DiffItem +typealias DomainDiffSection = DiffSection + +enum DiffService { + static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff { + let sections = [ + availabilitySection(from: oldReport, to: newReport), + ownershipSection(from: oldReport, to: newReport), + dnsSection(from: oldReport, to: newReport), + webSection(from: oldReport, to: newReport), + emailSection(from: oldReport, to: newReport), + networkSection(from: oldReport, to: newReport), + subdomainsSection(from: oldReport, to: newReport), + riskSection(from: oldReport, to: newReport) + ] + + let changedSections = sections.filter(\.hasChanges) + return DomainDiff( + domain: newReport.domain, + fromTimestamp: oldReport.timestamp, + toTimestamp: newReport.timestamp, + sections: sections, + changedSectionIDs: changedSections.map(\.id), + changedSectionTitles: changedSections.map(\.title), + contextNote: comparisonContextNote(from: oldReport, to: newReport) + ) + } + + static func compare(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiff { + let builder = DomainReportBuilder() + let oldReport = builder.build(from: oldSnapshot, deriveChangeSummary: false) + let newReport = builder.build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false) + return compare(from: oldReport, to: newReport) + } + + static func summary( + from oldSnapshot: LookupSnapshot, + to newSnapshot: LookupSnapshot, + generatedAt: Date = Date(), + riskAssessment: DomainRiskAssessment? = nil, + insights: [String]? = nil + ) -> DomainChangeSummary { + let diff = compare(from: oldSnapshot, to: newSnapshot) + let changedItems = diff.sections.flatMap(\.items).filter(\.hasChanges) + let highlights = diff.changedSectionTitles + let severity = changedItems.map(\.severity).max() ?? .low + let message = summaryMessage(from: highlights, changeCount: changedItems.count) + let observedFacts = changedItems.prefix(4).map { item in + "\(item.label): \(item.oldValue ?? "none") -> \(item.newValue ?? "none")" + } + + let analysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot) + let currentRiskAssessment = riskAssessment ?? analysis.riskAssessment + let currentInsights = insights ?? analysis.insights + let previousRiskScore = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment.score + let riskScoreDelta = currentRiskAssessment.score - previousRiskScore + let impactClassification = DomainInsightEngine.impactClassification( + severity: severity, + riskDelta: riskScoreDelta, + changedSections: highlights + ) + + return DomainChangeSummary( + hasChanges: !changedItems.isEmpty, + changedSections: highlights, + message: message, + severity: severity, + impactClassification: impactClassification, + generatedAt: generatedAt, + observedFacts: observedFacts, + inferredConclusions: highlights.isEmpty ? [] : [message], + contextNote: diff.contextNote, + riskAssessment: currentRiskAssessment, + insights: currentInsights, + riskScoreDelta: riskScoreDelta + ) + } + + static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? { + var notes: [String] = [] + if oldReport.resolverURLString != newReport.resolverURLString { + notes.append("Compared snapshots used different DNS resolvers.") + } + if oldReport.resultSource != newReport.resultSource { + notes.append("Compared snapshots came from different collection modes.") + } + return notes.isEmpty ? nil : notes.joined(separator: " ") + } + + static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { + comparisonContextNote( + from: DomainReportBuilder().build(from: oldSnapshot, deriveChangeSummary: false), + to: DomainReportBuilder().build(from: newSnapshot, previousSnapshot: oldSnapshot, deriveChangeSummary: false) + ) + } + + 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 oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "availability", + title: "Domain / Availability", + items: [ + compare(id: "domain", label: "Domain", oldValue: oldReport.domain, newValue: newReport.domain, severity: .low), + compare( + id: "availability", + label: "Availability", + oldValue: availabilityLabel(oldReport.availability), + newValue: availabilityLabel(newReport.availability), + severity: .high + ), + compare(id: "primary-ip", label: "Primary IP", oldValue: oldReport.dns.primaryIP, newValue: newReport.dns.primaryIP, severity: .high), + compare( + id: "tls-status", + label: "TLS Status", + oldValue: oldReport.web.tlsStatus, + newValue: newReport.web.tlsStatus, + severity: .medium + ) + ].compactMap { $0 } + ) + } + + private static func ownershipSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "ownership", + title: "Ownership", + items: [ + compare(id: "registrar", label: "Registrar", oldValue: oldReport.ownership?.registrar, newValue: newReport.ownership?.registrar, severity: .high), + compare(id: "registrant", label: "Registrant", oldValue: oldReport.ownership?.registrant, newValue: newReport.ownership?.registrant, severity: .medium), + compare( + id: "ownership-created", + label: "Registration Date", + oldValue: ownershipDateLabel(oldReport.ownership?.createdDate), + newValue: ownershipDateLabel(newReport.ownership?.createdDate), + severity: .low + ), + compare( + id: "ownership-expires", + label: "Expiration Date", + oldValue: ownershipDateLabel(oldReport.ownership?.expirationDate), + newValue: ownershipDateLabel(newReport.ownership?.expirationDate), + severity: .medium + ), + compare( + id: "ownership-status", + label: "Status", + oldValue: joined(oldReport.ownership?.status), + newValue: joined(newReport.ownership?.status), + severity: .low + ), + compare( + id: "ownership-nameservers", + label: "Nameservers", + oldValue: joined(oldReport.ownership?.nameservers), + newValue: joined(newReport.ownership?.nameservers), + severity: .medium + ), + compare(id: "ownership-abuse", label: "Abuse Contact", oldValue: oldReport.ownership?.abuseEmail, newValue: newReport.ownership?.abuseEmail, severity: .low) + ].compactMap { $0 } + ) + } + + private static func dnsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + let oldSections = Dictionary(uniqueKeysWithValues: oldReport.dns.recordSections.map { ($0.recordType, $0) }) + let newSections = Dictionary(uniqueKeysWithValues: newReport.dns.recordSections.map { ($0.recordType, $0) }) + let recordTypes = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue } + + var items: [DiffItem] = [ + compare(id: "dnssec", label: "DNSSEC", oldValue: dnssecLabel(oldReport.dns.dnssecSigned), newValue: dnssecLabel(newReport.dns.dnssecSigned), severity: .medium), + compare(id: "ptr", label: "PTR", oldValue: oldReport.dns.ptrRecord, newValue: newReport.dns.ptrRecord, severity: .low) + ].compactMap { $0 } + + for type in recordTypes { + items.append( + compare( + id: "dns-\(type.rawValue.lowercased())-records", + label: "\(type.rawValue) Records", + oldValue: normalizedRecordValues(for: oldSections[type]), + newValue: normalizedRecordValues(for: newSections[type]), + severity: type == .A || type == .NS ? .high : .medium + ) ?? DiffItem(id: "", label: "", changeType: .unchanged, oldValue: nil, newValue: nil, severity: .low) + ) + if let ttlChange = compare( + id: "dns-\(type.rawValue.lowercased())-ttl", + label: "\(type.rawValue) TTL", + oldValue: normalizedTTLValues(for: oldSections[type]), + newValue: normalizedTTLValues(for: newSections[type]), + severity: .low + ) { + items.append(ttlChange) + } + } + + return DiffSection( + id: "dns", + title: "DNS", + items: items.filter { !$0.id.isEmpty } + ) + } + + private static func webSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "web", + title: "Web", + items: [ + compare(id: "web-status", label: "HTTP Status", oldValue: oldReport.web.statusCode.map(String.init), newValue: newReport.web.statusCode.map(String.init), severity: .medium), + compare(id: "web-grade", label: "Security Grade", oldValue: oldReport.web.securityGrade, newValue: newReport.web.securityGrade, severity: .medium), + compare(id: "web-final-url", label: "Final URL", oldValue: oldReport.web.finalURL, newValue: newReport.web.finalURL, severity: .high), + compare(id: "web-tls-issuer", label: "TLS Issuer", oldValue: oldReport.web.tls?.issuer, newValue: newReport.web.tls?.issuer, severity: .medium), + compare(id: "web-tls-expiry", label: "TLS Expiration", oldValue: expirationLabel(oldReport.web.tls), newValue: expirationLabel(newReport.web.tls), severity: .medium), + compare(id: "web-headers", label: "Headers", oldValue: normalizedHeaders(oldReport.web.headers), newValue: normalizedHeaders(newReport.web.headers), severity: .low), + compare(id: "web-redirects", label: "Redirect Chain", oldValue: redirectChainSummary(oldReport.web.redirectChain), newValue: redirectChainSummary(newReport.web.redirectChain), severity: .medium) + ].compactMap { $0 } + ) + } + + private static func emailSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "email", + title: "Email Security", + items: [ + compare(id: "email-summary", label: "Summary", oldValue: oldReport.email.summary, newValue: newReport.email.summary, severity: .medium), + compare(id: "email-grade", label: "Grade", oldValue: oldReport.email.grade?.rawValue, newValue: newReport.email.grade?.rawValue, severity: .medium), + compare(id: "email-spf", label: "SPF", oldValue: recordLabel(oldReport.email.records?.spf), newValue: recordLabel(newReport.email.records?.spf), severity: .medium), + compare(id: "email-dmarc", label: "DMARC", oldValue: recordLabel(oldReport.email.records?.dmarc), newValue: recordLabel(newReport.email.records?.dmarc), severity: .high), + compare(id: "email-dkim", label: "DKIM", oldValue: recordLabel(oldReport.email.records?.dkim), newValue: recordLabel(newReport.email.records?.dkim), severity: .medium), + compare(id: "email-bimi", label: "BIMI", oldValue: recordLabel(oldReport.email.records?.bimi), newValue: recordLabel(newReport.email.records?.bimi), severity: .low), + compare(id: "email-mta-sts", label: "MTA-STS", oldValue: mtaStsLabel(oldReport.email.records?.mtaSts), newValue: mtaStsLabel(newReport.email.records?.mtaSts), severity: .medium) + ].compactMap { $0 } + ) + } + + private static func networkSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "network", + title: "Network", + items: [ + compare(id: "network-reachability", label: "Reachability", oldValue: oldReport.network.reachabilitySummary, newValue: newReport.network.reachabilitySummary, severity: .medium), + compare(id: "network-geolocation", label: "Geolocation", oldValue: oldReport.network.geolocationSummary, newValue: newReport.network.geolocationSummary, severity: .medium), + 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), + compare(id: "network-port-scan", label: "Port Scan", oldValue: portScanSummary(oldReport.network.portScan), newValue: portScanSummary(newReport.network.portScan), severity: .medium) + ].compactMap { $0 } + ) + } + + private static func subdomainsSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "subdomains", + title: "Subdomains", + items: [ + compare(id: "subdomains-primary", label: "Primary Subdomains", oldValue: joined(oldReport.subdomains), newValue: joined(newReport.subdomains), severity: .low), + compare(id: "subdomains-extended", label: "Extended Subdomains", oldValue: joined(oldReport.extendedSubdomains), newValue: joined(newReport.extendedSubdomains), severity: .low), + compare(id: "subdomains-groups", label: "Groups", oldValue: groupSummary(oldReport.subdomainGroups), newValue: groupSummary(newReport.subdomainGroups), severity: .low) + ].compactMap { $0 } + ) + } + + private static func riskSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "risk", + title: "Risk / Insights", + items: [ + compare(id: "risk-score", label: "Risk Score", oldValue: "\(oldReport.riskAssessment.score)", newValue: "\(newReport.riskAssessment.score)", severity: .high), + compare(id: "risk-level", label: "Risk Level", oldValue: oldReport.riskAssessment.level.title, newValue: newReport.riskAssessment.level.title, severity: .high), + compare(id: "risk-factors", label: "Risk Factors", oldValue: joined(oldReport.riskAssessment.factors.map(\.description)), newValue: joined(newReport.riskAssessment.factors.map(\.description)), severity: .medium), + compare(id: "risk-insights", label: "Insights", oldValue: joined(oldReport.insights), newValue: joined(newReport.insights), severity: .medium) + ].compactMap { $0 } + ) + } + + private static func compare( + id: String, + label: String, + oldValue: String?, + newValue: String?, + severity: ChangeSeverity + ) -> DiffItem? { + let oldValue = normalized(oldValue) + let newValue = normalized(newValue) + + guard oldValue != nil || newValue != nil else { + return nil + } + + let changeType: DiffChangeType + switch (oldValue?.lowercased(), newValue?.lowercased()) { + case let (old?, new?) where old == new: + changeType = .unchanged + case (nil, _?): + changeType = .added + case (_?, nil): + changeType = .removed + default: + changeType = .changed + } + + return DiffItem( + id: id, + label: label, + changeType: changeType, + oldValue: oldValue, + newValue: newValue, + severity: severity + ) + } + + static func summaryMessage(from sectionTitles: [String], changeCount: Int) -> String { + guard !sectionTitles.isEmpty else { + return "No meaningful changes" + } + if sectionTitles.count == 1 { + return "\(sectionTitles[0]) changed" + } + return "\(sectionTitles[0]) and \(sectionTitles[1].lowercased()) changed (\(changeCount) items)" + } + + private static func normalized(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown: + return "Unknown" + } + } + + private static func ownershipDateLabel(_ date: Date?) -> String? { + date?.formatted(date: .abbreviated, time: .omitted) + } + + private static func expirationLabel(_ certificate: SSLCertificateInfo?) -> String? { + guard let certificate else { return nil } + return "\(certificate.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(certificate.daysUntilExpiry)d)" + } + + private static func joined(_ values: [String]?) -> String? { + guard let values else { return nil } + let normalizedValues = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .sorted() + return normalizedValues.isEmpty ? nil : normalizedValues.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.lowercased()):\($0.ttl)" } + .sorted() + return values.isEmpty ? nil : values.joined(separator: ", ") + } + + private static func normalizedHeaders(_ headers: [HTTPHeader]) -> String? { + let values = headers + .map { "\($0.name.lowercased()): \($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" } + .sorted() + return values.isEmpty ? nil : values.joined(separator: " | ") + } + + private static func redirectChainSummary(_ redirects: [RedirectHop]) -> String? { + let values = redirects.map { "\($0.statusCode) \($0.url)" } + return values.isEmpty ? nil : values.joined(separator: " -> ") + } + + private static func portScanSummary(_ results: [PortScanResult]) -> String? { + let values = results + .sorted { $0.port < $1.port } + .map { "\($0.port):\($0.open ? "open" : "closed")" } + return values.isEmpty ? nil : values.joined(separator: ", ") + } + + private static func groupSummary(_ groups: [SubdomainGroup]) -> String? { + joined(groups.map { "\($0.label): \($0.subdomains.count)" }) + } + + private static func recordLabel(_ record: EmailSecurityRecord?) -> String? { + guard let record else { return nil } + if record.found { + return record.value ?? "Present" + } + return "Missing" + } + + private static func mtaStsLabel(_ result: MTASTSResult?) -> String? { + guard let result else { return nil } + guard result.txtFound else { return "Missing" } + return result.policyMode ?? "Present" + } + + private static func dnssecLabel(_ value: Bool?) -> String? { + switch value { + case true: + return "Signed" + case false: + return "Unsigned" + case nil: + return nil + } + } +} + +enum DomainDiffService { + static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { + DiffService.compare(from: oldSnapshot, to: newSnapshot).sections + } + + static func summary( + from oldSnapshot: LookupSnapshot, + to newSnapshot: LookupSnapshot, + generatedAt: Date = Date(), + riskAssessment: DomainRiskAssessment? = nil, + insights: [String]? = nil + ) -> DomainChangeSummary { + DiffService.summary( + from: oldSnapshot, + to: newSnapshot, + generatedAt: generatedAt, + riskAssessment: riskAssessment, + insights: insights + ) + } + + static func comparisonContextNote(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> String? { + DiffService.comparisonContextNote(from: oldSnapshot, to: newSnapshot) + } + + static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { + DiffService.certificateWarningLevel(for: snapshot) + } +} -- cgit v1.2.3