summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-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
-rw-r--r--DomainDigCLI.swift8
-rw-r--r--DomainInspectionService.swift8
-rw-r--r--DomainReportBuilder.swift457
-rw-r--r--LookupSnapshot.swift16
13 files changed, 1061 insertions, 41 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 02634ff..29813b0 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -378,7 +378,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 34;
+ CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -395,7 +395,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 4.2.0;
+ MARKETING_VERSION = 4.3.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -415,7 +415,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 34;
+ CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -432,7 +432,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 4.2.0;
+ MARKETING_VERSION = 4.3.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
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)
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
index 00944ee..00713fd 100644
--- a/DomainDigCLI.swift
+++ b/DomainDigCLI.swift
@@ -219,6 +219,14 @@ struct DomainDigCLI {
ownershipError: snapshot.ownershipError,
ownershipHistory: ownershipHistory,
ownershipHistoryError: ownershipHistoryError,
+ inferredProvider: snapshot.inferredProvider,
+ priorProviders: snapshot.priorProviders,
+ domainClassification: snapshot.domainClassification,
+ ownershipTransitions: snapshot.ownershipTransitions,
+ hostingTransitions: snapshot.hostingTransitions,
+ subdomainHistory: snapshot.subdomainHistory,
+ riskSignals: snapshot.riskSignals,
+ intelligenceTimeline: snapshot.intelligenceTimeline,
ptrRecord: snapshot.ptrRecord,
ptrError: snapshot.ptrError,
redirectChain: snapshot.redirectChain,
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift
index 55e87f5..0562195 100644
--- a/DomainInspectionService.swift
+++ b/DomainInspectionService.swift
@@ -349,6 +349,14 @@ struct DomainInspectionService {
ownershipError: ownership.message,
ownershipHistory: [],
ownershipHistoryError: nil,
+ inferredProvider: nil,
+ priorProviders: [],
+ domainClassification: nil,
+ ownershipTransitions: [],
+ hostingTransitions: [],
+ subdomainHistory: [],
+ riskSignals: [],
+ intelligenceTimeline: [],
ptrRecord: ptrRecord.value,
ptrError: ptrRecord.message,
redirectChain: redirectChain.value,
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index afed9db..50c46db 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -22,6 +22,14 @@ struct DomainReport: Codable {
let geolocationConfidence: ConfidenceLevel?
let ownership: DomainOwnership?
let ownershipHistory: [DomainOwnershipHistoryEvent]
+ let inferredProvider: InferredProviderFingerprint?
+ let priorProviders: [String]
+ let domainClassification: DomainClassificationSummary?
+ let ownershipTransitions: [OwnershipTransitionEvent]
+ let hostingTransitions: [HostingTransitionEvent]
+ let subdomainHistory: [SubdomainHistoryEntry]
+ let riskSignals: [IntelligenceRiskSignal]
+ let intelligenceTimeline: [IntelligenceTimelineEvent]
let dns: DNSResultSummary
let web: WebResultSummary
let email: EmailSecuritySummary
@@ -133,6 +141,7 @@ struct DomainReportBuilder {
from snapshot: LookupSnapshot,
previousSnapshot: LookupSnapshot? = nil,
workflowContext: DomainWorkflowContext? = nil,
+ historyEntries: [HistoryEntry] = [],
deriveChangeSummary: Bool = true
) -> DomainReport {
let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain)
@@ -145,12 +154,14 @@ struct DomainReportBuilder {
let previousReport = build(
from: previousSnapshot,
workflowContext: workflowContext,
+ historyEntries: historyEntries,
deriveChangeSummary: false
)
let currentReport = buildBaseReport(
from: snapshot,
previousSnapshot: previousSnapshot,
workflowContext: workflowContext,
+ historyEntries: historyEntries,
analysis: analysis,
primaryIP: primaryIP,
changeSummary: nil as DomainChangeSummary?
@@ -193,6 +204,7 @@ struct DomainReportBuilder {
from: snapshot,
previousSnapshot: previousSnapshot,
workflowContext: workflowContext,
+ historyEntries: historyEntries,
analysis: analysis,
primaryIP: primaryIP,
changeSummary: changeSummary
@@ -210,12 +222,14 @@ struct DomainReportBuilder {
from entry: HistoryEntry,
previousSnapshot: LookupSnapshot? = nil,
workflowContext: DomainWorkflowContext? = nil,
+ historyEntries: [HistoryEntry] = [],
deriveChangeSummary: Bool = true
) -> DomainReport {
build(
from: entry.snapshot,
previousSnapshot: previousSnapshot,
workflowContext: workflowContext,
+ historyEntries: historyEntries,
deriveChangeSummary: deriveChangeSummary
)
}
@@ -224,10 +238,16 @@ struct DomainReportBuilder {
from snapshot: LookupSnapshot,
previousSnapshot: LookupSnapshot?,
workflowContext: DomainWorkflowContext?,
+ historyEntries: [HistoryEntry],
analysis: DomainAnalysisBundle,
primaryIP: String?,
changeSummary: DomainChangeSummary?
) -> DomainReport {
+ let intelligence = DomainIntelligenceService.derive(
+ snapshot: snapshot,
+ previousSnapshot: previousSnapshot,
+ historyEntries: historyEntries
+ )
let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot)
let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0
let instabilityScore = DomainHealth.instabilityScore(
@@ -277,6 +297,14 @@ struct DomainReportBuilder {
geolocationConfidence: snapshot.geolocationConfidence,
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,
dns: DNSResultSummary(
resolverDisplayName: snapshot.resolverDisplayName,
resolverURLString: snapshot.resolverURLString,
@@ -343,7 +371,7 @@ struct DomainReportBuilder {
certificateExpiryState: certificateExpiryState,
workflowContext: workflowContext,
metadata: DomainReportMetadata(
- schemaVersion: "3.7.0",
+ schemaVersion: "4.3.0",
resolverDisplayName: snapshot.resolverDisplayName,
resolverURLString: snapshot.resolverURLString,
appVersion: snapshot.appVersion,
@@ -421,3 +449,430 @@ struct DomainReportBuilder {
return geolocation.ip
}
}
+
+struct DerivedDomainIntelligence {
+ let inferredProvider: InferredProviderFingerprint?
+ let priorProviders: [String]
+ let domainClassification: DomainClassificationSummary?
+ let ownershipTransitions: [OwnershipTransitionEvent]
+ let hostingTransitions: [HostingTransitionEvent]
+ let subdomainHistory: [SubdomainHistoryEntry]
+ let riskSignals: [IntelligenceRiskSignal]
+ let timelineEvents: [IntelligenceTimelineEvent]
+}
+
+enum DomainIntelligenceService {
+ static func derive(
+ snapshot: LookupSnapshot,
+ previousSnapshot: LookupSnapshot? = nil,
+ historyEntries: [HistoryEntry]
+ ) -> DerivedDomainIntelligence {
+ let orderedHistory = historyEntries
+ .filter { $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame }
+ .sorted { $0.timestamp < $1.timestamp }
+ let observationSnapshots = mergeObservations(historyEntries: orderedHistory, currentSnapshot: snapshot)
+ let providerObservations = observationSnapshots.compactMap { observation -> (Date, InferredProviderFingerprint)? in
+ inferProvider(from: observation).map { (observation.timestamp, $0) }
+ }
+ let currentProvider = inferProvider(from: snapshot)
+ let priorProviders = Array(Set(providerObservations.dropLast().map { $0.1.name })).sorted()
+ let ownershipTransitions = ownershipTransitions(from: observationSnapshots)
+ let hostingTransitions = hostingTransitions(from: providerObservations)
+ let currentClassification = classify(snapshot: snapshot)
+ let subdomainHistory = buildSubdomainHistory(from: observationSnapshots)
+ let riskSignals = buildRiskSignals(
+ snapshot: snapshot,
+ previousSnapshot: previousSnapshot,
+ ownershipTransitions: ownershipTransitions,
+ hostingTransitions: hostingTransitions,
+ subdomainHistory: subdomainHistory
+ )
+ let timelineEvents = buildTimelineEvents(
+ snapshot: snapshot,
+ observations: observationSnapshots,
+ providerObservations: providerObservations,
+ ownershipTransitions: ownershipTransitions,
+ hostingTransitions: hostingTransitions,
+ riskSignals: riskSignals
+ )
+ return DerivedDomainIntelligence(
+ inferredProvider: currentProvider,
+ priorProviders: priorProviders,
+ domainClassification: currentClassification,
+ ownershipTransitions: ownershipTransitions,
+ hostingTransitions: hostingTransitions,
+ subdomainHistory: subdomainHistory,
+ riskSignals: riskSignals,
+ timelineEvents: timelineEvents
+ )
+ }
+
+ private static func mergeObservations(historyEntries: [HistoryEntry], currentSnapshot: LookupSnapshot) -> [LookupSnapshot] {
+ var snapshots = historyEntries.map(\.snapshot)
+ let alreadyIncluded = snapshots.contains {
+ $0.timestamp == currentSnapshot.timestamp && $0.domain.caseInsensitiveCompare(currentSnapshot.domain) == .orderedSame
+ }
+ if !alreadyIncluded {
+ snapshots.append(currentSnapshot)
+ }
+ return snapshots.sorted { $0.timestamp < $1.timestamp }
+ }
+
+ private static func inferProvider(from snapshot: LookupSnapshot) -> InferredProviderFingerprint? {
+ let headerMap = Dictionary(uniqueKeysWithValues: snapshot.httpHeaders.map { ($0.name.lowercased(), $0.value.lowercased()) })
+ let dnsProviders = dnsValues(for: .NS, in: snapshot.dnsSections) + dnsValues(for: .CNAME, in: snapshot.dnsSections)
+ let issuer = snapshot.sslInfo?.issuer.lowercased() ?? ""
+ let org = snapshot.ipGeolocation?.org?.lowercased() ?? ""
+
+ if headerMap["cf-ray"] != nil || containsAny(in: dnsProviders, matching: ["cloudflare"]) || issuer.contains("cloudflare") {
+ return provider("Cloudflare", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["cf-ray", "cloudflare"]))
+ }
+ if headerMap["x-vercel-id"] != nil || containsAny(in: dnsProviders, matching: ["vercel"]) {
+ return provider("Vercel", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-vercel-id", "vercel"]))
+ }
+ if containsHeaderValue(headerMap, value: "netlify") || containsAny(in: dnsProviders, matching: ["netlify"]) {
+ return provider("Netlify", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["netlify"]))
+ }
+ if containsHeaderValue(headerMap, value: "fastly") || headerMap["x-served-by"]?.contains("cache") == true {
+ return provider("Fastly", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["fastly", "x-served-by"]))
+ }
+ if headerMap["x-amz-cf-id"] != nil || containsHeaderValue(headerMap, value: "cloudfront") || containsAny(in: dnsProviders, matching: ["cloudfront.net"]) {
+ return provider("CloudFront", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-amz-cf-id", "cloudfront"]))
+ }
+ if containsAny(in: dnsProviders, matching: ["awsdns", "amazonaws.com"]) || org.contains("amazon") {
+ return provider("AWS", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["awsdns", "amazon"]))
+ }
+ if containsAny(in: dnsProviders, matching: ["github.io"]) || containsHeaderValue(headerMap, value: "github") {
+ return provider("GitHub Pages", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["github"]))
+ }
+ return nil
+ }
+
+ private static func classify(snapshot: LookupSnapshot) -> DomainClassificationSummary? {
+ let host = snapshot.domain.lowercased()
+ let headerValues = snapshot.httpHeaders.map { "\($0.name.lowercased()):\($0.value.lowercased())" }
+ let finalURL = snapshot.redirectChain.last?.url.lowercased() ?? ""
+
+ if host.hasPrefix("api.") || host.contains(".api.") {
+ return .init(kind: .api, confidence: .high, reasons: ["Hostname pattern"])
+ }
+ if containsAny(in: [host, finalURL], matching: ["auth", "login", "sso", "oauth"]) {
+ return .init(kind: .auth, confidence: .high, reasons: ["Auth-oriented host or redirect"])
+ }
+ if containsAny(in: [host, finalURL], matching: ["docs", "developer", "developers", "help"]) {
+ return .init(kind: .docs, confidence: .high, reasons: ["Docs-oriented host or redirect"])
+ }
+ if containsAny(in: [host, finalURL], matching: ["status", "statuspage", "health"]) {
+ return .init(kind: .status, confidence: .medium, reasons: ["Status-oriented host or redirect"])
+ }
+ if containsAny(in: [host], matching: ["cdn.", "static.", "assets.", "img."]) {
+ return .init(kind: .staticSite, confidence: .medium, reasons: ["Static asset hostname"])
+ }
+ if containsAny(in: [host], matching: ["app.", "portal.", "admin.", "dashboard."]) {
+ return .init(kind: .app, confidence: .medium, reasons: ["Application hostname"])
+ }
+ if containsAny(in: [host], matching: ["vpn.", "internal.", "infra."]) || headerValues.contains(where: { $0.contains("x-envoy") }) {
+ return .init(kind: .infrastructure, confidence: .medium, reasons: ["Infrastructure-oriented hostname or headers"])
+ }
+ if host == apexDomain(for: host) || host.hasPrefix("www.") {
+ return .init(kind: .marketing, confidence: .low, reasons: ["Apex or www host"])
+ }
+ return nil
+ }
+
+ private static func ownershipTransitions(from snapshots: [LookupSnapshot]) -> [OwnershipTransitionEvent] {
+ zip(snapshots, snapshots.dropFirst()).compactMap { previous, current in
+ guard let previousOwnership = previous.ownership, let currentOwnership = current.ownership else {
+ return nil
+ }
+ var changeParts: [String] = []
+ if previousOwnership.registrar != currentOwnership.registrar {
+ changeParts.append("registrar")
+ }
+ if previousOwnership.registrant != currentOwnership.registrant {
+ changeParts.append("ownership")
+ }
+ if normalized(previousOwnership.nameservers) != normalized(currentOwnership.nameservers) {
+ changeParts.append("nameservers")
+ }
+ guard !changeParts.isEmpty else { return nil }
+ return OwnershipTransitionEvent(
+ date: current.timestamp,
+ summary: "Changed \(changeParts.joined(separator: ", "))",
+ previousRegistrar: previousOwnership.registrar,
+ currentRegistrar: currentOwnership.registrar,
+ previousRegistrant: previousOwnership.registrant,
+ currentRegistrant: currentOwnership.registrant,
+ previousNameservers: previousOwnership.nameservers,
+ currentNameservers: currentOwnership.nameservers
+ )
+ }
+ .sorted { $0.date > $1.date }
+ }
+
+ private static func hostingTransitions(from observations: [(Date, InferredProviderFingerprint)]) -> [HostingTransitionEvent] {
+ zip(observations, observations.dropFirst()).compactMap { previous, current in
+ guard previous.1.name != current.1.name else { return nil }
+ return HostingTransitionEvent(
+ date: current.0,
+ fromProvider: previous.1.name,
+ toProvider: current.1.name,
+ summary: "Hosting moved from \(previous.1.name) to \(current.1.name)"
+ )
+ }
+ .sorted { $0.date > $1.date }
+ }
+
+ private static func buildSubdomainHistory(from snapshots: [LookupSnapshot]) -> [SubdomainHistoryEntry] {
+ struct WorkingState {
+ var firstSeen: Date
+ var lastSeen: Date
+ var recurrenceCount: Int
+ var statusChangeCount: Int
+ var lastSeenInPreviousSnapshot: Bool
+ }
+
+ var states: [String: WorkingState] = [:]
+ for snapshot in snapshots {
+ let currentHosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() })
+ let knownHosts = Set(states.keys).union(currentHosts)
+ for host in knownHosts {
+ let isPresent = currentHosts.contains(host)
+ if var state = states[host] {
+ if isPresent {
+ state.lastSeen = snapshot.timestamp
+ state.recurrenceCount += 1
+ }
+ if state.lastSeenInPreviousSnapshot != isPresent {
+ state.statusChangeCount += 1
+ }
+ state.lastSeenInPreviousSnapshot = isPresent
+ states[host] = state
+ } else if isPresent {
+ states[host] = WorkingState(
+ firstSeen: snapshot.timestamp,
+ lastSeen: snapshot.timestamp,
+ recurrenceCount: 1,
+ statusChangeCount: 0,
+ lastSeenInPreviousSnapshot: true
+ )
+ }
+ }
+ }
+
+ return states.map { host, state in
+ let isEphemeral = state.recurrenceCount <= 2 || state.statusChangeCount >= 2
+ return SubdomainHistoryEntry(
+ hostname: host,
+ firstSeen: state.firstSeen,
+ lastSeen: state.lastSeen,
+ recurrenceCount: state.recurrenceCount,
+ statusChangeCount: state.statusChangeCount,
+ lastKnownStatus: state.lastSeenInPreviousSnapshot ? "Active" : "Inactive",
+ isEphemeral: isEphemeral
+ )
+ }
+ .sorted { lhs, rhs in
+ if lhs.isEphemeral != rhs.isEphemeral {
+ return lhs.isEphemeral && !rhs.isEphemeral
+ }
+ return lhs.hostname < rhs.hostname
+ }
+ }
+
+ private static func buildRiskSignals(
+ snapshot: LookupSnapshot,
+ previousSnapshot: LookupSnapshot?,
+ ownershipTransitions: [OwnershipTransitionEvent],
+ hostingTransitions: [HostingTransitionEvent],
+ subdomainHistory: [SubdomainHistoryEntry]
+ ) -> [IntelligenceRiskSignal] {
+ var signals: [IntelligenceRiskSignal] = []
+ let dnsInstabilityCount = snapshot.dnsHistory.filter { !$0.changedRecordTypes.isEmpty }.count
+ let ephemeralSubdomains = subdomainHistory.filter(\.isEphemeral)
+
+ if ownershipTransitions.count >= 2 {
+ signals.append(.init(
+ id: "ownership-churn",
+ title: "Ownership churn",
+ detail: "Observed \(ownershipTransitions.count) ownership transitions in local history.",
+ severity: .high,
+ firstObserved: ownershipTransitions.last?.date,
+ lastObserved: ownershipTransitions.first?.date
+ ))
+ }
+ if dnsInstabilityCount >= 3 {
+ signals.append(.init(
+ id: "unstable-dns",
+ title: "Unstable DNS",
+ detail: "DNS history shows \(dnsInstabilityCount) recorded change events.",
+ severity: .medium,
+ firstObserved: snapshot.dnsHistory.last?.date,
+ lastObserved: snapshot.dnsHistory.first?.date
+ ))
+ }
+ if hostingTransitions.count >= 2 {
+ signals.append(.init(
+ id: "repeated-hosting-moves",
+ title: "Repeated hosting moves",
+ detail: "Infrastructure provider changed \(hostingTransitions.count) times across observations.",
+ severity: .medium,
+ firstObserved: hostingTransitions.last?.date,
+ lastObserved: hostingTransitions.first?.date
+ ))
+ }
+ if !ephemeralSubdomains.isEmpty {
+ signals.append(.init(
+ id: "ephemeral-subdomains",
+ title: "Ephemeral subdomains",
+ detail: "\(ephemeralSubdomains.count) subdomains appear short-lived or unstable.",
+ severity: ephemeralSubdomains.count >= 3 ? .medium : .low,
+ firstObserved: ephemeralSubdomains.map(\.firstSeen).min(),
+ lastObserved: ephemeralSubdomains.map(\.lastSeen).max()
+ ))
+ }
+ if let createdDate = snapshot.ownership?.createdDate {
+ let ageDays = Calendar.current.dateComponents([.day], from: createdDate, to: snapshot.timestamp).day ?? 0
+ if ageDays <= 180 {
+ signals.append(.init(
+ id: "young-registration",
+ title: "Short registration age",
+ detail: "Domain registration is \(ageDays) days old.",
+ severity: ageDays <= 90 ? .high : .medium,
+ firstObserved: createdDate,
+ lastObserved: snapshot.timestamp
+ ))
+ }
+ }
+ if let previousSnapshot,
+ let previousProvider = inferProvider(from: previousSnapshot)?.name,
+ let currentProvider = inferProvider(from: snapshot)?.name,
+ previousProvider != currentProvider {
+ signals.append(.init(
+ id: "recent-hosting-move",
+ title: "Recent hosting move",
+ detail: "Latest snapshot moved from \(previousProvider) to \(currentProvider).",
+ severity: .medium,
+ firstObserved: snapshot.timestamp,
+ lastObserved: snapshot.timestamp
+ ))
+ }
+ return signals.sorted { ($0.lastObserved ?? .distantPast) > ($1.lastObserved ?? .distantPast) }
+ }
+
+ private static func buildTimelineEvents(
+ snapshot: LookupSnapshot,
+ observations: [LookupSnapshot],
+ providerObservations: [(Date, InferredProviderFingerprint)],
+ ownershipTransitions: [OwnershipTransitionEvent],
+ hostingTransitions: [HostingTransitionEvent],
+ riskSignals: [IntelligenceRiskSignal]
+ ) -> [IntelligenceTimelineEvent] {
+ var events: [IntelligenceTimelineEvent] = []
+
+ events += ownershipTransitions.map {
+ .init(date: $0.date, category: .ownership, title: "Ownership transition", detail: $0.summary, severity: .high)
+ }
+ events += snapshot.dnsHistory.map {
+ .init(date: $0.date, category: .dns, title: "DNS change", detail: $0.summary, severity: $0.changedRecordTypes.contains(.A) || $0.changedRecordTypes.contains(.NS) ? .high : .medium)
+ }
+ events += hostingTransitions.map {
+ .init(date: $0.date, category: .hosting, title: "Hosting transition", detail: $0.summary, severity: .medium)
+ }
+ events += buildClassificationEvents(from: observations)
+ events += buildSubdomainDiscoveryEvents(from: observations)
+ events += riskSignals.compactMap {
+ guard let date = $0.lastObserved ?? $0.firstObserved else { return nil }
+ return IntelligenceTimelineEvent(date: date, category: .risk, title: $0.title, detail: $0.detail, severity: $0.severity)
+ }
+ if let latestProvider = providerObservations.last?.1 {
+ events.append(.init(
+ date: snapshot.timestamp,
+ category: .hosting,
+ title: "Current infrastructure",
+ detail: "Likely running on \(latestProvider.name)",
+ severity: .low
+ ))
+ }
+ return events.sorted { $0.date > $1.date }
+ }
+
+ private static func buildClassificationEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] {
+ let classifications = observations.compactMap { snapshot -> (Date, DomainClassificationSummary)? in
+ classify(snapshot: snapshot).map { (snapshot.timestamp, $0) }
+ }
+ return zip(classifications, classifications.dropFirst()).compactMap { previous, current in
+ guard previous.1.kind != current.1.kind else { return nil }
+ return .init(
+ date: current.0,
+ category: .classification,
+ title: "Classification changed",
+ detail: "\(previous.1.kind.title) -> \(current.1.kind.title)",
+ severity: .medium
+ )
+ }
+ }
+
+ private static func buildSubdomainDiscoveryEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] {
+ var seen = Set<String>()
+ var events: [IntelligenceTimelineEvent] = []
+ for snapshot in observations {
+ let hosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() })
+ for host in hosts where seen.insert(host).inserted {
+ events.append(.init(
+ date: snapshot.timestamp,
+ category: .subdomain,
+ title: "Subdomain observed",
+ detail: host,
+ severity: .low
+ ))
+ }
+ }
+ return events
+ }
+
+ private static func provider(_ name: String, confidence: ConfidenceLevel, evidence: [String]) -> InferredProviderFingerprint {
+ .init(name: name, confidence: confidence, evidence: evidence)
+ }
+
+ private static func providerEvidence(headerMap: [String: String], dnsProviders: [String], matches: [String]) -> [String] {
+ var evidence: [String] = []
+ for match in matches {
+ if headerMap.keys.contains(match) || headerMap.values.contains(where: { $0.contains(match) }) {
+ evidence.append("HTTP \(match)")
+ }
+ if dnsProviders.contains(where: { $0.lowercased().contains(match) }) {
+ evidence.append("DNS \(match)")
+ }
+ }
+ return Array(Set(evidence)).sorted()
+ }
+
+ private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] {
+ sections
+ .first(where: { $0.recordType == type })?
+ .records
+ .map(\.value) ?? []
+ }
+
+ private static func normalized(_ values: [String]) -> [String] {
+ values.map { $0.lowercased() }.sorted()
+ }
+
+ private static func containsAny(in values: [String], matching patterns: [String]) -> Bool {
+ values.contains { value in
+ let normalized = value.lowercased()
+ return patterns.contains { normalized.contains($0) }
+ }
+ }
+
+ private static func containsHeaderValue(_ headerMap: [String: String], value: String) -> Bool {
+ headerMap.values.contains(where: { $0.contains(value) })
+ }
+
+ private static func apexDomain(for host: String) -> String {
+ let parts = host.split(separator: ".")
+ guard parts.count > 2 else { return host }
+ return parts.suffix(2).joined(separator: ".")
+ }
+}
diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift
index 752e7f2..f9400f5 100644
--- a/LookupSnapshot.swift
+++ b/LookupSnapshot.swift
@@ -48,6 +48,14 @@ struct LookupSnapshot {
let ownershipError: String?
let ownershipHistory: [DomainOwnershipHistoryEvent]
let ownershipHistoryError: String?
+ let inferredProvider: InferredProviderFingerprint?
+ let priorProviders: [String]
+ let domainClassification: DomainClassificationSummary?
+ let ownershipTransitions: [OwnershipTransitionEvent]
+ let hostingTransitions: [HostingTransitionEvent]
+ let subdomainHistory: [SubdomainHistoryEntry]
+ let riskSignals: [IntelligenceRiskSignal]
+ let intelligenceTimeline: [IntelligenceTimelineEvent]
let ptrRecord: String?
let ptrError: String?
let redirectChain: [RedirectHop]
@@ -122,6 +130,14 @@ extension HistoryEntry {
ownershipError: ownershipError,
ownershipHistory: ownershipHistory,
ownershipHistoryError: ownershipHistoryError,
+ inferredProvider: inferredProvider,
+ priorProviders: priorProviders,
+ domainClassification: domainClassification,
+ ownershipTransitions: ownershipTransitions,
+ hostingTransitions: hostingTransitions,
+ subdomainHistory: subdomainHistory,
+ riskSignals: riskSignals,
+ intelligenceTimeline: intelligenceTimeline,
ptrRecord: ptrRecord,
ptrError: ptrError,
redirectChain: redirectChain,