Commit 237a72a031
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -378,7 +378,7 @@ | ||
| 378 | 378 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 379 | 379 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 380 | 380 | CODE_SIGN_STYLE = Automatic; |
| 381 | CURRENT_PROJECT_VERSION = 34; | |
| 381 | CURRENT_PROJECT_VERSION = 35; | |
| 382 | 382 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 383 | 383 | ENABLE_PREVIEWS = YES; |
| 384 | 384 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -395,7 +395,7 @@ | ||
| 395 | 395 | "$(inherited)", |
| 396 | 396 | "@executable_path/Frameworks", |
| 397 | 397 | ); |
| 398 | MARKETING_VERSION = 4.2.0; | |
| 398 | MARKETING_VERSION = 4.3.0; | |
| 399 | 399 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 400 | 400 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 401 | 401 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -415,7 +415,7 @@ | ||
| 415 | 415 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 416 | 416 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 417 | 417 | CODE_SIGN_STYLE = Automatic; |
| 418 | CURRENT_PROJECT_VERSION = 34; | |
| 418 | CURRENT_PROJECT_VERSION = 35; | |
| 419 | 419 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 420 | 420 | ENABLE_PREVIEWS = YES; |
| 421 | 421 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -432,7 +432,7 @@ | ||
| 432 | 432 | "$(inherited)", |
| 433 | 433 | "@executable_path/Frameworks", |
| 434 | 434 | ); |
| 435 | MARKETING_VERSION = 4.2.0; | |
| 435 | MARKETING_VERSION = 4.3.0; | |
| 436 | 436 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 437 | 437 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 438 | 438 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +133
| @@ -11,6 +11,7 @@ enum LookupInputMode: String, CaseIterable, Identifiable { | ||
| 11 | 11 | |
| 12 | 12 | enum ResultSection: String, Hashable { |
| 13 | 13 | case domain |
| 14 | case intelligence | |
| 14 | 15 | case ownership |
| 15 | 16 | case dns |
| 16 | 17 | case web |
| @@ -78,6 +79,10 @@ struct ContentView: View { | ||
| 78 | 79 | .padding(.top, appDensity.metrics.cardSpacing) |
| 79 | 80 | } |
| 80 | 81 | } |
| 82 | if let report = viewModel.currentReport { | |
| 83 | intelligenceSection(report: report) | |
| 84 | .padding(.top, appDensity.metrics.sectionSpacing) | |
| 85 | } | |
| 81 | 86 | domainOverviewSection |
| 82 | 87 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 83 | 88 | ownershipSection |
| @@ -415,6 +420,14 @@ struct ContentView: View { | ||
| 415 | 420 | ) |
| 416 | 421 | } |
| 417 | 422 | |
| 423 | private func intelligenceSection(report: DomainReport) -> some View { | |
| 424 | IntelligenceSectionView( | |
| 425 | isCollapsed: sectionCollapsedBinding(.intelligence), | |
| 426 | report: report, | |
| 427 | showsPlaceholder: FeatureAccessService.currentTier != .proPlus | |
| 428 | ) | |
| 429 | } | |
| 430 | ||
| 418 | 431 | private var ownershipSection: some View { |
| 419 | 432 | OwnershipSectionView( |
| 420 | 433 | isCollapsed: sectionCollapsedBinding(.ownership), |
| @@ -1558,6 +1571,126 @@ struct OwnershipSectionView: View { | ||
| 1558 | 1571 | } |
| 1559 | 1572 | } |
| 1560 | 1573 | |
| 1574 | struct IntelligenceSectionView: View { | |
| 1575 | @Environment(\.appDensity) private var appDensity | |
| 1576 | @Binding var isCollapsed: Bool | |
| 1577 | let report: DomainReport | |
| 1578 | let showsPlaceholder: Bool | |
| 1579 | ||
| 1580 | var body: some View { | |
| 1581 | CollapsibleSectionView(title: "Data+ Intelligence", isCollapsed: $isCollapsed) { | |
| 1582 | CardView(allowsHorizontalScroll: false) { | |
| 1583 | if showsPlaceholder { | |
| 1584 | MessageRowView(text: "Richer intelligence history, hosting analysis, and risk signals are available in Pro+", isError: false) | |
| 1585 | } else { | |
| 1586 | if let provider = report.inferredProvider { | |
| 1587 | intelligenceBlock(title: "Infrastructure") { | |
| 1588 | LabeledValueRow(row: .init(label: "Provider", value: provider.name, tone: .primary)) | |
| 1589 | if !provider.evidence.isEmpty { | |
| 1590 | MessageRowView(text: provider.evidence.joined(separator: " • "), isError: false) | |
| 1591 | } | |
| 1592 | if !report.priorProviders.isEmpty { | |
| 1593 | LabeledValueRow(row: .init(label: "Prior", value: report.priorProviders.joined(separator: ", "), tone: .secondary)) | |
| 1594 | } | |
| 1595 | } | |
| 1596 | } | |
| 1597 | if let classification = report.domainClassification { | |
| 1598 | intelligenceBlock(title: "Classification") { | |
| 1599 | LabeledValueRow(row: .init(label: "Purpose", value: classification.kind.title, tone: .primary)) | |
| 1600 | MessageRowView(text: classification.reasons.joined(separator: " • "), isError: false) | |
| 1601 | } | |
| 1602 | } | |
| 1603 | intelligenceBlock(title: "Risk Signals") { | |
| 1604 | if report.riskSignals.isEmpty { | |
| 1605 | MessageRowView(text: "No material historical risk signals detected", isError: false) | |
| 1606 | } else { | |
| 1607 | ForEach(report.riskSignals.prefix(4)) { signal in | |
| 1608 | VStack(alignment: .leading, spacing: 3) { | |
| 1609 | Text(signal.title) | |
| 1610 | .font(appDensity.font(.caption, weight: .semibold)) | |
| 1611 | Text(signal.detail) | |
| 1612 | .font(appDensity.font(.caption2)) | |
| 1613 | .foregroundStyle(.secondary) | |
| 1614 | } | |
| 1615 | } | |
| 1616 | } | |
| 1617 | } | |
| 1618 | intelligenceBlock(title: "Ownership History") { | |
| 1619 | if report.ownershipTransitions.isEmpty { | |
| 1620 | MessageRowView(text: "No ownership transitions observed locally", isError: false) | |
| 1621 | } else { | |
| 1622 | ForEach(report.ownershipTransitions.prefix(4)) { event in | |
| 1623 | intelligenceEventRow(date: event.date, title: event.summary) | |
| 1624 | } | |
| 1625 | } | |
| 1626 | } | |
| 1627 | intelligenceBlock(title: "Hosting History") { | |
| 1628 | if report.hostingTransitions.isEmpty { | |
| 1629 | MessageRowView(text: "No hosting transitions observed locally", isError: false) | |
| 1630 | } else { | |
| 1631 | ForEach(report.hostingTransitions.prefix(4)) { event in | |
| 1632 | intelligenceEventRow(date: event.date, title: event.summary) | |
| 1633 | } | |
| 1634 | } | |
| 1635 | } | |
| 1636 | intelligenceBlock(title: "Subdomain Intelligence") { | |
| 1637 | if report.subdomainHistory.isEmpty { | |
| 1638 | MessageRowView(text: "No subdomain history available", isError: false) | |
| 1639 | } else { | |
| 1640 | ForEach(report.subdomainHistory.prefix(5)) { item in | |
| 1641 | VStack(alignment: .leading, spacing: 3) { | |
| 1642 | HStack { | |
| 1643 | Text(item.hostname) | |
| 1644 | .font(appDensity.font(.caption)) | |
| 1645 | Spacer() | |
| 1646 | if item.isEphemeral { | |
| 1647 | Text("Ephemeral") | |
| 1648 | .font(appDensity.font(.caption2)) | |
| 1649 | .foregroundStyle(.yellow) | |
| 1650 | } | |
| 1651 | } | |
| 1652 | Text("First \(item.firstSeen.formatted(date: .abbreviated, time: .omitted)) • Last \(item.lastSeen.formatted(date: .abbreviated, time: .omitted)) • Seen \(item.recurrenceCount)x") | |
| 1653 | .font(appDensity.font(.caption2)) | |
| 1654 | .foregroundStyle(.secondary) | |
| 1655 | } | |
| 1656 | } | |
| 1657 | } | |
| 1658 | } | |
| 1659 | intelligenceBlock(title: "Timeline") { | |
| 1660 | if report.intelligenceTimeline.isEmpty { | |
| 1661 | MessageRowView(text: "No inferred intelligence events yet", isError: false) | |
| 1662 | } else { | |
| 1663 | ForEach(report.intelligenceTimeline.prefix(5)) { event in | |
| 1664 | intelligenceEventRow(date: event.date, title: "\(event.title): \(event.detail)") | |
| 1665 | } | |
| 1666 | } | |
| 1667 | } | |
| 1668 | } | |
| 1669 | } | |
| 1670 | } | |
| 1671 | } | |
| 1672 | ||
| 1673 | @ViewBuilder | |
| 1674 | private func intelligenceBlock<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View { | |
| 1675 | VStack(alignment: .leading, spacing: 8) { | |
| 1676 | Text(title) | |
| 1677 | .font(appDensity.font(.subheadline, weight: .semibold)) | |
| 1678 | .foregroundStyle(.cyan) | |
| 1679 | content() | |
| 1680 | } | |
| 1681 | } | |
| 1682 | ||
| 1683 | private func intelligenceEventRow(date: Date, title: String) -> some View { | |
| 1684 | VStack(alignment: .leading, spacing: 3) { | |
| 1685 | Text(date.formatted(date: .abbreviated, time: .omitted)) | |
| 1686 | .font(appDensity.font(.caption2)) | |
| 1687 | .foregroundStyle(.secondary) | |
| 1688 | Text(title) | |
| 1689 | .font(appDensity.font(.caption)) | |
| 1690 | } | |
| 1691 | } | |
| 1692 | } | |
| 1693 | ||
| 1561 | 1694 | struct SubdomainsSectionView: View { |
| 1562 | 1695 | @Environment(\.appDensity) private var appDensity |
| 1563 | 1696 | @Binding var isCollapsed: Bool |
DomainDig/DiffService.swift +15
| @@ -115,6 +115,7 @@ enum DiffService { | ||
| 115 | 115 | emailSection(from: oldReport, to: newReport), |
| 116 | 116 | networkSection(from: oldReport, to: newReport), |
| 117 | 117 | subdomainsSection(from: oldReport, to: newReport), |
| 118 | intelligenceSection(from: oldReport, to: newReport), | |
| 118 | 119 | riskSection(from: oldReport, to: newReport) |
| 119 | 120 | ] |
| 120 | 121 | |
| @@ -384,6 +385,20 @@ enum DiffService { | ||
| 384 | 385 | ) |
| 385 | 386 | } |
| 386 | 387 | |
| 388 | private static func intelligenceSection(from oldReport: DomainReport, to newReport: DomainReport) -> DiffSection { | |
| 389 | DiffSection( | |
| 390 | id: "intelligence", | |
| 391 | title: "Data+ Intelligence", | |
| 392 | items: [ | |
| 393 | compare(id: "intel-provider", label: "Provider", oldValue: oldReport.inferredProvider?.name, newValue: newReport.inferredProvider?.name, severity: .medium), | |
| 394 | compare(id: "intel-classification", label: "Classification", oldValue: oldReport.domainClassification?.kind.title, newValue: newReport.domainClassification?.kind.title, severity: .medium), | |
| 395 | compare(id: "intel-hosting-history", label: "Hosting Transitions", oldValue: joined(oldReport.hostingTransitions.map(\.summary)), newValue: joined(newReport.hostingTransitions.map(\.summary)), severity: .medium), | |
| 396 | compare(id: "intel-ownership-history", label: "Ownership Transitions", oldValue: joined(oldReport.ownershipTransitions.map(\.summary)), newValue: joined(newReport.ownershipTransitions.map(\.summary)), severity: .high), | |
| 397 | compare(id: "intel-risk-signals", label: "Risk Signals", oldValue: joined(oldReport.riskSignals.map(\.title)), newValue: joined(newReport.riskSignals.map(\.title)), severity: .medium) | |
| 398 | ].compactMap { $0 } | |
| 399 | ) | |
| 400 | } | |
| 401 | ||
| 387 | 402 | private static func compare( |
| 388 | 403 | id: String, |
| 389 | 404 | label: String, |
DomainDig/DomainMonitoringService.swift +8
| @@ -910,6 +910,14 @@ final class DomainMonitoringService { | ||
| 910 | 910 | ownershipError: previousSnapshot.ownershipError, |
| 911 | 911 | ownershipHistory: previousSnapshot.ownershipHistory, |
| 912 | 912 | ownershipHistoryError: previousSnapshot.ownershipHistoryError, |
| 913 | inferredProvider: previousSnapshot.inferredProvider, | |
| 914 | priorProviders: previousSnapshot.priorProviders, | |
| 915 | domainClassification: previousSnapshot.domainClassification, | |
| 916 | ownershipTransitions: previousSnapshot.ownershipTransitions, | |
| 917 | hostingTransitions: previousSnapshot.hostingTransitions, | |
| 918 | subdomainHistory: previousSnapshot.subdomainHistory, | |
| 919 | riskSignals: previousSnapshot.riskSignals, | |
| 920 | intelligenceTimeline: previousSnapshot.intelligenceTimeline, | |
| 913 | 921 | ptrRecord: previousSnapshot.ptrRecord, |
| 914 | 922 | ptrError: previousSnapshot.ptrError, |
| 915 | 923 | redirectChain: previousSnapshot.redirectChain, |
DomainDig/DomainViewModel.swift +53 −3
| @@ -621,6 +621,14 @@ final class DomainViewModel { | ||
| 621 | 621 | ownershipError: ownershipError, |
| 622 | 622 | ownershipHistory: ownershipHistory, |
| 623 | 623 | ownershipHistoryError: ownershipHistoryError, |
| 624 | inferredProvider: currentHistoryEntry?.inferredProvider ?? currentReport?.inferredProvider, | |
| 625 | priorProviders: currentHistoryEntry?.priorProviders ?? currentReport?.priorProviders ?? [], | |
| 626 | domainClassification: currentHistoryEntry?.domainClassification ?? currentReport?.domainClassification, | |
| 627 | ownershipTransitions: currentHistoryEntry?.ownershipTransitions ?? currentReport?.ownershipTransitions ?? [], | |
| 628 | hostingTransitions: currentHistoryEntry?.hostingTransitions ?? currentReport?.hostingTransitions ?? [], | |
| 629 | subdomainHistory: currentHistoryEntry?.subdomainHistory ?? currentReport?.subdomainHistory ?? [], | |
| 630 | riskSignals: currentHistoryEntry?.riskSignals ?? currentReport?.riskSignals ?? [], | |
| 631 | intelligenceTimeline: currentHistoryEntry?.intelligenceTimeline ?? currentReport?.intelligenceTimeline ?? [], | |
| 624 | 632 | ptrRecord: ptrRecord, |
| 625 | 633 | ptrError: ptrError, |
| 626 | 634 | redirectChain: redirectChain, |
| @@ -1750,7 +1758,8 @@ final class DomainViewModel { | ||
| 1750 | 1758 | for: snapshot.domain, |
| 1751 | 1759 | trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id, |
| 1752 | 1760 | replacingLatest: false |
| 1753 | ) | |
| 1761 | ), | |
| 1762 | historyEntries: historyEntries(for: snapshot.domain) | |
| 1754 | 1763 | ) |
| 1755 | 1764 | DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain) |
| 1756 | 1765 | currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary |
| @@ -1873,6 +1882,14 @@ final class DomainViewModel { | ||
| 1873 | 1882 | ownershipError: previousSnapshot.ownershipError, |
| 1874 | 1883 | ownershipHistory: previousSnapshot.ownershipHistory, |
| 1875 | 1884 | ownershipHistoryError: previousSnapshot.ownershipHistoryError, |
| 1885 | inferredProvider: previousSnapshot.inferredProvider, | |
| 1886 | priorProviders: previousSnapshot.priorProviders, | |
| 1887 | domainClassification: previousSnapshot.domainClassification, | |
| 1888 | ownershipTransitions: previousSnapshot.ownershipTransitions, | |
| 1889 | hostingTransitions: previousSnapshot.hostingTransitions, | |
| 1890 | subdomainHistory: previousSnapshot.subdomainHistory, | |
| 1891 | riskSignals: previousSnapshot.riskSignals, | |
| 1892 | intelligenceTimeline: previousSnapshot.intelligenceTimeline, | |
| 1876 | 1893 | ptrRecord: previousSnapshot.ptrRecord, |
| 1877 | 1894 | ptrError: previousSnapshot.ptrError, |
| 1878 | 1895 | redirectChain: previousSnapshot.redirectChain, |
| @@ -2228,7 +2245,15 @@ final class DomainViewModel { | ||
| 2228 | 2245 | ) -> HistoryEntry? { |
| 2229 | 2246 | let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id |
| 2230 | 2247 | let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) |
| 2248 | let domainHistoryEntries = history.filter { | |
| 2249 | $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame | |
| 2250 | } | |
| 2231 | 2251 | let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) |
| 2252 | let intelligence = DomainIntelligenceService.derive( | |
| 2253 | snapshot: snapshot, | |
| 2254 | previousSnapshot: previousSnapshot, | |
| 2255 | historyEntries: domainHistoryEntries | |
| 2256 | ) | |
| 2232 | 2257 | let changeSummary = reuseCurrentAnalysis |
| 2233 | 2258 | ? currentChangeSummary ?? snapshot.changeSummary |
| 2234 | 2259 | : previousSnapshot.map { |
| @@ -2262,7 +2287,11 @@ final class DomainViewModel { | ||
| 2262 | 2287 | currentDiffSections = diffSections |
| 2263 | 2288 | ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] |
| 2264 | 2289 | if !reuseCurrentAnalysis { |
| 2265 | currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot) | |
| 2290 | currentReport = reportBuilder.build( | |
| 2291 | from: snapshot, | |
| 2292 | previousSnapshot: previousSnapshot, | |
| 2293 | historyEntries: domainHistoryEntries | |
| 2294 | ) | |
| 2266 | 2295 | } |
| 2267 | 2296 | } |
| 2268 | 2297 | |
| @@ -2280,6 +2309,14 @@ final class DomainViewModel { | ||
| 2280 | 2309 | mtaSts: snapshot.emailSecurity?.mtaSts, |
| 2281 | 2310 | ownership: snapshot.ownership, |
| 2282 | 2311 | ownershipHistory: snapshot.ownershipHistory, |
| 2312 | inferredProvider: intelligence.inferredProvider, | |
| 2313 | priorProviders: intelligence.priorProviders, | |
| 2314 | domainClassification: intelligence.domainClassification, | |
| 2315 | ownershipTransitions: intelligence.ownershipTransitions, | |
| 2316 | hostingTransitions: intelligence.hostingTransitions, | |
| 2317 | subdomainHistory: intelligence.subdomainHistory, | |
| 2318 | riskSignals: intelligence.riskSignals, | |
| 2319 | intelligenceTimeline: intelligence.timelineEvents, | |
| 2283 | 2320 | ptrRecord: snapshot.ptrRecord, |
| 2284 | 2321 | redirectChain: snapshot.redirectChain, |
| 2285 | 2322 | subdomains: snapshot.subdomains, |
| @@ -3509,7 +3546,12 @@ final class DomainViewModel { | ||
| 3509 | 3546 | } |
| 3510 | 3547 | |
| 3511 | 3548 | private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport { |
| 3512 | reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext) | |
| 3549 | reportBuilder.build( | |
| 3550 | from: entry, | |
| 3551 | previousSnapshot: comparisonSnapshot(for: entry), | |
| 3552 | workflowContext: workflowContext, | |
| 3553 | historyEntries: historyEntries(for: entry.domain) | |
| 3554 | ) | |
| 3513 | 3555 | } |
| 3514 | 3556 | |
| 3515 | 3557 | private var activeWorkflowContext: DomainWorkflowContext? { |
| @@ -3572,6 +3614,14 @@ final class DomainViewModel { | ||
| 3572 | 3614 | ownershipError: nil, |
| 3573 | 3615 | ownershipHistory: [], |
| 3574 | 3616 | ownershipHistoryError: nil, |
| 3617 | inferredProvider: nil, | |
| 3618 | priorProviders: [], | |
| 3619 | domainClassification: nil, | |
| 3620 | ownershipTransitions: [], | |
| 3621 | hostingTransitions: [], | |
| 3622 | subdomainHistory: [], | |
| 3623 | riskSignals: [], | |
| 3624 | intelligenceTimeline: [], | |
| 3575 | 3625 | ptrRecord: nil, |
| 3576 | 3626 | ptrError: nil, |
| 3577 | 3627 | redirectChain: [], |
DomainDig/ExternalDataService.swift +85 −31
| @@ -364,6 +364,8 @@ actor ExternalDataService { | ||
| 364 | 364 | summary: event["summary"] as? String ?? "DNS change observed", |
| 365 | 365 | aRecords: event["a_records"] as? [String] ?? [], |
| 366 | 366 | nameservers: event["nameservers"] as? [String] ?? [], |
| 367 | recordSnapshots: parseDNSRecordSnapshots(from: event), | |
| 368 | changedRecordTypes: parseDNSChangedRecordTypes(from: event), | |
| 367 | 369 | source: event["source"] as? String ?? "Configured external history feed", |
| 368 | 370 | isExternal: true |
| 369 | 371 | ) |
| @@ -459,46 +461,44 @@ actor ExternalDataService { | ||
| 459 | 461 | .sorted { $0.timestamp < $1.timestamp } |
| 460 | 462 | |
| 461 | 463 | var events: [DNSHistoryEvent] = [] |
| 462 | var previousARecords: [String] = [] | |
| 463 | var previousNameservers: [String] = [] | |
| 464 | var previousRecordValues: [DNSRecordType: [String]] = [:] | |
| 464 | 465 | |
| 465 | 466 | for entry in domainHistory { |
| 466 | let aRecords = Self.dnsValues(for: .A, in: entry.dnsSections) | |
| 467 | let nameservers = Self.dnsValues(for: .NS, in: entry.dnsSections) | |
| 468 | let summary = dnsSummaryChange( | |
| 469 | previousARecords: previousARecords, | |
| 470 | currentARecords: aRecords, | |
| 471 | previousNameservers: previousNameservers, | |
| 472 | currentNameservers: nameservers | |
| 473 | ) | |
| 467 | let currentRecordValues = Self.historyRecordValues(in: entry.dnsSections) | |
| 468 | let changedRecordTypes = Self.changedRecordTypes(previous: previousRecordValues, current: currentRecordValues) | |
| 469 | let summary = dnsSummaryChange(previous: previousRecordValues, current: currentRecordValues) | |
| 474 | 470 | |
| 475 | 471 | if let summary { |
| 476 | 472 | events.append( |
| 477 | 473 | DNSHistoryEvent( |
| 478 | 474 | date: entry.timestamp, |
| 479 | 475 | summary: summary, |
| 480 | aRecords: aRecords, | |
| 481 | nameservers: nameservers, | |
| 476 | aRecords: currentRecordValues[.A] ?? [], | |
| 477 | nameservers: currentRecordValues[.NS] ?? [], | |
| 478 | recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) } | |
| 479 | .sorted { $0.recordType.rawValue < $1.recordType.rawValue }, | |
| 480 | changedRecordTypes: changedRecordTypes, | |
| 482 | 481 | source: "Local observations", |
| 483 | 482 | isExternal: false |
| 484 | 483 | ) |
| 485 | 484 | ) |
| 486 | 485 | } |
| 487 | 486 | |
| 488 | previousARecords = aRecords | |
| 489 | previousNameservers = nameservers | |
| 487 | previousRecordValues = currentRecordValues | |
| 490 | 488 | } |
| 491 | 489 | |
| 492 | 490 | if events.isEmpty { |
| 493 | let currentARecords = Self.dnsValues(for: .A, in: dnsSections) | |
| 494 | let currentNameservers = Self.dnsValues(for: .NS, in: dnsSections) | |
| 495 | if !currentARecords.isEmpty || !currentNameservers.isEmpty { | |
| 491 | let currentRecordValues = Self.historyRecordValues(in: dnsSections) | |
| 492 | if !currentRecordValues.isEmpty { | |
| 496 | 493 | events.append( |
| 497 | 494 | DNSHistoryEvent( |
| 498 | 495 | date: Date(), |
| 499 | 496 | summary: "Current DNS snapshot", |
| 500 | aRecords: currentARecords, | |
| 501 | nameservers: currentNameservers, | |
| 497 | aRecords: currentRecordValues[.A] ?? [], | |
| 498 | nameservers: currentRecordValues[.NS] ?? [], | |
| 499 | recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) } | |
| 500 | .sorted { $0.recordType.rawValue < $1.recordType.rawValue }, | |
| 501 | changedRecordTypes: Array(currentRecordValues.keys).sorted { $0.rawValue < $1.rawValue }, | |
| 502 | 502 | source: "Local observations", |
| 503 | 503 | isExternal: false |
| 504 | 504 | ) |
| @@ -540,8 +540,7 @@ actor ExternalDataService { | ||
| 540 | 540 | let duplicate = partialResult.contains { |
| 541 | 541 | $0.date == event.date |
| 542 | 542 | && $0.summary == event.summary |
| 543 | && $0.aRecords == event.aRecords | |
| 544 | && $0.nameservers == event.nameservers | |
| 543 | && compareDNSRecordSnapshots($0.recordSnapshots, event.recordSnapshots) | |
| 545 | 544 | } |
| 546 | 545 | if !duplicate { |
| 547 | 546 | partialResult.append(event) |
| @@ -585,19 +584,18 @@ actor ExternalDataService { | ||
| 585 | 584 | } |
| 586 | 585 | |
| 587 | 586 | private static func dnsSummaryChange( |
| 588 | previousARecords: [String], | |
| 589 | currentARecords: [String], | |
| 590 | previousNameservers: [String], | |
| 591 | currentNameservers: [String] | |
| 587 | previous: [DNSRecordType: [String]], | |
| 588 | current: [DNSRecordType: [String]] | |
| 592 | 589 | ) -> String? { |
| 593 | 590 | var changes: [String] = [] |
| 594 | if previousARecords != currentARecords, !currentARecords.isEmpty { | |
| 595 | changes.append("A records changed") | |
| 596 | } | |
| 597 | if previousNameservers != currentNameservers, !currentNameservers.isEmpty { | |
| 598 | changes.append("NS records changed") | |
| 591 | for type in [DNSRecordType.A, .AAAA, .MX, .NS, .TXT, .CNAME] { | |
| 592 | let previousValues = previous[type] ?? [] | |
| 593 | let currentValues = current[type] ?? [] | |
| 594 | if previousValues != currentValues, !currentValues.isEmpty { | |
| 595 | changes.append("\(type.rawValue) records changed") | |
| 596 | } | |
| 599 | 597 | } |
| 600 | if previousARecords.isEmpty && previousNameservers.isEmpty && (!currentARecords.isEmpty || !currentNameservers.isEmpty) { | |
| 598 | if previous.isEmpty && !current.isEmpty { | |
| 601 | 599 | changes.append("Initial DNS observation") |
| 602 | 600 | } |
| 603 | 601 | return changes.isEmpty ? nil : changes.joined(separator: " • ") |
| @@ -619,6 +617,62 @@ actor ExternalDataService { | ||
| 619 | 617 | .sorted() ?? [] |
| 620 | 618 | } |
| 621 | 619 | |
| 620 | private func parseDNSRecordSnapshots(from event: [String: Any]) -> [DNSHistoryRecordSnapshot] { | |
| 621 | if let snapshots = event["record_snapshots"] as? [[String: Any]] { | |
| 622 | return snapshots.compactMap { item in | |
| 623 | guard let typeName = item["type"] as? String, | |
| 624 | let type = DNSRecordType(rawValue: typeName) else { | |
| 625 | return nil | |
| 626 | } | |
| 627 | return DNSHistoryRecordSnapshot(recordType: type, values: item["values"] as? [String] ?? []) | |
| 628 | } | |
| 629 | } | |
| 630 | var snapshots: [DNSHistoryRecordSnapshot] = [] | |
| 631 | if let aRecords = event["a_records"] as? [String], !aRecords.isEmpty { | |
| 632 | snapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords)) | |
| 633 | } | |
| 634 | if let nameservers = event["nameservers"] as? [String], !nameservers.isEmpty { | |
| 635 | snapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers)) | |
| 636 | } | |
| 637 | return snapshots | |
| 638 | } | |
| 639 | ||
| 640 | private func parseDNSChangedRecordTypes(from event: [String: Any]) -> [DNSRecordType] { | |
| 641 | if let rawTypes = event["changed_record_types"] as? [String] { | |
| 642 | return rawTypes.compactMap(DNSRecordType.init(rawValue:)) | |
| 643 | } | |
| 644 | return parseDNSRecordSnapshots(from: event).map(\.recordType) | |
| 645 | } | |
| 646 | ||
| 647 | private static func historyRecordValues(in sections: [DNSSection]) -> [DNSRecordType: [String]] { | |
| 648 | let trackedTypes: [DNSRecordType] = [.A, .AAAA, .MX, .NS, .TXT, .CNAME] | |
| 649 | return trackedTypes.reduce(into: [DNSRecordType: [String]]()) { result, type in | |
| 650 | let values = dnsValues(for: type, in: sections) | |
| 651 | if !values.isEmpty { | |
| 652 | result[type] = values | |
| 653 | } | |
| 654 | } | |
| 655 | } | |
| 656 | ||
| 657 | private static func changedRecordTypes( | |
| 658 | previous: [DNSRecordType: [String]], | |
| 659 | current: [DNSRecordType: [String]] | |
| 660 | ) -> [DNSRecordType] { | |
| 661 | Array(Set(previous.keys).union(current.keys)) | |
| 662 | .filter { previous[$0] != current[$0] } | |
| 663 | .sorted { $0.rawValue < $1.rawValue } | |
| 664 | } | |
| 665 | ||
| 666 | private static func compareDNSRecordSnapshots( | |
| 667 | _ lhs: [DNSHistoryRecordSnapshot], | |
| 668 | _ rhs: [DNSHistoryRecordSnapshot] | |
| 669 | ) -> Bool { | |
| 670 | guard lhs.count == rhs.count else { return false } | |
| 671 | return zip(lhs, rhs).allSatisfy { left, right in | |
| 672 | left.recordType == right.recordType && left.values == right.values | |
| 673 | } | |
| 674 | } | |
| 675 | ||
| 622 | 676 | private static let iso8601DateFormatter: ISO8601DateFormatter = { |
| 623 | 677 | let formatter = ISO8601DateFormatter() |
| 624 | 678 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] |
DomainDig/HistoryView.swift +11 −1
| @@ -159,7 +159,11 @@ struct HistoryDetailView: View { | ||
| 159 | 159 | } |
| 160 | 160 | |
| 161 | 161 | private var report: DomainReport { |
| 162 | DomainReportBuilder().build(from: entry, previousSnapshot: viewModel.comparisonSnapshot(for: entry)) | |
| 162 | DomainReportBuilder().build( | |
| 163 | from: entry, | |
| 164 | previousSnapshot: viewModel.comparisonSnapshot(for: entry), | |
| 165 | historyEntries: viewModel.historyEntries(for: entry.domain) | |
| 166 | ) | |
| 163 | 167 | } |
| 164 | 168 | |
| 165 | 169 | private var trackedDomain: TrackedDomain? { |
| @@ -176,6 +180,12 @@ struct HistoryDetailView: View { | ||
| 176 | 180 | .padding(.top, 8) |
| 177 | 181 | InsightsSummaryCardView(insights: report.insights) |
| 178 | 182 | .padding(.top, 8) |
| 183 | IntelligenceSectionView( | |
| 184 | isCollapsed: .constant(false), | |
| 185 | report: report, | |
| 186 | showsPlaceholder: FeatureAccessService.currentTier != .proPlus | |
| 187 | ) | |
| 188 | .padding(.top, 8) | |
| 179 | 189 | DomainSectionView( |
| 180 | 190 | isCollapsed: .constant(false), |
| 181 | 191 | rows: DomainViewModel.domainRows(from: snapshot), |
DomainDig/Models.swift +251
| @@ -473,6 +473,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { | ||
| 473 | 473 | let summary: String |
| 474 | 474 | let aRecords: [String] |
| 475 | 475 | let nameservers: [String] |
| 476 | let recordSnapshots: [DNSHistoryRecordSnapshot] | |
| 477 | let changedRecordTypes: [DNSRecordType] | |
| 476 | 478 | let source: String |
| 477 | 479 | let isExternal: Bool |
| 478 | 480 | |
| @@ -482,6 +484,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { | ||
| 482 | 484 | summary: String, |
| 483 | 485 | aRecords: [String] = [], |
| 484 | 486 | nameservers: [String] = [], |
| 487 | recordSnapshots: [DNSHistoryRecordSnapshot] = [], | |
| 488 | changedRecordTypes: [DNSRecordType] = [], | |
| 485 | 489 | source: String, |
| 486 | 490 | isExternal: Bool |
| 487 | 491 | ) { |
| @@ -490,9 +494,225 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { | ||
| 490 | 494 | self.summary = summary |
| 491 | 495 | self.aRecords = aRecords |
| 492 | 496 | self.nameservers = nameservers |
| 497 | self.recordSnapshots = recordSnapshots | |
| 498 | self.changedRecordTypes = changedRecordTypes | |
| 493 | 499 | self.source = source |
| 494 | 500 | self.isExternal = isExternal |
| 495 | 501 | } |
| 502 | ||
| 503 | init(from decoder: Decoder) throws { | |
| 504 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 505 | id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() | |
| 506 | date = try container.decode(Date.self, forKey: .date) | |
| 507 | summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "DNS change observed" | |
| 508 | aRecords = try container.decodeIfPresent([String].self, forKey: .aRecords) ?? [] | |
| 509 | nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? [] | |
| 510 | let decodedRecordSnapshots = try container.decodeIfPresent([DNSHistoryRecordSnapshot].self, forKey: .recordSnapshots) ?? [] | |
| 511 | if decodedRecordSnapshots.isEmpty { | |
| 512 | var synthesizedSnapshots: [DNSHistoryRecordSnapshot] = [] | |
| 513 | if !aRecords.isEmpty { | |
| 514 | synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords)) | |
| 515 | } | |
| 516 | if !nameservers.isEmpty { | |
| 517 | synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers)) | |
| 518 | } | |
| 519 | recordSnapshots = synthesizedSnapshots | |
| 520 | } else { | |
| 521 | recordSnapshots = decodedRecordSnapshots | |
| 522 | } | |
| 523 | changedRecordTypes = try container.decodeIfPresent([DNSRecordType].self, forKey: .changedRecordTypes) | |
| 524 | ?? recordSnapshots.map(\.recordType) | |
| 525 | source = try container.decodeIfPresent(String.self, forKey: .source) ?? "Unknown" | |
| 526 | isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false | |
| 527 | } | |
| 528 | } | |
| 529 | ||
| 530 | struct DNSHistoryRecordSnapshot: Identifiable, Codable, Sendable, Equatable { | |
| 531 | let id: UUID | |
| 532 | let recordType: DNSRecordType | |
| 533 | let values: [String] | |
| 534 | ||
| 535 | nonisolated init(id: UUID = UUID(), recordType: DNSRecordType, values: [String]) { | |
| 536 | self.id = id | |
| 537 | self.recordType = recordType | |
| 538 | self.values = values | |
| 539 | } | |
| 540 | ||
| 541 | static func == (lhs: DNSHistoryRecordSnapshot, rhs: DNSHistoryRecordSnapshot) -> Bool { | |
| 542 | lhs.recordType == rhs.recordType && lhs.values == rhs.values | |
| 543 | } | |
| 544 | } | |
| 545 | ||
| 546 | struct InferredProviderFingerprint: Codable, Equatable, Sendable { | |
| 547 | let name: String | |
| 548 | let confidence: ConfidenceLevel | |
| 549 | let evidence: [String] | |
| 550 | } | |
| 551 | ||
| 552 | enum DomainClassificationKind: String, Codable, CaseIterable, Sendable { | |
| 553 | case marketing | |
| 554 | case app | |
| 555 | case api | |
| 556 | case auth | |
| 557 | case docs | |
| 558 | case staticSite = "static" | |
| 559 | case infrastructure | |
| 560 | case status | |
| 561 | case unknown | |
| 562 | ||
| 563 | var title: String { | |
| 564 | switch self { | |
| 565 | case .staticSite: | |
| 566 | return "Static" | |
| 567 | default: | |
| 568 | return rawValue.capitalized | |
| 569 | } | |
| 570 | } | |
| 571 | } | |
| 572 | ||
| 573 | struct DomainClassificationSummary: Codable, Equatable, Sendable { | |
| 574 | let kind: DomainClassificationKind | |
| 575 | let confidence: ConfidenceLevel | |
| 576 | let reasons: [String] | |
| 577 | } | |
| 578 | ||
| 579 | struct OwnershipTransitionEvent: Identifiable, Codable, Equatable, Sendable { | |
| 580 | let id: UUID | |
| 581 | let date: Date | |
| 582 | let summary: String | |
| 583 | let previousRegistrar: String? | |
| 584 | let currentRegistrar: String? | |
| 585 | let previousRegistrant: String? | |
| 586 | let currentRegistrant: String? | |
| 587 | let previousNameservers: [String] | |
| 588 | let currentNameservers: [String] | |
| 589 | ||
| 590 | nonisolated init( | |
| 591 | id: UUID = UUID(), | |
| 592 | date: Date, | |
| 593 | summary: String, | |
| 594 | previousRegistrar: String? = nil, | |
| 595 | currentRegistrar: String? = nil, | |
| 596 | previousRegistrant: String? = nil, | |
| 597 | currentRegistrant: String? = nil, | |
| 598 | previousNameservers: [String] = [], | |
| 599 | currentNameservers: [String] = [] | |
| 600 | ) { | |
| 601 | self.id = id | |
| 602 | self.date = date | |
| 603 | self.summary = summary | |
| 604 | self.previousRegistrar = previousRegistrar | |
| 605 | self.currentRegistrar = currentRegistrar | |
| 606 | self.previousRegistrant = previousRegistrant | |
| 607 | self.currentRegistrant = currentRegistrant | |
| 608 | self.previousNameservers = previousNameservers | |
| 609 | self.currentNameservers = currentNameservers | |
| 610 | } | |
| 611 | } | |
| 612 | ||
| 613 | struct HostingTransitionEvent: Identifiable, Codable, Equatable, Sendable { | |
| 614 | let id: UUID | |
| 615 | let date: Date | |
| 616 | let fromProvider: String | |
| 617 | let toProvider: String | |
| 618 | let summary: String | |
| 619 | ||
| 620 | nonisolated init(id: UUID = UUID(), date: Date, fromProvider: String, toProvider: String, summary: String) { | |
| 621 | self.id = id | |
| 622 | self.date = date | |
| 623 | self.fromProvider = fromProvider | |
| 624 | self.toProvider = toProvider | |
| 625 | self.summary = summary | |
| 626 | } | |
| 627 | } | |
| 628 | ||
| 629 | struct SubdomainHistoryEntry: Identifiable, Codable, Equatable, Sendable { | |
| 630 | let id: String | |
| 631 | let hostname: String | |
| 632 | let firstSeen: Date | |
| 633 | let lastSeen: Date | |
| 634 | let recurrenceCount: Int | |
| 635 | let statusChangeCount: Int | |
| 636 | let lastKnownStatus: String | |
| 637 | let isEphemeral: Bool | |
| 638 | ||
| 639 | nonisolated init( | |
| 640 | hostname: String, | |
| 641 | firstSeen: Date, | |
| 642 | lastSeen: Date, | |
| 643 | recurrenceCount: Int, | |
| 644 | statusChangeCount: Int, | |
| 645 | lastKnownStatus: String, | |
| 646 | isEphemeral: Bool | |
| 647 | ) { | |
| 648 | id = hostname.lowercased() | |
| 649 | self.hostname = hostname | |
| 650 | self.firstSeen = firstSeen | |
| 651 | self.lastSeen = lastSeen | |
| 652 | self.recurrenceCount = recurrenceCount | |
| 653 | self.statusChangeCount = statusChangeCount | |
| 654 | self.lastKnownStatus = lastKnownStatus | |
| 655 | self.isEphemeral = isEphemeral | |
| 656 | } | |
| 657 | } | |
| 658 | ||
| 659 | struct IntelligenceRiskSignal: Identifiable, Codable, Equatable, Sendable { | |
| 660 | let id: String | |
| 661 | let title: String | |
| 662 | let detail: String | |
| 663 | let severity: ChangeSeverity | |
| 664 | let firstObserved: Date? | |
| 665 | let lastObserved: Date? | |
| 666 | ||
| 667 | nonisolated init( | |
| 668 | id: String, | |
| 669 | title: String, | |
| 670 | detail: String, | |
| 671 | severity: ChangeSeverity, | |
| 672 | firstObserved: Date? = nil, | |
| 673 | lastObserved: Date? = nil | |
| 674 | ) { | |
| 675 | self.id = id | |
| 676 | self.title = title | |
| 677 | self.detail = detail | |
| 678 | self.severity = severity | |
| 679 | self.firstObserved = firstObserved | |
| 680 | self.lastObserved = lastObserved | |
| 681 | } | |
| 682 | } | |
| 683 | ||
| 684 | enum IntelligenceTimelineEventCategory: String, Codable, Sendable { | |
| 685 | case ownership | |
| 686 | case dns | |
| 687 | case hosting | |
| 688 | case subdomain | |
| 689 | case classification | |
| 690 | case risk | |
| 691 | } | |
| 692 | ||
| 693 | struct IntelligenceTimelineEvent: Identifiable, Codable, Equatable, Sendable { | |
| 694 | let id: UUID | |
| 695 | let date: Date | |
| 696 | let category: IntelligenceTimelineEventCategory | |
| 697 | let title: String | |
| 698 | let detail: String | |
| 699 | let severity: ChangeSeverity | |
| 700 | ||
| 701 | nonisolated init( | |
| 702 | id: UUID = UUID(), | |
| 703 | date: Date, | |
| 704 | category: IntelligenceTimelineEventCategory, | |
| 705 | title: String, | |
| 706 | detail: String, | |
| 707 | severity: ChangeSeverity | |
| 708 | ) { | |
| 709 | self.id = id | |
| 710 | self.date = date | |
| 711 | self.category = category | |
| 712 | self.title = title | |
| 713 | self.detail = detail | |
| 714 | self.severity = severity | |
| 715 | } | |
| 496 | 716 | } |
| 497 | 717 | |
| 498 | 718 | struct DomainPricingInsight: Codable, Equatable, Sendable { |
| @@ -2051,6 +2271,14 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 2051 | 2271 | var mtaSts: MTASTSResult? |
| 2052 | 2272 | var ownership: DomainOwnership? |
| 2053 | 2273 | var ownershipHistory: [DomainOwnershipHistoryEvent] |
| 2274 | var inferredProvider: InferredProviderFingerprint? | |
| 2275 | var priorProviders: [String] | |
| 2276 | var domainClassification: DomainClassificationSummary? | |
| 2277 | var ownershipTransitions: [OwnershipTransitionEvent] | |
| 2278 | var hostingTransitions: [HostingTransitionEvent] | |
| 2279 | var subdomainHistory: [SubdomainHistoryEntry] | |
| 2280 | var riskSignals: [IntelligenceRiskSignal] | |
| 2281 | var intelligenceTimeline: [IntelligenceTimelineEvent] | |
| 2054 | 2282 | var ptrRecord: String? |
| 2055 | 2283 | var redirectChain: [RedirectHop] |
| 2056 | 2284 | var subdomains: [DiscoveredSubdomain] |
| @@ -2106,6 +2334,13 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 2106 | 2334 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 2107 | 2335 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, |
| 2108 | 2336 | ownershipHistory: [DomainOwnershipHistoryEvent] = [], |
| 2337 | inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [], | |
| 2338 | domainClassification: DomainClassificationSummary? = nil, | |
| 2339 | ownershipTransitions: [OwnershipTransitionEvent] = [], | |
| 2340 | hostingTransitions: [HostingTransitionEvent] = [], | |
| 2341 | subdomainHistory: [SubdomainHistoryEntry] = [], | |
| 2342 | riskSignals: [IntelligenceRiskSignal] = [], | |
| 2343 | intelligenceTimeline: [IntelligenceTimelineEvent] = [], | |
| 2109 | 2344 | ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], |
| 2110 | 2345 | extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], |
| 2111 | 2346 | domainPricing: DomainPricingInsight? = nil, |
| @@ -2141,6 +2376,14 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 2141 | 2376 | self.mtaSts = mtaSts ?? emailSecurity?.mtaSts |
| 2142 | 2377 | self.ownership = ownership |
| 2143 | 2378 | self.ownershipHistory = ownershipHistory |
| 2379 | self.inferredProvider = inferredProvider | |
| 2380 | self.priorProviders = priorProviders | |
| 2381 | self.domainClassification = domainClassification | |
| 2382 | self.ownershipTransitions = ownershipTransitions | |
| 2383 | self.hostingTransitions = hostingTransitions | |
| 2384 | self.subdomainHistory = subdomainHistory | |
| 2385 | self.riskSignals = riskSignals | |
| 2386 | self.intelligenceTimeline = intelligenceTimeline | |
| 2144 | 2387 | self.ptrRecord = ptrRecord |
| 2145 | 2388 | self.redirectChain = redirectChain |
| 2146 | 2389 | self.subdomains = subdomains |
| @@ -2208,6 +2451,14 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 2208 | 2451 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts |
| 2209 | 2452 | ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) |
| 2210 | 2453 | ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? [] |
| 2454 | inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider) | |
| 2455 | priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? [] | |
| 2456 | domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification) | |
| 2457 | ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? [] | |
| 2458 | hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? [] | |
| 2459 | subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? [] | |
| 2460 | riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? [] | |
| 2461 | intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? [] | |
| 2211 | 2462 | ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) |
| 2212 | 2463 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] |
| 2213 | 2464 | subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] |
DomainDig/TimelineView.swift +13 −1
| @@ -25,7 +25,7 @@ struct TimelineView: View { | ||
| 25 | 25 | NavigationLink { |
| 26 | 26 | HistoryDetailView(viewModel: viewModel, entry: entry) |
| 27 | 27 | } label: { |
| 28 | TimelineRow(summary: summary) | |
| 28 | TimelineRow(summary: summary, entry: entry) | |
| 29 | 29 | } |
| 30 | 30 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 31 | 31 | Button { |
| @@ -102,6 +102,7 @@ struct TimelineView: View { | ||
| 102 | 102 | private struct TimelineRow: View { |
| 103 | 103 | @Environment(\.appDensity) private var appDensity |
| 104 | 104 | let summary: SnapshotSummary |
| 105 | let entry: HistoryEntry | |
| 105 | 106 | |
| 106 | 107 | var body: some View { |
| 107 | 108 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { |
| @@ -138,6 +139,17 @@ private struct TimelineRow: View { | ||
| 138 | 139 | .font(appDensity.font(.caption2)) |
| 139 | 140 | .foregroundStyle(.secondary) |
| 140 | 141 | |
| 142 | if !entry.intelligenceTimeline.isEmpty { | |
| 143 | VStack(alignment: .leading, spacing: 4) { | |
| 144 | ForEach(Array(entry.intelligenceTimeline.prefix(2))) { event in | |
| 145 | Text("\(event.title): \(event.detail)") | |
| 146 | .font(appDensity.font(.caption2)) | |
| 147 | .foregroundStyle(.secondary) | |
| 148 | .lineLimit(1) | |
| 149 | } | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 141 | 153 | HStack(spacing: 8) { |
| 142 | 154 | if let primaryIP = summary.primaryIP { |
| 143 | 155 | Text(primaryIP) |
DomainDigCLI.swift +8
| @@ -219,6 +219,14 @@ struct DomainDigCLI { | ||
| 219 | 219 | ownershipError: snapshot.ownershipError, |
| 220 | 220 | ownershipHistory: ownershipHistory, |
| 221 | 221 | ownershipHistoryError: ownershipHistoryError, |
| 222 | inferredProvider: snapshot.inferredProvider, | |
| 223 | priorProviders: snapshot.priorProviders, | |
| 224 | domainClassification: snapshot.domainClassification, | |
| 225 | ownershipTransitions: snapshot.ownershipTransitions, | |
| 226 | hostingTransitions: snapshot.hostingTransitions, | |
| 227 | subdomainHistory: snapshot.subdomainHistory, | |
| 228 | riskSignals: snapshot.riskSignals, | |
| 229 | intelligenceTimeline: snapshot.intelligenceTimeline, | |
| 222 | 230 | ptrRecord: snapshot.ptrRecord, |
| 223 | 231 | ptrError: snapshot.ptrError, |
| 224 | 232 | redirectChain: snapshot.redirectChain, |
DomainInspectionService.swift +8
| @@ -349,6 +349,14 @@ struct DomainInspectionService { | ||
| 349 | 349 | ownershipError: ownership.message, |
| 350 | 350 | ownershipHistory: [], |
| 351 | 351 | ownershipHistoryError: nil, |
| 352 | inferredProvider: nil, | |
| 353 | priorProviders: [], | |
| 354 | domainClassification: nil, | |
| 355 | ownershipTransitions: [], | |
| 356 | hostingTransitions: [], | |
| 357 | subdomainHistory: [], | |
| 358 | riskSignals: [], | |
| 359 | intelligenceTimeline: [], | |
| 352 | 360 | ptrRecord: ptrRecord.value, |
| 353 | 361 | ptrError: ptrRecord.message, |
| 354 | 362 | redirectChain: redirectChain.value, |
DomainReportBuilder.swift +456 −1
| @@ -22,6 +22,14 @@ struct DomainReport: Codable { | ||
| 22 | 22 | let geolocationConfidence: ConfidenceLevel? |
| 23 | 23 | let ownership: DomainOwnership? |
| 24 | 24 | let ownershipHistory: [DomainOwnershipHistoryEvent] |
| 25 | let inferredProvider: InferredProviderFingerprint? | |
| 26 | let priorProviders: [String] | |
| 27 | let domainClassification: DomainClassificationSummary? | |
| 28 | let ownershipTransitions: [OwnershipTransitionEvent] | |
| 29 | let hostingTransitions: [HostingTransitionEvent] | |
| 30 | let subdomainHistory: [SubdomainHistoryEntry] | |
| 31 | let riskSignals: [IntelligenceRiskSignal] | |
| 32 | let intelligenceTimeline: [IntelligenceTimelineEvent] | |
| 25 | 33 | let dns: DNSResultSummary |
| 26 | 34 | let web: WebResultSummary |
| 27 | 35 | let email: EmailSecuritySummary |
| @@ -133,6 +141,7 @@ struct DomainReportBuilder { | ||
| 133 | 141 | from snapshot: LookupSnapshot, |
| 134 | 142 | previousSnapshot: LookupSnapshot? = nil, |
| 135 | 143 | workflowContext: DomainWorkflowContext? = nil, |
| 144 | historyEntries: [HistoryEntry] = [], | |
| 136 | 145 | deriveChangeSummary: Bool = true |
| 137 | 146 | ) -> DomainReport { |
| 138 | 147 | let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain) |
| @@ -145,12 +154,14 @@ struct DomainReportBuilder { | ||
| 145 | 154 | let previousReport = build( |
| 146 | 155 | from: previousSnapshot, |
| 147 | 156 | workflowContext: workflowContext, |
| 157 | historyEntries: historyEntries, | |
| 148 | 158 | deriveChangeSummary: false |
| 149 | 159 | ) |
| 150 | 160 | let currentReport = buildBaseReport( |
| 151 | 161 | from: snapshot, |
| 152 | 162 | previousSnapshot: previousSnapshot, |
| 153 | 163 | workflowContext: workflowContext, |
| 164 | historyEntries: historyEntries, | |
| 154 | 165 | analysis: analysis, |
| 155 | 166 | primaryIP: primaryIP, |
| 156 | 167 | changeSummary: nil as DomainChangeSummary? |
| @@ -193,6 +204,7 @@ struct DomainReportBuilder { | ||
| 193 | 204 | from: snapshot, |
| 194 | 205 | previousSnapshot: previousSnapshot, |
| 195 | 206 | workflowContext: workflowContext, |
| 207 | historyEntries: historyEntries, | |
| 196 | 208 | analysis: analysis, |
| 197 | 209 | primaryIP: primaryIP, |
| 198 | 210 | changeSummary: changeSummary |
| @@ -210,12 +222,14 @@ struct DomainReportBuilder { | ||
| 210 | 222 | from entry: HistoryEntry, |
| 211 | 223 | previousSnapshot: LookupSnapshot? = nil, |
| 212 | 224 | workflowContext: DomainWorkflowContext? = nil, |
| 225 | historyEntries: [HistoryEntry] = [], | |
| 213 | 226 | deriveChangeSummary: Bool = true |
| 214 | 227 | ) -> DomainReport { |
| 215 | 228 | build( |
| 216 | 229 | from: entry.snapshot, |
| 217 | 230 | previousSnapshot: previousSnapshot, |
| 218 | 231 | workflowContext: workflowContext, |
| 232 | historyEntries: historyEntries, | |
| 219 | 233 | deriveChangeSummary: deriveChangeSummary |
| 220 | 234 | ) |
| 221 | 235 | } |
| @@ -224,10 +238,16 @@ struct DomainReportBuilder { | ||
| 224 | 238 | from snapshot: LookupSnapshot, |
| 225 | 239 | previousSnapshot: LookupSnapshot?, |
| 226 | 240 | workflowContext: DomainWorkflowContext?, |
| 241 | historyEntries: [HistoryEntry], | |
| 227 | 242 | analysis: DomainAnalysisBundle, |
| 228 | 243 | primaryIP: String?, |
| 229 | 244 | changeSummary: DomainChangeSummary? |
| 230 | 245 | ) -> DomainReport { |
| 246 | let intelligence = DomainIntelligenceService.derive( | |
| 247 | snapshot: snapshot, | |
| 248 | previousSnapshot: previousSnapshot, | |
| 249 | historyEntries: historyEntries | |
| 250 | ) | |
| 231 | 251 | let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot) |
| 232 | 252 | let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0 |
| 233 | 253 | let instabilityScore = DomainHealth.instabilityScore( |
| @@ -277,6 +297,14 @@ struct DomainReportBuilder { | ||
| 277 | 297 | geolocationConfidence: snapshot.geolocationConfidence, |
| 278 | 298 | ownership: snapshot.ownership, |
| 279 | 299 | ownershipHistory: snapshot.ownershipHistory, |
| 300 | inferredProvider: intelligence.inferredProvider, | |
| 301 | priorProviders: intelligence.priorProviders, | |
| 302 | domainClassification: intelligence.domainClassification, | |
| 303 | ownershipTransitions: intelligence.ownershipTransitions, | |
| 304 | hostingTransitions: intelligence.hostingTransitions, | |
| 305 | subdomainHistory: intelligence.subdomainHistory, | |
| 306 | riskSignals: intelligence.riskSignals, | |
| 307 | intelligenceTimeline: intelligence.timelineEvents, | |
| 280 | 308 | dns: DNSResultSummary( |
| 281 | 309 | resolverDisplayName: snapshot.resolverDisplayName, |
| 282 | 310 | resolverURLString: snapshot.resolverURLString, |
| @@ -343,7 +371,7 @@ struct DomainReportBuilder { | ||
| 343 | 371 | certificateExpiryState: certificateExpiryState, |
| 344 | 372 | workflowContext: workflowContext, |
| 345 | 373 | metadata: DomainReportMetadata( |
| 346 | schemaVersion: "3.7.0", | |
| 374 | schemaVersion: "4.3.0", | |
| 347 | 375 | resolverDisplayName: snapshot.resolverDisplayName, |
| 348 | 376 | resolverURLString: snapshot.resolverURLString, |
| 349 | 377 | appVersion: snapshot.appVersion, |
| @@ -421,3 +449,430 @@ struct DomainReportBuilder { | ||
| 421 | 449 | return geolocation.ip |
| 422 | 450 | } |
| 423 | 451 | } |
| 452 | ||
| 453 | struct DerivedDomainIntelligence { | |
| 454 | let inferredProvider: InferredProviderFingerprint? | |
| 455 | let priorProviders: [String] | |
| 456 | let domainClassification: DomainClassificationSummary? | |
| 457 | let ownershipTransitions: [OwnershipTransitionEvent] | |
| 458 | let hostingTransitions: [HostingTransitionEvent] | |
| 459 | let subdomainHistory: [SubdomainHistoryEntry] | |
| 460 | let riskSignals: [IntelligenceRiskSignal] | |
| 461 | let timelineEvents: [IntelligenceTimelineEvent] | |
| 462 | } | |
| 463 | ||
| 464 | enum DomainIntelligenceService { | |
| 465 | static func derive( | |
| 466 | snapshot: LookupSnapshot, | |
| 467 | previousSnapshot: LookupSnapshot? = nil, | |
| 468 | historyEntries: [HistoryEntry] | |
| 469 | ) -> DerivedDomainIntelligence { | |
| 470 | let orderedHistory = historyEntries | |
| 471 | .filter { $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame } | |
| 472 | .sorted { $0.timestamp < $1.timestamp } | |
| 473 | let observationSnapshots = mergeObservations(historyEntries: orderedHistory, currentSnapshot: snapshot) | |
| 474 | let providerObservations = observationSnapshots.compactMap { observation -> (Date, InferredProviderFingerprint)? in | |
| 475 | inferProvider(from: observation).map { (observation.timestamp, $0) } | |
| 476 | } | |
| 477 | let currentProvider = inferProvider(from: snapshot) | |
| 478 | let priorProviders = Array(Set(providerObservations.dropLast().map { $0.1.name })).sorted() | |
| 479 | let ownershipTransitions = ownershipTransitions(from: observationSnapshots) | |
| 480 | let hostingTransitions = hostingTransitions(from: providerObservations) | |
| 481 | let currentClassification = classify(snapshot: snapshot) | |
| 482 | let subdomainHistory = buildSubdomainHistory(from: observationSnapshots) | |
| 483 | let riskSignals = buildRiskSignals( | |
| 484 | snapshot: snapshot, | |
| 485 | previousSnapshot: previousSnapshot, | |
| 486 | ownershipTransitions: ownershipTransitions, | |
| 487 | hostingTransitions: hostingTransitions, | |
| 488 | subdomainHistory: subdomainHistory | |
| 489 | ) | |
| 490 | let timelineEvents = buildTimelineEvents( | |
| 491 | snapshot: snapshot, | |
| 492 | observations: observationSnapshots, | |
| 493 | providerObservations: providerObservations, | |
| 494 | ownershipTransitions: ownershipTransitions, | |
| 495 | hostingTransitions: hostingTransitions, | |
| 496 | riskSignals: riskSignals | |
| 497 | ) | |
| 498 | return DerivedDomainIntelligence( | |
| 499 | inferredProvider: currentProvider, | |
| 500 | priorProviders: priorProviders, | |
| 501 | domainClassification: currentClassification, | |
| 502 | ownershipTransitions: ownershipTransitions, | |
| 503 | hostingTransitions: hostingTransitions, | |
| 504 | subdomainHistory: subdomainHistory, | |
| 505 | riskSignals: riskSignals, | |
| 506 | timelineEvents: timelineEvents | |
| 507 | ) | |
| 508 | } | |
| 509 | ||
| 510 | private static func mergeObservations(historyEntries: [HistoryEntry], currentSnapshot: LookupSnapshot) -> [LookupSnapshot] { | |
| 511 | var snapshots = historyEntries.map(\.snapshot) | |
| 512 | let alreadyIncluded = snapshots.contains { | |
| 513 | $0.timestamp == currentSnapshot.timestamp && $0.domain.caseInsensitiveCompare(currentSnapshot.domain) == .orderedSame | |
| 514 | } | |
| 515 | if !alreadyIncluded { | |
| 516 | snapshots.append(currentSnapshot) | |
| 517 | } | |
| 518 | return snapshots.sorted { $0.timestamp < $1.timestamp } | |
| 519 | } | |
| 520 | ||
| 521 | private static func inferProvider(from snapshot: LookupSnapshot) -> InferredProviderFingerprint? { | |
| 522 | let headerMap = Dictionary(uniqueKeysWithValues: snapshot.httpHeaders.map { ($0.name.lowercased(), $0.value.lowercased()) }) | |
| 523 | let dnsProviders = dnsValues(for: .NS, in: snapshot.dnsSections) + dnsValues(for: .CNAME, in: snapshot.dnsSections) | |
| 524 | let issuer = snapshot.sslInfo?.issuer.lowercased() ?? "" | |
| 525 | let org = snapshot.ipGeolocation?.org?.lowercased() ?? "" | |
| 526 | ||
| 527 | if headerMap["cf-ray"] != nil || containsAny(in: dnsProviders, matching: ["cloudflare"]) || issuer.contains("cloudflare") { | |
| 528 | return provider("Cloudflare", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["cf-ray", "cloudflare"])) | |
| 529 | } | |
| 530 | if headerMap["x-vercel-id"] != nil || containsAny(in: dnsProviders, matching: ["vercel"]) { | |
| 531 | return provider("Vercel", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-vercel-id", "vercel"])) | |
| 532 | } | |
| 533 | if containsHeaderValue(headerMap, value: "netlify") || containsAny(in: dnsProviders, matching: ["netlify"]) { | |
| 534 | return provider("Netlify", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["netlify"])) | |
| 535 | } | |
| 536 | if containsHeaderValue(headerMap, value: "fastly") || headerMap["x-served-by"]?.contains("cache") == true { | |
| 537 | return provider("Fastly", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["fastly", "x-served-by"])) | |
| 538 | } | |
| 539 | if headerMap["x-amz-cf-id"] != nil || containsHeaderValue(headerMap, value: "cloudfront") || containsAny(in: dnsProviders, matching: ["cloudfront.net"]) { | |
| 540 | return provider("CloudFront", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-amz-cf-id", "cloudfront"])) | |
| 541 | } | |
| 542 | if containsAny(in: dnsProviders, matching: ["awsdns", "amazonaws.com"]) || org.contains("amazon") { | |
| 543 | return provider("AWS", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["awsdns", "amazon"])) | |
| 544 | } | |
| 545 | if containsAny(in: dnsProviders, matching: ["github.io"]) || containsHeaderValue(headerMap, value: "github") { | |
| 546 | return provider("GitHub Pages", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["github"])) | |
| 547 | } | |
| 548 | return nil | |
| 549 | } | |
| 550 | ||
| 551 | private static func classify(snapshot: LookupSnapshot) -> DomainClassificationSummary? { | |
| 552 | let host = snapshot.domain.lowercased() | |
| 553 | let headerValues = snapshot.httpHeaders.map { "\($0.name.lowercased()):\($0.value.lowercased())" } | |
| 554 | let finalURL = snapshot.redirectChain.last?.url.lowercased() ?? "" | |
| 555 | ||
| 556 | if host.hasPrefix("api.") || host.contains(".api.") { | |
| 557 | return .init(kind: .api, confidence: .high, reasons: ["Hostname pattern"]) | |
| 558 | } | |
| 559 | if containsAny(in: [host, finalURL], matching: ["auth", "login", "sso", "oauth"]) { | |
| 560 | return .init(kind: .auth, confidence: .high, reasons: ["Auth-oriented host or redirect"]) | |
| 561 | } | |
| 562 | if containsAny(in: [host, finalURL], matching: ["docs", "developer", "developers", "help"]) { | |
| 563 | return .init(kind: .docs, confidence: .high, reasons: ["Docs-oriented host or redirect"]) | |
| 564 | } | |
| 565 | if containsAny(in: [host, finalURL], matching: ["status", "statuspage", "health"]) { | |
| 566 | return .init(kind: .status, confidence: .medium, reasons: ["Status-oriented host or redirect"]) | |
| 567 | } | |
| 568 | if containsAny(in: [host], matching: ["cdn.", "static.", "assets.", "img."]) { | |
| 569 | return .init(kind: .staticSite, confidence: .medium, reasons: ["Static asset hostname"]) | |
| 570 | } | |
| 571 | if containsAny(in: [host], matching: ["app.", "portal.", "admin.", "dashboard."]) { | |
| 572 | return .init(kind: .app, confidence: .medium, reasons: ["Application hostname"]) | |
| 573 | } | |
| 574 | if containsAny(in: [host], matching: ["vpn.", "internal.", "infra."]) || headerValues.contains(where: { $0.contains("x-envoy") }) { | |
| 575 | return .init(kind: .infrastructure, confidence: .medium, reasons: ["Infrastructure-oriented hostname or headers"]) | |
| 576 | } | |
| 577 | if host == apexDomain(for: host) || host.hasPrefix("www.") { | |
| 578 | return .init(kind: .marketing, confidence: .low, reasons: ["Apex or www host"]) | |
| 579 | } | |
| 580 | return nil | |
| 581 | } | |
| 582 | ||
| 583 | private static func ownershipTransitions(from snapshots: [LookupSnapshot]) -> [OwnershipTransitionEvent] { | |
| 584 | zip(snapshots, snapshots.dropFirst()).compactMap { previous, current in | |
| 585 | guard let previousOwnership = previous.ownership, let currentOwnership = current.ownership else { | |
| 586 | return nil | |
| 587 | } | |
| 588 | var changeParts: [String] = [] | |
| 589 | if previousOwnership.registrar != currentOwnership.registrar { | |
| 590 | changeParts.append("registrar") | |
| 591 | } | |
| 592 | if previousOwnership.registrant != currentOwnership.registrant { | |
| 593 | changeParts.append("ownership") | |
| 594 | } | |
| 595 | if normalized(previousOwnership.nameservers) != normalized(currentOwnership.nameservers) { | |
| 596 | changeParts.append("nameservers") | |
| 597 | } | |
| 598 | guard !changeParts.isEmpty else { return nil } | |
| 599 | return OwnershipTransitionEvent( | |
| 600 | date: current.timestamp, | |
| 601 | summary: "Changed \(changeParts.joined(separator: ", "))", | |
| 602 | previousRegistrar: previousOwnership.registrar, | |
| 603 | currentRegistrar: currentOwnership.registrar, | |
| 604 | previousRegistrant: previousOwnership.registrant, | |
| 605 | currentRegistrant: currentOwnership.registrant, | |
| 606 | previousNameservers: previousOwnership.nameservers, | |
| 607 | currentNameservers: currentOwnership.nameservers | |
| 608 | ) | |
| 609 | } | |
| 610 | .sorted { $0.date > $1.date } | |
| 611 | } | |
| 612 | ||
| 613 | private static func hostingTransitions(from observations: [(Date, InferredProviderFingerprint)]) -> [HostingTransitionEvent] { | |
| 614 | zip(observations, observations.dropFirst()).compactMap { previous, current in | |
| 615 | guard previous.1.name != current.1.name else { return nil } | |
| 616 | return HostingTransitionEvent( | |
| 617 | date: current.0, | |
| 618 | fromProvider: previous.1.name, | |
| 619 | toProvider: current.1.name, | |
| 620 | summary: "Hosting moved from \(previous.1.name) to \(current.1.name)" | |
| 621 | ) | |
| 622 | } | |
| 623 | .sorted { $0.date > $1.date } | |
| 624 | } | |
| 625 | ||
| 626 | private static func buildSubdomainHistory(from snapshots: [LookupSnapshot]) -> [SubdomainHistoryEntry] { | |
| 627 | struct WorkingState { | |
| 628 | var firstSeen: Date | |
| 629 | var lastSeen: Date | |
| 630 | var recurrenceCount: Int | |
| 631 | var statusChangeCount: Int | |
| 632 | var lastSeenInPreviousSnapshot: Bool | |
| 633 | } | |
| 634 | ||
| 635 | var states: [String: WorkingState] = [:] | |
| 636 | for snapshot in snapshots { | |
| 637 | let currentHosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() }) | |
| 638 | let knownHosts = Set(states.keys).union(currentHosts) | |
| 639 | for host in knownHosts { | |
| 640 | let isPresent = currentHosts.contains(host) | |
| 641 | if var state = states[host] { | |
| 642 | if isPresent { | |
| 643 | state.lastSeen = snapshot.timestamp | |
| 644 | state.recurrenceCount += 1 | |
| 645 | } | |
| 646 | if state.lastSeenInPreviousSnapshot != isPresent { | |
| 647 | state.statusChangeCount += 1 | |
| 648 | } | |
| 649 | state.lastSeenInPreviousSnapshot = isPresent | |
| 650 | states[host] = state | |
| 651 | } else if isPresent { | |
| 652 | states[host] = WorkingState( | |
| 653 | firstSeen: snapshot.timestamp, | |
| 654 | lastSeen: snapshot.timestamp, | |
| 655 | recurrenceCount: 1, | |
| 656 | statusChangeCount: 0, | |
| 657 | lastSeenInPreviousSnapshot: true | |
| 658 | ) | |
| 659 | } | |
| 660 | } | |
| 661 | } | |
| 662 | ||
| 663 | return states.map { host, state in | |
| 664 | let isEphemeral = state.recurrenceCount <= 2 || state.statusChangeCount >= 2 | |
| 665 | return SubdomainHistoryEntry( | |
| 666 | hostname: host, | |
| 667 | firstSeen: state.firstSeen, | |
| 668 | lastSeen: state.lastSeen, | |
| 669 | recurrenceCount: state.recurrenceCount, | |
| 670 | statusChangeCount: state.statusChangeCount, | |
| 671 | lastKnownStatus: state.lastSeenInPreviousSnapshot ? "Active" : "Inactive", | |
| 672 | isEphemeral: isEphemeral | |
| 673 | ) | |
| 674 | } | |
| 675 | .sorted { lhs, rhs in | |
| 676 | if lhs.isEphemeral != rhs.isEphemeral { | |
| 677 | return lhs.isEphemeral && !rhs.isEphemeral | |
| 678 | } | |
| 679 | return lhs.hostname < rhs.hostname | |
| 680 | } | |
| 681 | } | |
| 682 | ||
| 683 | private static func buildRiskSignals( | |
| 684 | snapshot: LookupSnapshot, | |
| 685 | previousSnapshot: LookupSnapshot?, | |
| 686 | ownershipTransitions: [OwnershipTransitionEvent], | |
| 687 | hostingTransitions: [HostingTransitionEvent], | |
| 688 | subdomainHistory: [SubdomainHistoryEntry] | |
| 689 | ) -> [IntelligenceRiskSignal] { | |
| 690 | var signals: [IntelligenceRiskSignal] = [] | |
| 691 | let dnsInstabilityCount = snapshot.dnsHistory.filter { !$0.changedRecordTypes.isEmpty }.count | |
| 692 | let ephemeralSubdomains = subdomainHistory.filter(\.isEphemeral) | |
| 693 | ||
| 694 | if ownershipTransitions.count >= 2 { | |
| 695 | signals.append(.init( | |
| 696 | id: "ownership-churn", | |
| 697 | title: "Ownership churn", | |
| 698 | detail: "Observed \(ownershipTransitions.count) ownership transitions in local history.", | |
| 699 | severity: .high, | |
| 700 | firstObserved: ownershipTransitions.last?.date, | |
| 701 | lastObserved: ownershipTransitions.first?.date | |
| 702 | )) | |
| 703 | } | |
| 704 | if dnsInstabilityCount >= 3 { | |
| 705 | signals.append(.init( | |
| 706 | id: "unstable-dns", | |
| 707 | title: "Unstable DNS", | |
| 708 | detail: "DNS history shows \(dnsInstabilityCount) recorded change events.", | |
| 709 | severity: .medium, | |
| 710 | firstObserved: snapshot.dnsHistory.last?.date, | |
| 711 | lastObserved: snapshot.dnsHistory.first?.date | |
| 712 | )) | |
| 713 | } | |
| 714 | if hostingTransitions.count >= 2 { | |
| 715 | signals.append(.init( | |
| 716 | id: "repeated-hosting-moves", | |
| 717 | title: "Repeated hosting moves", | |
| 718 | detail: "Infrastructure provider changed \(hostingTransitions.count) times across observations.", | |
| 719 | severity: .medium, | |
| 720 | firstObserved: hostingTransitions.last?.date, | |
| 721 | lastObserved: hostingTransitions.first?.date | |
| 722 | )) | |
| 723 | } | |
| 724 | if !ephemeralSubdomains.isEmpty { | |
| 725 | signals.append(.init( | |
| 726 | id: "ephemeral-subdomains", | |
| 727 | title: "Ephemeral subdomains", | |
| 728 | detail: "\(ephemeralSubdomains.count) subdomains appear short-lived or unstable.", | |
| 729 | severity: ephemeralSubdomains.count >= 3 ? .medium : .low, | |
| 730 | firstObserved: ephemeralSubdomains.map(\.firstSeen).min(), | |
| 731 | lastObserved: ephemeralSubdomains.map(\.lastSeen).max() | |
| 732 | )) | |
| 733 | } | |
| 734 | if let createdDate = snapshot.ownership?.createdDate { | |
| 735 | let ageDays = Calendar.current.dateComponents([.day], from: createdDate, to: snapshot.timestamp).day ?? 0 | |
| 736 | if ageDays <= 180 { | |
| 737 | signals.append(.init( | |
| 738 | id: "young-registration", | |
| 739 | title: "Short registration age", | |
| 740 | detail: "Domain registration is \(ageDays) days old.", | |
| 741 | severity: ageDays <= 90 ? .high : .medium, | |
| 742 | firstObserved: createdDate, | |
| 743 | lastObserved: snapshot.timestamp | |
| 744 | )) | |
| 745 | } | |
| 746 | } | |
| 747 | if let previousSnapshot, | |
| 748 | let previousProvider = inferProvider(from: previousSnapshot)?.name, | |
| 749 | let currentProvider = inferProvider(from: snapshot)?.name, | |
| 750 | previousProvider != currentProvider { | |
| 751 | signals.append(.init( | |
| 752 | id: "recent-hosting-move", | |
| 753 | title: "Recent hosting move", | |
| 754 | detail: "Latest snapshot moved from \(previousProvider) to \(currentProvider).", | |
| 755 | severity: .medium, | |
| 756 | firstObserved: snapshot.timestamp, | |
| 757 | lastObserved: snapshot.timestamp | |
| 758 | )) | |
| 759 | } | |
| 760 | return signals.sorted { ($0.lastObserved ?? .distantPast) > ($1.lastObserved ?? .distantPast) } | |
| 761 | } | |
| 762 | ||
| 763 | private static func buildTimelineEvents( | |
| 764 | snapshot: LookupSnapshot, | |
| 765 | observations: [LookupSnapshot], | |
| 766 | providerObservations: [(Date, InferredProviderFingerprint)], | |
| 767 | ownershipTransitions: [OwnershipTransitionEvent], | |
| 768 | hostingTransitions: [HostingTransitionEvent], | |
| 769 | riskSignals: [IntelligenceRiskSignal] | |
| 770 | ) -> [IntelligenceTimelineEvent] { | |
| 771 | var events: [IntelligenceTimelineEvent] = [] | |
| 772 | ||
| 773 | events += ownershipTransitions.map { | |
| 774 | .init(date: $0.date, category: .ownership, title: "Ownership transition", detail: $0.summary, severity: .high) | |
| 775 | } | |
| 776 | events += snapshot.dnsHistory.map { | |
| 777 | .init(date: $0.date, category: .dns, title: "DNS change", detail: $0.summary, severity: $0.changedRecordTypes.contains(.A) || $0.changedRecordTypes.contains(.NS) ? .high : .medium) | |
| 778 | } | |
| 779 | events += hostingTransitions.map { | |
| 780 | .init(date: $0.date, category: .hosting, title: "Hosting transition", detail: $0.summary, severity: .medium) | |
| 781 | } | |
| 782 | events += buildClassificationEvents(from: observations) | |
| 783 | events += buildSubdomainDiscoveryEvents(from: observations) | |
| 784 | events += riskSignals.compactMap { | |
| 785 | guard let date = $0.lastObserved ?? $0.firstObserved else { return nil } | |
| 786 | return IntelligenceTimelineEvent(date: date, category: .risk, title: $0.title, detail: $0.detail, severity: $0.severity) | |
| 787 | } | |
| 788 | if let latestProvider = providerObservations.last?.1 { | |
| 789 | events.append(.init( | |
| 790 | date: snapshot.timestamp, | |
| 791 | category: .hosting, | |
| 792 | title: "Current infrastructure", | |
| 793 | detail: "Likely running on \(latestProvider.name)", | |
| 794 | severity: .low | |
| 795 | )) | |
| 796 | } | |
| 797 | return events.sorted { $0.date > $1.date } | |
| 798 | } | |
| 799 | ||
| 800 | private static func buildClassificationEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] { | |
| 801 | let classifications = observations.compactMap { snapshot -> (Date, DomainClassificationSummary)? in | |
| 802 | classify(snapshot: snapshot).map { (snapshot.timestamp, $0) } | |
| 803 | } | |
| 804 | return zip(classifications, classifications.dropFirst()).compactMap { previous, current in | |
| 805 | guard previous.1.kind != current.1.kind else { return nil } | |
| 806 | return .init( | |
| 807 | date: current.0, | |
| 808 | category: .classification, | |
| 809 | title: "Classification changed", | |
| 810 | detail: "\(previous.1.kind.title) -> \(current.1.kind.title)", | |
| 811 | severity: .medium | |
| 812 | ) | |
| 813 | } | |
| 814 | } | |
| 815 | ||
| 816 | private static func buildSubdomainDiscoveryEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] { | |
| 817 | var seen = Set<String>() | |
| 818 | var events: [IntelligenceTimelineEvent] = [] | |
| 819 | for snapshot in observations { | |
| 820 | let hosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() }) | |
| 821 | for host in hosts where seen.insert(host).inserted { | |
| 822 | events.append(.init( | |
| 823 | date: snapshot.timestamp, | |
| 824 | category: .subdomain, | |
| 825 | title: "Subdomain observed", | |
| 826 | detail: host, | |
| 827 | severity: .low | |
| 828 | )) | |
| 829 | } | |
| 830 | } | |
| 831 | return events | |
| 832 | } | |
| 833 | ||
| 834 | private static func provider(_ name: String, confidence: ConfidenceLevel, evidence: [String]) -> InferredProviderFingerprint { | |
| 835 | .init(name: name, confidence: confidence, evidence: evidence) | |
| 836 | } | |
| 837 | ||
| 838 | private static func providerEvidence(headerMap: [String: String], dnsProviders: [String], matches: [String]) -> [String] { | |
| 839 | var evidence: [String] = [] | |
| 840 | for match in matches { | |
| 841 | if headerMap.keys.contains(match) || headerMap.values.contains(where: { $0.contains(match) }) { | |
| 842 | evidence.append("HTTP \(match)") | |
| 843 | } | |
| 844 | if dnsProviders.contains(where: { $0.lowercased().contains(match) }) { | |
| 845 | evidence.append("DNS \(match)") | |
| 846 | } | |
| 847 | } | |
| 848 | return Array(Set(evidence)).sorted() | |
| 849 | } | |
| 850 | ||
| 851 | private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] { | |
| 852 | sections | |
| 853 | .first(where: { $0.recordType == type })? | |
| 854 | .records | |
| 855 | .map(\.value) ?? [] | |
| 856 | } | |
| 857 | ||
| 858 | private static func normalized(_ values: [String]) -> [String] { | |
| 859 | values.map { $0.lowercased() }.sorted() | |
| 860 | } | |
| 861 | ||
| 862 | private static func containsAny(in values: [String], matching patterns: [String]) -> Bool { | |
| 863 | values.contains { value in | |
| 864 | let normalized = value.lowercased() | |
| 865 | return patterns.contains { normalized.contains($0) } | |
| 866 | } | |
| 867 | } | |
| 868 | ||
| 869 | private static func containsHeaderValue(_ headerMap: [String: String], value: String) -> Bool { | |
| 870 | headerMap.values.contains(where: { $0.contains(value) }) | |
| 871 | } | |
| 872 | ||
| 873 | private static func apexDomain(for host: String) -> String { | |
| 874 | let parts = host.split(separator: ".") | |
| 875 | guard parts.count > 2 else { return host } | |
| 876 | return parts.suffix(2).joined(separator: ".") | |
| 877 | } | |
| 878 | } | |
LookupSnapshot.swift +16
| @@ -48,6 +48,14 @@ struct LookupSnapshot { | ||
| 48 | 48 | let ownershipError: String? |
| 49 | 49 | let ownershipHistory: [DomainOwnershipHistoryEvent] |
| 50 | 50 | let ownershipHistoryError: String? |
| 51 | let inferredProvider: InferredProviderFingerprint? | |
| 52 | let priorProviders: [String] | |
| 53 | let domainClassification: DomainClassificationSummary? | |
| 54 | let ownershipTransitions: [OwnershipTransitionEvent] | |
| 55 | let hostingTransitions: [HostingTransitionEvent] | |
| 56 | let subdomainHistory: [SubdomainHistoryEntry] | |
| 57 | let riskSignals: [IntelligenceRiskSignal] | |
| 58 | let intelligenceTimeline: [IntelligenceTimelineEvent] | |
| 51 | 59 | let ptrRecord: String? |
| 52 | 60 | let ptrError: String? |
| 53 | 61 | let redirectChain: [RedirectHop] |
| @@ -122,6 +130,14 @@ extension HistoryEntry { | ||
| 122 | 130 | ownershipError: ownershipError, |
| 123 | 131 | ownershipHistory: ownershipHistory, |
| 124 | 132 | ownershipHistoryError: ownershipHistoryError, |
| 133 | inferredProvider: inferredProvider, | |
| 134 | priorProviders: priorProviders, | |
| 135 | domainClassification: domainClassification, | |
| 136 | ownershipTransitions: ownershipTransitions, | |
| 137 | hostingTransitions: hostingTransitions, | |
| 138 | subdomainHistory: subdomainHistory, | |
| 139 | riskSignals: riskSignals, | |
| 140 | intelligenceTimeline: intelligenceTimeline, | |
| 125 | 141 | ptrRecord: ptrRecord, |
| 126 | 142 | ptrError: ptrError, |
| 127 | 143 | redirectChain: redirectChain, |