diff options
| author | Christian Cleberg <[email protected]> | 2026-04-26 00:39:28 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-26 00:39:28 -0500 |
| commit | 237a72a03102319638c5b0572ca3ab543238b821 (patch) | |
| tree | 21b34e8b69994ac58ef1c8c49c05bc4a4b65c3b7 /DomainDig | |
| parent | cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5 (diff) | |
| download | domain-dig-237a72a03102319638c5b0572ca3ab543238b821.tar.gz domain-dig-237a72a03102319638c5b0572ca3ab543238b821.tar.bz2 domain-dig-237a72a03102319638c5b0572ca3ab543238b821.zip | |
DomainDig v3.5.0: Expand the Pro+ Data+ intelligence layer with deeper local historical context
and inferred enrichment.
- add derived intelligence fields for provider fingerprinting, classification,
ownership transitions, hosting transitions, subdomain history, risk signals,
and inferred timeline events
- expand DNS history beyond A/NS snapshots to retain A, AAAA, MX, NS, TXT, and
CNAME change state
- persist enriched intelligence in snapshots and history entries so analysis is
local-first and incremental
- add a dedicated Data+ Intelligence panel to current and historical domain
detail views
- surface intelligence events in timeline rows and include Data+ changes in diff
output
- preserve non-blocking inspection behavior by keeping enrichment additive to
the main lookup path
This makes Pro+ materially deeper for investigative workflows by improving
historical ownership visibility, infrastructure context, hosting change
detection, subdomain intelligence, and explainable risk signals.
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/ContentView.swift | 133 | ||||
| -rw-r--r-- | DomainDig/DiffService.swift | 15 | ||||
| -rw-r--r-- | DomainDig/DomainMonitoringService.swift | 8 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 56 | ||||
| -rw-r--r-- | DomainDig/ExternalDataService.swift | 116 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 12 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 251 | ||||
| -rw-r--r-- | DomainDig/TimelineView.swift | 14 |
8 files changed, 569 insertions, 36 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 3716f88..5a469e1 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -11,6 +11,7 @@ enum LookupInputMode: String, CaseIterable, Identifiable { enum ResultSection: String, Hashable { case domain + case intelligence case ownership case dns case web @@ -78,6 +79,10 @@ struct ContentView: View { .padding(.top, appDensity.metrics.cardSpacing) } } + if let report = viewModel.currentReport { + intelligenceSection(report: report) + .padding(.top, appDensity.metrics.sectionSpacing) + } domainOverviewSection .padding(.top, appDensity.metrics.sectionSpacing) ownershipSection @@ -415,6 +420,14 @@ struct ContentView: View { ) } + private func intelligenceSection(report: DomainReport) -> some View { + IntelligenceSectionView( + isCollapsed: sectionCollapsedBinding(.intelligence), + report: report, + showsPlaceholder: FeatureAccessService.currentTier != .proPlus + ) + } + private var ownershipSection: some View { OwnershipSectionView( isCollapsed: sectionCollapsedBinding(.ownership), @@ -1558,6 +1571,126 @@ struct OwnershipSectionView: View { } } +struct IntelligenceSectionView: View { + @Environment(\.appDensity) private var appDensity + @Binding var isCollapsed: Bool + let report: DomainReport + let showsPlaceholder: Bool + + var body: some View { + CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) { + CardView(allowsHorizontalScroll: false) { + if showsPlaceholder { + MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false) + } else { + if let provider = report.inferredProvider { + intelligenceBlock(title: "Infrastructure") { + LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary)) + if !provider.evidence.isEmpty { + MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false) + } + if !report.priorProviders.isEmpty { + LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary)) + } + } + } + if let classification = report.domainClassification { + intelligenceBlock(title: "Classification") { + LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary)) + MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false) + } + } + intelligenceBlock(title: "Risk Signals") { + if report.riskSignals.isEmpty { + MessageRowView(text: "No material historical risk signals detected", isError: false) + } else { + ForEach(report.riskSignals.prefix(4)) { signal in + VStack(alignment: .leading, spacing: 3) { + Text(signal.title) + .font(appDensity.font(.caption, weight: .semibold)) + Text(signal.detail) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + intelligenceBlock(title: "Ownership History") { + if report.ownershipTransitions.isEmpty { + MessageRowView(text: "No ownership transitions observed locally", isError: false) + } else { + ForEach(report.ownershipTransitions.prefix(4)) { event in + intelligenceEventRow(date: event.date, title: event.summary) + } + } + } + intelligenceBlock(title: "Hosting History") { + if report.hostingTransitions.isEmpty { + MessageRowView(text: "No hosting transitions observed locally", isError: false) + } else { + ForEach(report.hostingTransitions.prefix(4)) { event in + intelligenceEventRow(date: event.date, title: event.summary) + } + } + } + intelligenceBlock(title: "Subdomain Intelligence") { + if report.subdomainHistory.isEmpty { + MessageRowView(text: "No subdomain history available", isError: false) + } else { + ForEach(report.subdomainHistory.prefix(5)) { item in + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(item.hostname) + .font(appDensity.font(.caption)) + Spacer() + if item.isEphemeral { + Text("Ephemeral") + .font(appDensity.font(.caption2)) + .foregroundStyle(.yellow) + } + } + Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + intelligenceBlock(title: "Timeline") { + if report.intelligenceTimeline.isEmpty { + MessageRowView(text: "No inferred intelligence events yet", isError: false) + } else { + ForEach(report.intelligenceTimeline.prefix(5)) { event in + intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)") + } + } + } + } + } + } + } + + @ViewBuilder + private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(appDensity.font(.subheadline, weight: .semibold)) + .foregroundStyle(.cyan) + content() + } + } + + private func intelligenceEventRow(date: Date, title: String) -> some View { + VStack(alignment: .leading, spacing: 3) { + Text(date.formatted(date: .abbreviated, time: .omitted)) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + Text(title) + .font(appDensity.font(.caption)) + } + } +} + struct SubdomainsSectionView: View { @Environment(\.appDensity) private var appDensity @Binding var isCollapsed: Bool diff --git a/DomainDig/DiffService.swift b/DomainDig/DiffService.swift index 948f470..211a16e 100644 --- a/DomainDig/DiffService.swift +++ b/DomainDig/DiffService.swift @@ -115,6 +115,7 @@ enum DiffService { emailSection(from: oldReport, to: newReport), networkSection(from: oldReport, to: newReport), subdomainsSection(from: oldReport, to: newReport), + intelligenceSection(from: oldReport, to: newReport), riskSection(from: oldReport, to: newReport) ] @@ -384,6 +385,20 @@ enum DiffService { ) } + private static func intelligenceSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { + DiffSection( + id: "intelligence", + title: "Data+ Intelligence", + items: [ + compare(id: "intel-provider", label: "Provider", oldValue: oldReport.inferredProvider?.name, newValue: newReport.inferredProvider?.name, severity: .medium), + compare(id: "intel-classification", label: "Classification", oldValue: oldReport.domainClassification?.kind.title, newValue: newReport.domainClassification?.kind.title, severity: .medium), + compare(id: "intel-hosting-history", label: "Hosting Transitions", oldValue: joined(oldReport.hostingTransitions.map(\.summary)), newValue: joined(newReport.hostingTransitions.map(\.summary)), severity: .medium), + compare(id: "intel-ownership-history", label: "Ownership Transitions", oldValue: joined(oldReport.ownershipTransitions.map(\.summary)), newValue: joined(newReport.ownershipTransitions.map(\.summary)), severity: .high), + compare(id: "intel-risk-signals", label: "Risk Signals", oldValue: joined(oldReport.riskSignals.map(\.title)), newValue: joined(newReport.riskSignals.map(\.title)), severity: .medium) + ].compactMap { $0 } + ) + } + private static func compare( id: String, label: String, diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift index 31b1505..3229fb7 100644 --- a/DomainDig/DomainMonitoringService.swift +++ b/DomainDig/DomainMonitoringService.swift @@ -910,6 +910,14 @@ final class DomainMonitoringService { ownershipError: previousSnapshot.ownershipError, ownershipHistory: previousSnapshot.ownershipHistory, ownershipHistoryError: previousSnapshot.ownershipHistoryError, + inferredProvider: previousSnapshot.inferredProvider, + priorProviders: previousSnapshot.priorProviders, + domainClassification: previousSnapshot.domainClassification, + ownershipTransitions: previousSnapshot.ownershipTransitions, + hostingTransitions: previousSnapshot.hostingTransitions, + subdomainHistory: previousSnapshot.subdomainHistory, + riskSignals: previousSnapshot.riskSignals, + intelligenceTimeline: previousSnapshot.intelligenceTimeline, ptrRecord: previousSnapshot.ptrRecord, ptrError: previousSnapshot.ptrError, redirectChain: previousSnapshot.redirectChain, diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 1d75221..88e2ec6 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -621,6 +621,14 @@ final class DomainViewModel { ownershipError: ownershipError, ownershipHistory: ownershipHistory, ownershipHistoryError: ownershipHistoryError, + inferredProvider: currentHistoryEntry?.inferredProvider ?? currentReport?.inferredProvider, + priorProviders: currentHistoryEntry?.priorProviders ?? currentReport?.priorProviders ?? [], + domainClassification: currentHistoryEntry?.domainClassification ?? currentReport?.domainClassification, + ownershipTransitions: currentHistoryEntry?.ownershipTransitions ?? currentReport?.ownershipTransitions ?? [], + hostingTransitions: currentHistoryEntry?.hostingTransitions ?? currentReport?.hostingTransitions ?? [], + subdomainHistory: currentHistoryEntry?.subdomainHistory ?? currentReport?.subdomainHistory ?? [], + riskSignals: currentHistoryEntry?.riskSignals ?? currentReport?.riskSignals ?? [], + intelligenceTimeline: currentHistoryEntry?.intelligenceTimeline ?? currentReport?.intelligenceTimeline ?? [], ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, @@ -1750,7 +1758,8 @@ final class DomainViewModel { for: snapshot.domain, trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id, replacingLatest: false - ) + ), + historyEntries: historyEntries(for: snapshot.domain) ) DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain) currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary @@ -1873,6 +1882,14 @@ final class DomainViewModel { ownershipError: previousSnapshot.ownershipError, ownershipHistory: previousSnapshot.ownershipHistory, ownershipHistoryError: previousSnapshot.ownershipHistoryError, + inferredProvider: previousSnapshot.inferredProvider, + priorProviders: previousSnapshot.priorProviders, + domainClassification: previousSnapshot.domainClassification, + ownershipTransitions: previousSnapshot.ownershipTransitions, + hostingTransitions: previousSnapshot.hostingTransitions, + subdomainHistory: previousSnapshot.subdomainHistory, + riskSignals: previousSnapshot.riskSignals, + intelligenceTimeline: previousSnapshot.intelligenceTimeline, ptrRecord: previousSnapshot.ptrRecord, ptrError: previousSnapshot.ptrError, redirectChain: previousSnapshot.redirectChain, @@ -2228,7 +2245,15 @@ final class DomainViewModel { ) -> HistoryEntry? { let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) + let domainHistoryEntries = history.filter { + $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame + } let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) + let intelligence = DomainIntelligenceService.derive( + snapshot: snapshot, + previousSnapshot: previousSnapshot, + historyEntries: domainHistoryEntries + ) let changeSummary = reuseCurrentAnalysis ? currentChangeSummary ?? snapshot.changeSummary : previousSnapshot.map { @@ -2262,7 +2287,11 @@ final class DomainViewModel { currentDiffSections = diffSections ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] if !reuseCurrentAnalysis { - currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot) + currentReport = reportBuilder.build( + from: snapshot, + previousSnapshot: previousSnapshot, + historyEntries: domainHistoryEntries + ) } } @@ -2280,6 +2309,14 @@ final class DomainViewModel { mtaSts: snapshot.emailSecurity?.mtaSts, ownership: snapshot.ownership, ownershipHistory: snapshot.ownershipHistory, + inferredProvider: intelligence.inferredProvider, + priorProviders: intelligence.priorProviders, + domainClassification: intelligence.domainClassification, + ownershipTransitions: intelligence.ownershipTransitions, + hostingTransitions: intelligence.hostingTransitions, + subdomainHistory: intelligence.subdomainHistory, + riskSignals: intelligence.riskSignals, + intelligenceTimeline: intelligence.timelineEvents, ptrRecord: snapshot.ptrRecord, redirectChain: snapshot.redirectChain, subdomains: snapshot.subdomains, @@ -3509,7 +3546,12 @@ final class DomainViewModel { } private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport { - reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext) + reportBuilder.build( + from: entry, + previousSnapshot: comparisonSnapshot(for: entry), + workflowContext: workflowContext, + historyEntries: historyEntries(for: entry.domain) + ) } private var activeWorkflowContext: DomainWorkflowContext? { @@ -3572,6 +3614,14 @@ final class DomainViewModel { ownershipError: nil, ownershipHistory: [], ownershipHistoryError: nil, + inferredProvider: nil, + priorProviders: [], + domainClassification: nil, + ownershipTransitions: [], + hostingTransitions: [], + subdomainHistory: [], + riskSignals: [], + intelligenceTimeline: [], ptrRecord: nil, ptrError: nil, redirectChain: [], diff --git a/DomainDig/ExternalDataService.swift b/DomainDig/ExternalDataService.swift index 5b4bf66..65f117e 100644 --- a/DomainDig/ExternalDataService.swift +++ b/DomainDig/ExternalDataService.swift @@ -364,6 +364,8 @@ actor ExternalDataService { summary: event["summary"] as? String ?? "DNS change observed", aRecords: event["a_records"] as? [String] ?? [], nameservers: event["nameservers"] as? [String] ?? [], + recordSnapshots: parseDNSRecordSnapshots(from: event), + changedRecordTypes: parseDNSChangedRecordTypes(from: event), source: event["source"] as? String ?? "Configured external history feed", isExternal: true ) @@ -459,46 +461,44 @@ actor ExternalDataService { .sorted { $0.timestamp < $1.timestamp } var events: [DNSHistoryEvent] = [] - var previousARecords: [String] = [] - var previousNameservers: [String] = [] + var previousRecordValues: [DNSRecordType: [String]] = [:] for entry in domainHistory { - let aRecords = Self.dnsValues(for: .A, in: entry.dnsSections) - let nameservers = Self.dnsValues(for: .NS, in: entry.dnsSections) - let summary = dnsSummaryChange( - previousARecords: previousARecords, - currentARecords: aRecords, - previousNameservers: previousNameservers, - currentNameservers: nameservers - ) + let currentRecordValues = Self.historyRecordValues(in: entry.dnsSections) + let changedRecordTypes = Self.changedRecordTypes(previous: previousRecordValues, current: currentRecordValues) + let summary = dnsSummaryChange(previous: previousRecordValues, current: currentRecordValues) if let summary { events.append( DNSHistoryEvent( date: entry.timestamp, summary: summary, - aRecords: aRecords, - nameservers: nameservers, + aRecords: currentRecordValues[.A] ?? [], + nameservers: currentRecordValues[.NS] ?? [], + recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) } + .sorted { $0.recordType.rawValue < $1.recordType.rawValue }, + changedRecordTypes: changedRecordTypes, source: "Local observations", isExternal: false ) ) } - previousARecords = aRecords - previousNameservers = nameservers + previousRecordValues = currentRecordValues } if events.isEmpty { - let currentARecords = Self.dnsValues(for: .A, in: dnsSections) - let currentNameservers = Self.dnsValues(for: .NS, in: dnsSections) - if !currentARecords.isEmpty || !currentNameservers.isEmpty { + let currentRecordValues = Self.historyRecordValues(in: dnsSections) + if !currentRecordValues.isEmpty { events.append( DNSHistoryEvent( date: Date(), summary: "Current DNS snapshot", - aRecords: currentARecords, - nameservers: currentNameservers, + aRecords: currentRecordValues[.A] ?? [], + nameservers: currentRecordValues[.NS] ?? [], + recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) } + .sorted { $0.recordType.rawValue < $1.recordType.rawValue }, + changedRecordTypes: Array(currentRecordValues.keys).sorted { $0.rawValue < $1.rawValue }, source: "Local observations", isExternal: false ) @@ -540,8 +540,7 @@ actor ExternalDataService { let duplicate = partialResult.contains { $0.date == event.date && $0.summary == event.summary - && $0.aRecords == event.aRecords - && $0.nameservers == event.nameservers + && compareDNSRecordSnapshots($0.recordSnapshots, event.recordSnapshots) } if !duplicate { partialResult.append(event) @@ -585,19 +584,18 @@ actor ExternalDataService { } private static func dnsSummaryChange( - previousARecords: [String], - currentARecords: [String], - previousNameservers: [String], - currentNameservers: [String] + previous: [DNSRecordType: [String]], + current: [DNSRecordType: [String]] ) -> String? { var changes: [String] = [] - if previousARecords != currentARecords, !currentARecords.isEmpty { - changes.append("A records changed") - } - if previousNameservers != currentNameservers, !currentNameservers.isEmpty { - changes.append("NS records changed") + for type in [DNSRecordType.A, .AAAA, .MX, .NS, .TXT, .CNAME] { + let previousValues = previous[type] ?? [] + let currentValues = current[type] ?? [] + if previousValues != currentValues, !currentValues.isEmpty { + changes.append("\(type.rawValue) records changed") + } } - if previousARecords.isEmpty && previousNameservers.isEmpty && (!currentARecords.isEmpty || !currentNameservers.isEmpty) { + if previous.isEmpty && !current.isEmpty { changes.append("Initial DNS observation") } return changes.isEmpty ? nil : changes.joined(separator: " • ") @@ -619,6 +617,62 @@ actor ExternalDataService { .sorted() ?? [] } + private func parseDNSRecordSnapshots(from event: [String: Any]) -> [DNSHistoryRecordSnapshot] { + if let snapshots = event["record_snapshots"] as? [[String: Any]] { + return snapshots.compactMap { item in + guard let typeName = item["type"] as? String, + let type = DNSRecordType(rawValue: typeName) else { + return nil + } + return DNSHistoryRecordSnapshot(recordType: type, values: item["values"] as? [String] ?? []) + } + } + var snapshots: [DNSHistoryRecordSnapshot] = [] + if let aRecords = event["a_records"] as? [String], !aRecords.isEmpty { + snapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords)) + } + if let nameservers = event["nameservers"] as? [String], !nameservers.isEmpty { + snapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers)) + } + return snapshots + } + + private func parseDNSChangedRecordTypes(from event: [String: Any]) -> [DNSRecordType] { + if let rawTypes = event["changed_record_types"] as? [String] { + return rawTypes.compactMap(DNSRecordType.init(rawValue:)) + } + return parseDNSRecordSnapshots(from: event).map(\.recordType) + } + + private static func historyRecordValues(in sections: [DNSSection]) -> [DNSRecordType: [String]] { + let trackedTypes: [DNSRecordType] = [.A, .AAAA, .MX, .NS, .TXT, .CNAME] + return trackedTypes.reduce(into: [DNSRecordType: [String]]()) { result, type in + let values = dnsValues(for: type, in: sections) + if !values.isEmpty { + result[type] = values + } + } + } + + private static func changedRecordTypes( + previous: [DNSRecordType: [String]], + current: [DNSRecordType: [String]] + ) -> [DNSRecordType] { + Array(Set(previous.keys).union(current.keys)) + .filter { previous[$0] != current[$0] } + .sorted { $0.rawValue < $1.rawValue } + } + + private static func compareDNSRecordSnapshots( + _ lhs: [DNSHistoryRecordSnapshot], + _ rhs: [DNSHistoryRecordSnapshot] + ) -> Bool { + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).allSatisfy { left, right in + left.recordType == right.recordType && left.values == right.values + } + } + private static let iso8601DateFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 3d6151c..316fcce 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -159,7 +159,11 @@ struct HistoryDetailView: View { } private var report: DomainReport { - DomainReportBuilder().build(from: entry, previousSnapshot: viewModel.comparisonSnapshot(for: entry)) + DomainReportBuilder().build( + from: entry, + previousSnapshot: viewModel.comparisonSnapshot(for: entry), + historyEntries: viewModel.historyEntries(for: entry.domain) + ) } private var trackedDomain: TrackedDomain? { @@ -176,6 +180,12 @@ struct HistoryDetailView: View { .padding(.top, 8) InsightsSummaryCardView(insights: report.insights) .padding(.top, 8) + IntelligenceSectionView( + isCollapsed: .constant(false), + report: report, + showsPlaceholder: FeatureAccessService.currentTier != .proPlus + ) + .padding(.top, 8) DomainSectionView( isCollapsed: .constant(false), rows: DomainViewModel.domainRows(from: snapshot), diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 8e1d494..376d264 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -473,6 +473,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { let summary: String let aRecords: [String] let nameservers: [String] + let recordSnapshots: [DNSHistoryRecordSnapshot] + let changedRecordTypes: [DNSRecordType] let source: String let isExternal: Bool @@ -482,6 +484,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { summary: String, aRecords: [String] = [], nameservers: [String] = [], + recordSnapshots: [DNSHistoryRecordSnapshot] = [], + changedRecordTypes: [DNSRecordType] = [], source: String, isExternal: Bool ) { @@ -490,9 +494,225 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { self.summary = summary self.aRecords = aRecords self.nameservers = nameservers + self.recordSnapshots = recordSnapshots + self.changedRecordTypes = changedRecordTypes self.source = source self.isExternal = isExternal } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + date = try container.decode(Date.self, forKey: .date) + summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "DNS change observed" + aRecords = try container.decodeIfPresent([String].self, forKey: .aRecords) ?? [] + nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? [] + let decodedRecordSnapshots = try container.decodeIfPresent([DNSHistoryRecordSnapshot].self, forKey: .recordSnapshots) ?? [] + if decodedRecordSnapshots.isEmpty { + var synthesizedSnapshots: [DNSHistoryRecordSnapshot] = [] + if !aRecords.isEmpty { + synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords)) + } + if !nameservers.isEmpty { + synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers)) + } + recordSnapshots = synthesizedSnapshots + } else { + recordSnapshots = decodedRecordSnapshots + } + changedRecordTypes = try container.decodeIfPresent([DNSRecordType].self, forKey: .changedRecordTypes) + ?? recordSnapshots.map(\.recordType) + source = try container.decodeIfPresent(String.self, forKey: .source) ?? "Unknown" + isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false + } +} + +struct DNSHistoryRecordSnapshot: Identifiable, Codable, Sendable, Equatable { + let id: UUID + let recordType: DNSRecordType + let values: [String] + + nonisolated init(id: UUID = UUID(), recordType: DNSRecordType, values: [String]) { + self.id = id + self.recordType = recordType + self.values = values + } + + static func == (lhs: DNSHistoryRecordSnapshot, rhs: DNSHistoryRecordSnapshot) -> Bool { + lhs.recordType == rhs.recordType && lhs.values == rhs.values + } +} + +struct InferredProviderFingerprint: Codable, Equatable, Sendable { + let name: String + let confidence: ConfidenceLevel + let evidence: [String] +} + +enum DomainClassificationKind: String, Codable, CaseIterable, Sendable { + case marketing + case app + case api + case auth + case docs + case staticSite = "static" + case infrastructure + case status + case unknown + + var title: String { + switch self { + case .staticSite: + return "Static" + default: + return rawValue.capitalized + } + } +} + +struct DomainClassificationSummary: Codable, Equatable, Sendable { + let kind: DomainClassificationKind + let confidence: ConfidenceLevel + let reasons: [String] +} + +struct OwnershipTransitionEvent: Identifiable, Codable, Equatable, Sendable { + let id: UUID + let date: Date + let summary: String + let previousRegistrar: String? + let currentRegistrar: String? + let previousRegistrant: String? + let currentRegistrant: String? + let previousNameservers: [String] + let currentNameservers: [String] + + nonisolated init( + id: UUID = UUID(), + date: Date, + summary: String, + previousRegistrar: String? = nil, + currentRegistrar: String? = nil, + previousRegistrant: String? = nil, + currentRegistrant: String? = nil, + previousNameservers: [String] = [], + currentNameservers: [String] = [] + ) { + self.id = id + self.date = date + self.summary = summary + self.previousRegistrar = previousRegistrar + self.currentRegistrar = currentRegistrar + self.previousRegistrant = previousRegistrant + self.currentRegistrant = currentRegistrant + self.previousNameservers = previousNameservers + self.currentNameservers = currentNameservers + } +} + +struct HostingTransitionEvent: Identifiable, Codable, Equatable, Sendable { + let id: UUID + let date: Date + let fromProvider: String + let toProvider: String + let summary: String + + nonisolated init(id: UUID = UUID(), date: Date, fromProvider: String, toProvider: String, summary: String) { + self.id = id + self.date = date + self.fromProvider = fromProvider + self.toProvider = toProvider + self.summary = summary + } +} + +struct SubdomainHistoryEntry: Identifiable, Codable, Equatable, Sendable { + let id: String + let hostname: String + let firstSeen: Date + let lastSeen: Date + let recurrenceCount: Int + let statusChangeCount: Int + let lastKnownStatus: String + let isEphemeral: Bool + + nonisolated init( + hostname: String, + firstSeen: Date, + lastSeen: Date, + recurrenceCount: Int, + statusChangeCount: Int, + lastKnownStatus: String, + isEphemeral: Bool + ) { + id = hostname.lowercased() + self.hostname = hostname + self.firstSeen = firstSeen + self.lastSeen = lastSeen + self.recurrenceCount = recurrenceCount + self.statusChangeCount = statusChangeCount + self.lastKnownStatus = lastKnownStatus + self.isEphemeral = isEphemeral + } +} + +struct IntelligenceRiskSignal: Identifiable, Codable, Equatable, Sendable { + let id: String + let title: String + let detail: String + let severity: ChangeSeverity + let firstObserved: Date? + let lastObserved: Date? + + nonisolated init( + id: String, + title: String, + detail: String, + severity: ChangeSeverity, + firstObserved: Date? = nil, + lastObserved: Date? = nil + ) { + self.id = id + self.title = title + self.detail = detail + self.severity = severity + self.firstObserved = firstObserved + self.lastObserved = lastObserved + } +} + +enum IntelligenceTimelineEventCategory: String, Codable, Sendable { + case ownership + case dns + case hosting + case subdomain + case classification + case risk +} + +struct IntelligenceTimelineEvent: Identifiable, Codable, Equatable, Sendable { + let id: UUID + let date: Date + let category: IntelligenceTimelineEventCategory + let title: String + let detail: String + let severity: ChangeSeverity + + nonisolated init( + id: UUID = UUID(), + date: Date, + category: IntelligenceTimelineEventCategory, + title: String, + detail: String, + severity: ChangeSeverity + ) { + self.id = id + self.date = date + self.category = category + self.title = title + self.detail = detail + self.severity = severity + } } struct DomainPricingInsight: Codable, Equatable, Sendable { @@ -2051,6 +2271,14 @@ struct HistoryEntry: Identifiable, Codable { var mtaSts: MTASTSResult? var ownership: DomainOwnership? var ownershipHistory: [DomainOwnershipHistoryEvent] + var inferredProvider: InferredProviderFingerprint? + var priorProviders: [String] + var domainClassification: DomainClassificationSummary? + var ownershipTransitions: [OwnershipTransitionEvent] + var hostingTransitions: [HostingTransitionEvent] + var subdomainHistory: [SubdomainHistoryEntry] + var riskSignals: [IntelligenceRiskSignal] + var intelligenceTimeline: [IntelligenceTimelineEvent] var ptrRecord: String? var redirectChain: [RedirectHop] var subdomains: [DiscoveredSubdomain] @@ -2106,6 +2334,13 @@ struct HistoryEntry: Identifiable, Codable { reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, ownershipHistory: [DomainOwnershipHistoryEvent] = [], + inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [], + domainClassification: DomainClassificationSummary? = nil, + ownershipTransitions: [OwnershipTransitionEvent] = [], + hostingTransitions: [HostingTransitionEvent] = [], + subdomainHistory: [SubdomainHistoryEntry] = [], + riskSignals: [IntelligenceRiskSignal] = [], + intelligenceTimeline: [IntelligenceTimelineEvent] = [], ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], domainPricing: DomainPricingInsight? = nil, @@ -2141,6 +2376,14 @@ struct HistoryEntry: Identifiable, Codable { self.mtaSts = mtaSts ?? emailSecurity?.mtaSts self.ownership = ownership self.ownershipHistory = ownershipHistory + self.inferredProvider = inferredProvider + self.priorProviders = priorProviders + self.domainClassification = domainClassification + self.ownershipTransitions = ownershipTransitions + self.hostingTransitions = hostingTransitions + self.subdomainHistory = subdomainHistory + self.riskSignals = riskSignals + self.intelligenceTimeline = intelligenceTimeline self.ptrRecord = ptrRecord self.redirectChain = redirectChain self.subdomains = subdomains @@ -2208,6 +2451,14 @@ struct HistoryEntry: Identifiable, Codable { mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? [] + inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider) + priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? [] + domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification) + ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? [] + hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? [] + subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? [] + riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? [] + intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? [] ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift index b163c76..8680d15 100644 --- a/DomainDig/TimelineView.swift +++ b/DomainDig/TimelineView.swift @@ -25,7 +25,7 @@ struct TimelineView: View { NavigationLink { HistoryDetailView(viewModel: viewModel, entry: entry) } label: { - TimelineRow(summary: summary) + TimelineRow(summary: summary, entry: entry) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button { @@ -102,6 +102,7 @@ struct TimelineView: View { private struct TimelineRow: View { @Environment(\.appDensity) private var appDensity let summary: SnapshotSummary + let entry: HistoryEntry var body: some View { VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { @@ -138,6 +139,17 @@ private struct TimelineRow: View { .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) + if !entry.intelligenceTimeline.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(entry.intelligenceTimeline.prefix(2))) { event in + Text("\(event.title): \(event.detail)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + HStack(spacing: 8) { if let primaryIP = summary.primaryIP { Text(primaryIP) |
