diff options
| author | Christian Cleberg <[email protected]> | 2026-04-26 00:39:28 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-26 00:39:28 -0500 |
| commit | 237a72a03102319638c5b0572ca3ab543238b821 (patch) | |
| tree | 21b34e8b69994ac58ef1c8c49c05bc4a4b65c3b7 | |
| parent | cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5 (diff) | |
| download | domain-dig-237a72a03102319638c5b0572ca3ab543238b821.tar.gz domain-dig-237a72a03102319638c5b0572ca3ab543238b821.tar.bz2 domain-dig-237a72a03102319638c5b0572ca3ab543238b821.zip | |
DomainDig v3.5.0: Expand the Pro+ Data+ intelligence layer with deeper local historical context
and inferred enrichment.
- add derived intelligence fields for provider fingerprinting, classification,
ownership transitions, hosting transitions, subdomain history, risk signals,
and inferred timeline events
- expand DNS history beyond A/NS snapshots to retain A, AAAA, MX, NS, TXT, and
CNAME change state
- persist enriched intelligence in snapshots and history entries so analysis is
local-first and incremental
- add a dedicated Data+ Intelligence panel to current and historical domain
detail views
- surface intelligence events in timeline rows and include Data+ changes in diff
output
- preserve non-blocking inspection behavior by keeping enrichment additive to
the main lookup path
This makes Pro+ materially deeper for investigative workflows by improving
historical ownership visibility, infrastructure context, hosting change
detection, subdomain intelligence, and explainable risk signals.
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 8 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 133 | ||||
| -rw-r--r-- | DomainDig/DiffService.swift | 15 | ||||
| -rw-r--r-- | DomainDig/DomainMonitoringService.swift | 8 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 56 | ||||
| -rw-r--r-- | DomainDig/ExternalDataService.swift | 116 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 12 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 251 | ||||
| -rw-r--r-- | DomainDig/TimelineView.swift | 14 | ||||
| -rw-r--r-- | DomainDigCLI.swift | 8 | ||||
| -rw-r--r-- | DomainInspectionService.swift | 8 | ||||
| -rw-r--r-- | DomainReportBuilder.swift | 457 | ||||
| -rw-r--r-- | LookupSnapshot.swift | 16 |
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, |
