summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-26 00:39:28 -0500
committerChristian Cleberg <[email protected]>2026-04-26 00:39:28 -0500
commit237a72a03102319638c5b0572ca3ab543238b821 (patch)
tree21b34e8b69994ac58ef1c8c49c05bc4a4b65c3b7 /DomainDig
parentcc69cbd7e589ec4b065ce74d8c5e7714a040cfc5 (diff)
downloaddomain-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.swift133
-rw-r--r--DomainDig/DiffService.swift15
-rw-r--r--DomainDig/DomainMonitoringService.swift8
-rw-r--r--DomainDig/DomainViewModel.swift56
-rw-r--r--DomainDig/ExternalDataService.swift116
-rw-r--r--DomainDig/HistoryView.swift12
-rw-r--r--DomainDig/Models.swift251
-rw-r--r--DomainDig/TimelineView.swift14
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)