Commit 237a72a031
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -378,7 +378,7 @@ | |||
| 378 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 378 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 379 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; | 379 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 380 | CODE_SIGN_STYLE = Automatic; | 380 | CODE_SIGN_STYLE = Automatic; |
| 381 | CURRENT_PROJECT_VERSION = 34; | 381 | CURRENT_PROJECT_VERSION = 35; |
| 382 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 382 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 383 | ENABLE_PREVIEWS = YES; | 383 | ENABLE_PREVIEWS = YES; |
| 384 | GENERATE_INFOPLIST_FILE = YES; | 384 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -395,7 +395,7 @@ | |||
| 395 | "$(inherited)", | 395 | "$(inherited)", |
| 396 | "@executable_path/Frameworks", | 396 | "@executable_path/Frameworks", |
| 397 | ); | 397 | ); |
| 398 | MARKETING_VERSION = 4.2.0; | 398 | MARKETING_VERSION = 4.3.0; |
| 399 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 399 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 400 | PRODUCT_NAME = "$(TARGET_NAME)"; | 400 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 401 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 401 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -415,7 +415,7 @@ | |||
| 415 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 415 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 416 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; | 416 | CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; |
| 417 | CODE_SIGN_STYLE = Automatic; | 417 | CODE_SIGN_STYLE = Automatic; |
| 418 | CURRENT_PROJECT_VERSION = 34; | 418 | CURRENT_PROJECT_VERSION = 35; |
| 419 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 419 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 420 | ENABLE_PREVIEWS = YES; | 420 | ENABLE_PREVIEWS = YES; |
| 421 | GENERATE_INFOPLIST_FILE = YES; | 421 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -432,7 +432,7 @@ | |||
| 432 | "$(inherited)", | 432 | "$(inherited)", |
| 433 | "@executable_path/Frameworks", | 433 | "@executable_path/Frameworks", |
| 434 | ); | 434 | ); |
| 435 | MARKETING_VERSION = 4.2.0; | 435 | MARKETING_VERSION = 4.3.0; |
| 436 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; | 436 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 437 | PRODUCT_NAME = "$(TARGET_NAME)"; | 437 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 438 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 438 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +133
| @@ -11,6 +11,7 @@ enum LookupInputMode: String, CaseIterable, Identifiable { | |||
| 11 | 11 | ||
| 12 | enum ResultSection: String, Hashable { | 12 | enum ResultSection: String, Hashable { |
| 13 | case domain | 13 | case domain |
| 14 | case intelligence | ||
| 14 | case ownership | 15 | case ownership |
| 15 | case dns | 16 | case dns |
| 16 | case web | 17 | case web |
| @@ -78,6 +79,10 @@ struct ContentView: View { | |||
| 78 | .padding(.top, appDensity.metrics.cardSpacing) | 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 | domainOverviewSection | 86 | domainOverviewSection |
| 82 | .padding(.top, appDensity.metrics.sectionSpacing) | 87 | .padding(.top, appDensity.metrics.sectionSpacing) |
| 83 | ownershipSection | 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 | private var ownershipSection: some View { | 431 | private var ownershipSection: some View { |
| 419 | OwnershipSectionView( | 432 | OwnershipSectionView( |
| 420 | isCollapsed: sectionCollapsedBinding(.ownership), | 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 | struct SubdomainsSectionView: View { | 1694 | struct SubdomainsSectionView: View { |
| 1562 | @Environment(\.appDensity) private var appDensity | 1695 | @Environment(\.appDensity) private var appDensity |
| 1563 | @Binding var isCollapsed: Bool | 1696 | @Binding var isCollapsed: Bool |
DomainDig/DiffService.swift +15
| @@ -115,6 +115,7 @@ enum DiffService { | |||
| 115 | emailSection(from: oldReport, to: newReport), | 115 | emailSection(from: oldReport, to: newReport), |
| 116 | networkSection(from: oldReport, to: newReport), | 116 | networkSection(from: oldReport, to: newReport), |
| 117 | subdomainsSection(from: oldReport, to: newReport), | 117 | subdomainsSection(from: oldReport, to: newReport), |
| 118 | intelligenceSection(from: oldReport, to: newReport), | ||
| 118 | riskSection(from: oldReport, to: newReport) | 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 | private static func compare( | 402 | private static func compare( |
| 388 | id: String, | 403 | id: String, |
| 389 | label: String, | 404 | label: String, |
DomainDig/DomainMonitoringService.swift +8
| @@ -910,6 +910,14 @@ final class DomainMonitoringService { | |||
| 910 | ownershipError: previousSnapshot.ownershipError, | 910 | ownershipError: previousSnapshot.ownershipError, |
| 911 | ownershipHistory: previousSnapshot.ownershipHistory, | 911 | ownershipHistory: previousSnapshot.ownershipHistory, |
| 912 | ownershipHistoryError: previousSnapshot.ownershipHistoryError, | 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 | ptrRecord: previousSnapshot.ptrRecord, | 921 | ptrRecord: previousSnapshot.ptrRecord, |
| 914 | ptrError: previousSnapshot.ptrError, | 922 | ptrError: previousSnapshot.ptrError, |
| 915 | redirectChain: previousSnapshot.redirectChain, | 923 | redirectChain: previousSnapshot.redirectChain, |
DomainDig/DomainViewModel.swift +53 −3
| @@ -621,6 +621,14 @@ final class DomainViewModel { | |||
| 621 | ownershipError: ownershipError, | 621 | ownershipError: ownershipError, |
| 622 | ownershipHistory: ownershipHistory, | 622 | ownershipHistory: ownershipHistory, |
| 623 | ownershipHistoryError: ownershipHistoryError, | 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 | ptrRecord: ptrRecord, | 632 | ptrRecord: ptrRecord, |
| 625 | ptrError: ptrError, | 633 | ptrError: ptrError, |
| 626 | redirectChain: redirectChain, | 634 | redirectChain: redirectChain, |
| @@ -1750,7 +1758,8 @@ final class DomainViewModel { | |||
| 1750 | for: snapshot.domain, | 1758 | for: snapshot.domain, |
| 1751 | trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id, | 1759 | trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id, |
| 1752 | replacingLatest: false | 1760 | replacingLatest: false |
| 1753 | ) | 1761 | ), |
| 1762 | historyEntries: historyEntries(for: snapshot.domain) | ||
| 1754 | ) | 1763 | ) |
| 1755 | DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain) | 1764 | DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain) |
| 1756 | currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary | 1765 | currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary |
| @@ -1873,6 +1882,14 @@ final class DomainViewModel { | |||
| 1873 | ownershipError: previousSnapshot.ownershipError, | 1882 | ownershipError: previousSnapshot.ownershipError, |
| 1874 | ownershipHistory: previousSnapshot.ownershipHistory, | 1883 | ownershipHistory: previousSnapshot.ownershipHistory, |
| 1875 | ownershipHistoryError: previousSnapshot.ownershipHistoryError, | 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 | ptrRecord: previousSnapshot.ptrRecord, | 1893 | ptrRecord: previousSnapshot.ptrRecord, |
| 1877 | ptrError: previousSnapshot.ptrError, | 1894 | ptrError: previousSnapshot.ptrError, |
| 1878 | redirectChain: previousSnapshot.redirectChain, | 1895 | redirectChain: previousSnapshot.redirectChain, |
| @@ -2228,7 +2245,15 @@ final class DomainViewModel { | |||
| 2228 | ) -> HistoryEntry? { | 2245 | ) -> HistoryEntry? { |
| 2229 | let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id | 2246 | let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id |
| 2230 | let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) | 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 | let analysis = reuseCurrentAnalysis ? nil : DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) | 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 | let changeSummary = reuseCurrentAnalysis | 2257 | let changeSummary = reuseCurrentAnalysis |
| 2233 | ? currentChangeSummary ?? snapshot.changeSummary | 2258 | ? currentChangeSummary ?? snapshot.changeSummary |
| 2234 | : previousSnapshot.map { | 2259 | : previousSnapshot.map { |
| @@ -2262,7 +2287,11 @@ final class DomainViewModel { | |||
| 2262 | currentDiffSections = diffSections | 2287 | currentDiffSections = diffSections |
| 2263 | ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] | 2288 | ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] |
| 2264 | if !reuseCurrentAnalysis { | 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 | mtaSts: snapshot.emailSecurity?.mtaSts, | 2309 | mtaSts: snapshot.emailSecurity?.mtaSts, |
| 2281 | ownership: snapshot.ownership, | 2310 | ownership: snapshot.ownership, |
| 2282 | ownershipHistory: snapshot.ownershipHistory, | 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 | ptrRecord: snapshot.ptrRecord, | 2320 | ptrRecord: snapshot.ptrRecord, |
| 2284 | redirectChain: snapshot.redirectChain, | 2321 | redirectChain: snapshot.redirectChain, |
| 2285 | subdomains: snapshot.subdomains, | 2322 | subdomains: snapshot.subdomains, |
| @@ -3509,7 +3546,12 @@ final class DomainViewModel { | |||
| 3509 | } | 3546 | } |
| 3510 | 3547 | ||
| 3511 | private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport { | 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 | private var activeWorkflowContext: DomainWorkflowContext? { | 3557 | private var activeWorkflowContext: DomainWorkflowContext? { |
| @@ -3572,6 +3614,14 @@ final class DomainViewModel { | |||
| 3572 | ownershipError: nil, | 3614 | ownershipError: nil, |
| 3573 | ownershipHistory: [], | 3615 | ownershipHistory: [], |
| 3574 | ownershipHistoryError: nil, | 3616 | ownershipHistoryError: nil, |
| 3617 | inferredProvider: nil, | ||
| 3618 | priorProviders: [], | ||
| 3619 | domainClassification: nil, | ||
| 3620 | ownershipTransitions: [], | ||
| 3621 | hostingTransitions: [], | ||
| 3622 | subdomainHistory: [], | ||
| 3623 | riskSignals: [], | ||
| 3624 | intelligenceTimeline: [], | ||
| 3575 | ptrRecord: nil, | 3625 | ptrRecord: nil, |
| 3576 | ptrError: nil, | 3626 | ptrError: nil, |
| 3577 | redirectChain: [], | 3627 | redirectChain: [], |
DomainDig/ExternalDataService.swift +85 −31
| @@ -364,6 +364,8 @@ actor ExternalDataService { | |||
| 364 | summary: event["summary"] as? String ?? "DNS change observed", | 364 | summary: event["summary"] as? String ?? "DNS change observed", |
| 365 | aRecords: event["a_records"] as? [String] ?? [], | 365 | aRecords: event["a_records"] as? [String] ?? [], |
| 366 | nameservers: event["nameservers"] as? [String] ?? [], | 366 | nameservers: event["nameservers"] as? [String] ?? [], |
| 367 | recordSnapshots: parseDNSRecordSnapshots(from: event), | ||
| 368 | changedRecordTypes: parseDNSChangedRecordTypes(from: event), | ||
| 367 | source: event["source"] as? String ?? "Configured external history feed", | 369 | source: event["source"] as? String ?? "Configured external history feed", |
| 368 | isExternal: true | 370 | isExternal: true |
| 369 | ) | 371 | ) |
| @@ -459,46 +461,44 @@ actor ExternalDataService { | |||
| 459 | .sorted { $0.timestamp < $1.timestamp } | 461 | .sorted { $0.timestamp < $1.timestamp } |
| 460 | 462 | ||
| 461 | var events: [DNSHistoryEvent] = [] | 463 | var events: [DNSHistoryEvent] = [] |
| 462 | var previousARecords: [String] = [] | 464 | var previousRecordValues: [DNSRecordType: [String]] = [:] |
| 463 | var previousNameservers: [String] = [] | ||
| 464 | 465 | ||
| 465 | for entry in domainHistory { | 466 | for entry in domainHistory { |
| 466 | let aRecords = Self.dnsValues(for: .A, in: entry.dnsSections) | 467 | let currentRecordValues = Self.historyRecordValues(in: entry.dnsSections) |
| 467 | let nameservers = Self.dnsValues(for: .NS, in: entry.dnsSections) | 468 | let changedRecordTypes = Self.changedRecordTypes(previous: previousRecordValues, current: currentRecordValues) |
| 468 | let summary = dnsSummaryChange( | 469 | let summary = dnsSummaryChange(previous: previousRecordValues, current: currentRecordValues) |
| 469 | previousARecords: previousARecords, | ||
| 470 | currentARecords: aRecords, | ||
| 471 | previousNameservers: previousNameservers, | ||
| 472 | currentNameservers: nameservers | ||
| 473 | ) | ||
| 474 | 470 | ||
| 475 | if let summary { | 471 | if let summary { |
| 476 | events.append( | 472 | events.append( |
| 477 | DNSHistoryEvent( | 473 | DNSHistoryEvent( |
| 478 | date: entry.timestamp, | 474 | date: entry.timestamp, |
| 479 | summary: summary, | 475 | summary: summary, |
| 480 | aRecords: aRecords, | 476 | aRecords: currentRecordValues[.A] ?? [], |
| 481 | nameservers: nameservers, | 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 | source: "Local observations", | 481 | source: "Local observations", |
| 483 | isExternal: false | 482 | isExternal: false |
| 484 | ) | 483 | ) |
| 485 | ) | 484 | ) |
| 486 | } | 485 | } |
| 487 | 486 | ||
| 488 | previousARecords = aRecords | 487 | previousRecordValues = currentRecordValues |
| 489 | previousNameservers = nameservers | ||
| 490 | } | 488 | } |
| 491 | 489 | ||
| 492 | if events.isEmpty { | 490 | if events.isEmpty { |
| 493 | let currentARecords = Self.dnsValues(for: .A, in: dnsSections) | 491 | let currentRecordValues = Self.historyRecordValues(in: dnsSections) |
| 494 | let currentNameservers = Self.dnsValues(for: .NS, in: dnsSections) | 492 | if !currentRecordValues.isEmpty { |
| 495 | if !currentARecords.isEmpty || !currentNameservers.isEmpty { | ||
| 496 | events.append( | 493 | events.append( |
| 497 | DNSHistoryEvent( | 494 | DNSHistoryEvent( |
| 498 | date: Date(), | 495 | date: Date(), |
| 499 | summary: "Current DNS snapshot", | 496 | summary: "Current DNS snapshot", |
| 500 | aRecords: currentARecords, | 497 | aRecords: currentRecordValues[.A] ?? [], |
| 501 | nameservers: currentNameservers, | 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 | source: "Local observations", | 502 | source: "Local observations", |
| 503 | isExternal: false | 503 | isExternal: false |
| 504 | ) | 504 | ) |
| @@ -540,8 +540,7 @@ actor ExternalDataService { | |||
| 540 | let duplicate = partialResult.contains { | 540 | let duplicate = partialResult.contains { |
| 541 | $0.date == event.date | 541 | $0.date == event.date |
| 542 | && $0.summary == event.summary | 542 | && $0.summary == event.summary |
| 543 | && $0.aRecords == event.aRecords | 543 | && compareDNSRecordSnapshots($0.recordSnapshots, event.recordSnapshots) |
| 544 | && $0.nameservers == event.nameservers | ||
| 545 | } | 544 | } |
| 546 | if !duplicate { | 545 | if !duplicate { |
| 547 | partialResult.append(event) | 546 | partialResult.append(event) |
| @@ -585,19 +584,18 @@ actor ExternalDataService { | |||
| 585 | } | 584 | } |
| 586 | 585 | ||
| 587 | private static func dnsSummaryChange( | 586 | private static func dnsSummaryChange( |
| 588 | previousARecords: [String], | 587 | previous: [DNSRecordType: [String]], |
| 589 | currentARecords: [String], | 588 | current: [DNSRecordType: [String]] |
| 590 | previousNameservers: [String], | ||
| 591 | currentNameservers: [String] | ||
| 592 | ) -> String? { | 589 | ) -> String? { |
| 593 | var changes: [String] = [] | 590 | var changes: [String] = [] |
| 594 | if previousARecords != currentARecords, !currentARecords.isEmpty { | 591 | for type in [DNSRecordType.A, .AAAA, .MX, .NS, .TXT, .CNAME] { |
| 595 | changes.append("A records changed") | 592 | let previousValues = previous[type] ?? [] |
| 596 | } | 593 | let currentValues = current[type] ?? [] |
| 597 | if previousNameservers != currentNameservers, !currentNameservers.isEmpty { | 594 | if previousValues != currentValues, !currentValues.isEmpty { |
| 598 | changes.append("NS records changed") | 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 | changes.append("Initial DNS observation") | 599 | changes.append("Initial DNS observation") |
| 602 | } | 600 | } |
| 603 | return changes.isEmpty ? nil : changes.joined(separator: " • ") | 601 | return changes.isEmpty ? nil : changes.joined(separator: " • ") |
| @@ -619,6 +617,62 @@ actor ExternalDataService { | |||
| 619 | .sorted() ?? [] | 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 | private static let iso8601DateFormatter: ISO8601DateFormatter = { | 676 | private static let iso8601DateFormatter: ISO8601DateFormatter = { |
| 623 | let formatter = ISO8601DateFormatter() | 677 | let formatter = ISO8601DateFormatter() |
| 624 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] | 678 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] |
DomainDig/HistoryView.swift +11 −1
| @@ -159,7 +159,11 @@ struct HistoryDetailView: View { | |||
| 159 | } | 159 | } |
| 160 | 160 | ||
| 161 | private var report: DomainReport { | 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 | private var trackedDomain: TrackedDomain? { | 169 | private var trackedDomain: TrackedDomain? { |
| @@ -176,6 +180,12 @@ struct HistoryDetailView: View { | |||
| 176 | .padding(.top, 8) | 180 | .padding(.top, 8) |
| 177 | InsightsSummaryCardView(insights: report.insights) | 181 | InsightsSummaryCardView(insights: report.insights) |
| 178 | .padding(.top, 8) | 182 | .padding(.top, 8) |
| 183 | IntelligenceSectionView( | ||
| 184 | isCollapsed: .constant(false), | ||
| 185 | report: report, | ||
| 186 | showsPlaceholder: FeatureAccessService.currentTier != .proPlus | ||
| 187 | ) | ||
| 188 | .padding(.top, 8) | ||
| 179 | DomainSectionView( | 189 | DomainSectionView( |
| 180 | isCollapsed: .constant(false), | 190 | isCollapsed: .constant(false), |
| 181 | rows: DomainViewModel.domainRows(from: snapshot), | 191 | rows: DomainViewModel.domainRows(from: snapshot), |
DomainDig/Models.swift +251
| @@ -473,6 +473,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { | |||
| 473 | let summary: String | 473 | let summary: String |
| 474 | let aRecords: [String] | 474 | let aRecords: [String] |
| 475 | let nameservers: [String] | 475 | let nameservers: [String] |
| 476 | let recordSnapshots: [DNSHistoryRecordSnapshot] | ||
| 477 | let changedRecordTypes: [DNSRecordType] | ||
| 476 | let source: String | 478 | let source: String |
| 477 | let isExternal: Bool | 479 | let isExternal: Bool |
| 478 | 480 | ||
| @@ -482,6 +484,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { | |||
| 482 | summary: String, | 484 | summary: String, |
| 483 | aRecords: [String] = [], | 485 | aRecords: [String] = [], |
| 484 | nameservers: [String] = [], | 486 | nameservers: [String] = [], |
| 487 | recordSnapshots: [DNSHistoryRecordSnapshot] = [], | ||
| 488 | changedRecordTypes: [DNSRecordType] = [], | ||
| 485 | source: String, | 489 | source: String, |
| 486 | isExternal: Bool | 490 | isExternal: Bool |
| 487 | ) { | 491 | ) { |
| @@ -490,9 +494,225 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { | |||
| 490 | self.summary = summary | 494 | self.summary = summary |
| 491 | self.aRecords = aRecords | 495 | self.aRecords = aRecords |
| 492 | self.nameservers = nameservers | 496 | self.nameservers = nameservers |
| 497 | self.recordSnapshots = recordSnapshots | ||
| 498 | self.changedRecordTypes = changedRecordTypes | ||
| 493 | self.source = source | 499 | self.source = source |
| 494 | self.isExternal = isExternal | 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 | struct DomainPricingInsight: Codable, Equatable, Sendable { | 718 | struct DomainPricingInsight: Codable, Equatable, Sendable { |
| @@ -2051,6 +2271,14 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 2051 | var mtaSts: MTASTSResult? | 2271 | var mtaSts: MTASTSResult? |
| 2052 | var ownership: DomainOwnership? | 2272 | var ownership: DomainOwnership? |
| 2053 | var ownershipHistory: [DomainOwnershipHistoryEvent] | 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 | var ptrRecord: String? | 2282 | var ptrRecord: String? |
| 2055 | var redirectChain: [RedirectHop] | 2283 | var redirectChain: [RedirectHop] |
| 2056 | var subdomains: [DiscoveredSubdomain] | 2284 | var subdomains: [DiscoveredSubdomain] |
| @@ -2106,6 +2334,13 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 2106 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, | 2334 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 2107 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, | 2335 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, |
| 2108 | ownershipHistory: [DomainOwnershipHistoryEvent] = [], | 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 | ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], | 2344 | ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], |
| 2110 | extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], | 2345 | extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], |
| 2111 | domainPricing: DomainPricingInsight? = nil, | 2346 | domainPricing: DomainPricingInsight? = nil, |
| @@ -2141,6 +2376,14 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 2141 | self.mtaSts = mtaSts ?? emailSecurity?.mtaSts | 2376 | self.mtaSts = mtaSts ?? emailSecurity?.mtaSts |
| 2142 | self.ownership = ownership | 2377 | self.ownership = ownership |
| 2143 | self.ownershipHistory = ownershipHistory | 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 | self.ptrRecord = ptrRecord | 2387 | self.ptrRecord = ptrRecord |
| 2145 | self.redirectChain = redirectChain | 2388 | self.redirectChain = redirectChain |
| 2146 | self.subdomains = subdomains | 2389 | self.subdomains = subdomains |
| @@ -2208,6 +2451,14 @@ struct HistoryEntry: Identifiable, Codable { | |||
| 2208 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts | 2451 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts |
| 2209 | ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) | 2452 | ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) |
| 2210 | ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? [] | 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 | ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) | 2462 | ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) |
| 2212 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] | 2463 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] |
| 2213 | subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] | 2464 | subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] |
DomainDig/TimelineView.swift +13 −1
| @@ -25,7 +25,7 @@ struct TimelineView: View { | |||
| 25 | NavigationLink { | 25 | NavigationLink { |
| 26 | HistoryDetailView(viewModel: viewModel, entry: entry) | 26 | HistoryDetailView(viewModel: viewModel, entry: entry) |
| 27 | } label: { | 27 | } label: { |
| 28 | TimelineRow(summary: summary) | 28 | TimelineRow(summary: summary, entry: entry) |
| 29 | } | 29 | } |
| 30 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | 30 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 31 | Button { | 31 | Button { |
| @@ -102,6 +102,7 @@ struct TimelineView: View { | |||
| 102 | private struct TimelineRow: View { | 102 | private struct TimelineRow: View { |
| 103 | @Environment(\.appDensity) private var appDensity | 103 | @Environment(\.appDensity) private var appDensity |
| 104 | let summary: SnapshotSummary | 104 | let summary: SnapshotSummary |
| 105 | let entry: HistoryEntry | ||
| 105 | 106 | ||
| 106 | var body: some View { | 107 | var body: some View { |
| 107 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { | 108 | VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) { |
| @@ -138,6 +139,17 @@ private struct TimelineRow: View { | |||
| 138 | .font(appDensity.font(.caption2)) | 139 | .font(appDensity.font(.caption2)) |
| 139 | .foregroundStyle(.secondary) | 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 | HStack(spacing: 8) { | 153 | HStack(spacing: 8) { |
| 142 | if let primaryIP = summary.primaryIP { | 154 | if let primaryIP = summary.primaryIP { |
| 143 | Text(primaryIP) | 155 | Text(primaryIP) |
DomainDigCLI.swift +8
| @@ -219,6 +219,14 @@ struct DomainDigCLI { | |||
| 219 | ownershipError: snapshot.ownershipError, | 219 | ownershipError: snapshot.ownershipError, |
| 220 | ownershipHistory: ownershipHistory, | 220 | ownershipHistory: ownershipHistory, |
| 221 | ownershipHistoryError: ownershipHistoryError, | 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 | ptrRecord: snapshot.ptrRecord, | 230 | ptrRecord: snapshot.ptrRecord, |
| 223 | ptrError: snapshot.ptrError, | 231 | ptrError: snapshot.ptrError, |
| 224 | redirectChain: snapshot.redirectChain, | 232 | redirectChain: snapshot.redirectChain, |
DomainInspectionService.swift +8
| @@ -349,6 +349,14 @@ struct DomainInspectionService { | |||
| 349 | ownershipError: ownership.message, | 349 | ownershipError: ownership.message, |
| 350 | ownershipHistory: [], | 350 | ownershipHistory: [], |
| 351 | ownershipHistoryError: nil, | 351 | ownershipHistoryError: nil, |
| 352 | inferredProvider: nil, | ||
| 353 | priorProviders: [], | ||
| 354 | domainClassification: nil, | ||
| 355 | ownershipTransitions: [], | ||
| 356 | hostingTransitions: [], | ||
| 357 | subdomainHistory: [], | ||
| 358 | riskSignals: [], | ||
| 359 | intelligenceTimeline: [], | ||
| 352 | ptrRecord: ptrRecord.value, | 360 | ptrRecord: ptrRecord.value, |
| 353 | ptrError: ptrRecord.message, | 361 | ptrError: ptrRecord.message, |
| 354 | redirectChain: redirectChain.value, | 362 | redirectChain: redirectChain.value, |
DomainReportBuilder.swift +456 −1
| @@ -22,6 +22,14 @@ struct DomainReport: Codable { | |||
| 22 | let geolocationConfidence: ConfidenceLevel? | 22 | let geolocationConfidence: ConfidenceLevel? |
| 23 | let ownership: DomainOwnership? | 23 | let ownership: DomainOwnership? |
| 24 | let ownershipHistory: [DomainOwnershipHistoryEvent] | 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 | let dns: DNSResultSummary | 33 | let dns: DNSResultSummary |
| 26 | let web: WebResultSummary | 34 | let web: WebResultSummary |
| 27 | let email: EmailSecuritySummary | 35 | let email: EmailSecuritySummary |
| @@ -133,6 +141,7 @@ struct DomainReportBuilder { | |||
| 133 | from snapshot: LookupSnapshot, | 141 | from snapshot: LookupSnapshot, |
| 134 | previousSnapshot: LookupSnapshot? = nil, | 142 | previousSnapshot: LookupSnapshot? = nil, |
| 135 | workflowContext: DomainWorkflowContext? = nil, | 143 | workflowContext: DomainWorkflowContext? = nil, |
| 144 | historyEntries: [HistoryEntry] = [], | ||
| 136 | deriveChangeSummary: Bool = true | 145 | deriveChangeSummary: Bool = true |
| 137 | ) -> DomainReport { | 146 | ) -> DomainReport { |
| 138 | let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain) | 147 | let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain) |
| @@ -145,12 +154,14 @@ struct DomainReportBuilder { | |||
| 145 | let previousReport = build( | 154 | let previousReport = build( |
| 146 | from: previousSnapshot, | 155 | from: previousSnapshot, |
| 147 | workflowContext: workflowContext, | 156 | workflowContext: workflowContext, |
| 157 | historyEntries: historyEntries, | ||
| 148 | deriveChangeSummary: false | 158 | deriveChangeSummary: false |
| 149 | ) | 159 | ) |
| 150 | let currentReport = buildBaseReport( | 160 | let currentReport = buildBaseReport( |
| 151 | from: snapshot, | 161 | from: snapshot, |
| 152 | previousSnapshot: previousSnapshot, | 162 | previousSnapshot: previousSnapshot, |
| 153 | workflowContext: workflowContext, | 163 | workflowContext: workflowContext, |
| 164 | historyEntries: historyEntries, | ||
| 154 | analysis: analysis, | 165 | analysis: analysis, |
| 155 | primaryIP: primaryIP, | 166 | primaryIP: primaryIP, |
| 156 | changeSummary: nil as DomainChangeSummary? | 167 | changeSummary: nil as DomainChangeSummary? |
| @@ -193,6 +204,7 @@ struct DomainReportBuilder { | |||
| 193 | from: snapshot, | 204 | from: snapshot, |
| 194 | previousSnapshot: previousSnapshot, | 205 | previousSnapshot: previousSnapshot, |
| 195 | workflowContext: workflowContext, | 206 | workflowContext: workflowContext, |
| 207 | historyEntries: historyEntries, | ||
| 196 | analysis: analysis, | 208 | analysis: analysis, |
| 197 | primaryIP: primaryIP, | 209 | primaryIP: primaryIP, |
| 198 | changeSummary: changeSummary | 210 | changeSummary: changeSummary |
| @@ -210,12 +222,14 @@ struct DomainReportBuilder { | |||
| 210 | from entry: HistoryEntry, | 222 | from entry: HistoryEntry, |
| 211 | previousSnapshot: LookupSnapshot? = nil, | 223 | previousSnapshot: LookupSnapshot? = nil, |
| 212 | workflowContext: DomainWorkflowContext? = nil, | 224 | workflowContext: DomainWorkflowContext? = nil, |
| 225 | historyEntries: [HistoryEntry] = [], | ||
| 213 | deriveChangeSummary: Bool = true | 226 | deriveChangeSummary: Bool = true |
| 214 | ) -> DomainReport { | 227 | ) -> DomainReport { |
| 215 | build( | 228 | build( |
| 216 | from: entry.snapshot, | 229 | from: entry.snapshot, |
| 217 | previousSnapshot: previousSnapshot, | 230 | previousSnapshot: previousSnapshot, |
| 218 | workflowContext: workflowContext, | 231 | workflowContext: workflowContext, |
| 232 | historyEntries: historyEntries, | ||
| 219 | deriveChangeSummary: deriveChangeSummary | 233 | deriveChangeSummary: deriveChangeSummary |
| 220 | ) | 234 | ) |
| 221 | } | 235 | } |
| @@ -224,10 +238,16 @@ struct DomainReportBuilder { | |||
| 224 | from snapshot: LookupSnapshot, | 238 | from snapshot: LookupSnapshot, |
| 225 | previousSnapshot: LookupSnapshot?, | 239 | previousSnapshot: LookupSnapshot?, |
| 226 | workflowContext: DomainWorkflowContext?, | 240 | workflowContext: DomainWorkflowContext?, |
| 241 | historyEntries: [HistoryEntry], | ||
| 227 | analysis: DomainAnalysisBundle, | 242 | analysis: DomainAnalysisBundle, |
| 228 | primaryIP: String?, | 243 | primaryIP: String?, |
| 229 | changeSummary: DomainChangeSummary? | 244 | changeSummary: DomainChangeSummary? |
| 230 | ) -> DomainReport { | 245 | ) -> DomainReport { |
| 246 | let intelligence = DomainIntelligenceService.derive( | ||
| 247 | snapshot: snapshot, | ||
| 248 | previousSnapshot: previousSnapshot, | ||
| 249 | historyEntries: historyEntries | ||
| 250 | ) | ||
| 231 | let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot) | 251 | let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot) |
| 232 | let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0 | 252 | let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0 |
| 233 | let instabilityScore = DomainHealth.instabilityScore( | 253 | let instabilityScore = DomainHealth.instabilityScore( |
| @@ -277,6 +297,14 @@ struct DomainReportBuilder { | |||
| 277 | geolocationConfidence: snapshot.geolocationConfidence, | 297 | geolocationConfidence: snapshot.geolocationConfidence, |
| 278 | ownership: snapshot.ownership, | 298 | ownership: snapshot.ownership, |
| 279 | ownershipHistory: snapshot.ownershipHistory, | 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 | dns: DNSResultSummary( | 308 | dns: DNSResultSummary( |
| 281 | resolverDisplayName: snapshot.resolverDisplayName, | 309 | resolverDisplayName: snapshot.resolverDisplayName, |
| 282 | resolverURLString: snapshot.resolverURLString, | 310 | resolverURLString: snapshot.resolverURLString, |
| @@ -343,7 +371,7 @@ struct DomainReportBuilder { | |||
| 343 | certificateExpiryState: certificateExpiryState, | 371 | certificateExpiryState: certificateExpiryState, |
| 344 | workflowContext: workflowContext, | 372 | workflowContext: workflowContext, |
| 345 | metadata: DomainReportMetadata( | 373 | metadata: DomainReportMetadata( |
| 346 | schemaVersion: "3.7.0", | 374 | schemaVersion: "4.3.0", |
| 347 | resolverDisplayName: snapshot.resolverDisplayName, | 375 | resolverDisplayName: snapshot.resolverDisplayName, |
| 348 | resolverURLString: snapshot.resolverURLString, | 376 | resolverURLString: snapshot.resolverURLString, |
| 349 | appVersion: snapshot.appVersion, | 377 | appVersion: snapshot.appVersion, |
| @@ -421,3 +449,430 @@ struct DomainReportBuilder { | |||
| 421 | return geolocation.ip | 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 | let ownershipError: String? | 48 | let ownershipError: String? |
| 49 | let ownershipHistory: [DomainOwnershipHistoryEvent] | 49 | let ownershipHistory: [DomainOwnershipHistoryEvent] |
| 50 | let ownershipHistoryError: String? | 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 | let ptrRecord: String? | 59 | let ptrRecord: String? |
| 52 | let ptrError: String? | 60 | let ptrError: String? |
| 53 | let redirectChain: [RedirectHop] | 61 | let redirectChain: [RedirectHop] |
| @@ -122,6 +130,14 @@ extension HistoryEntry { | |||
| 122 | ownershipError: ownershipError, | 130 | ownershipError: ownershipError, |
| 123 | ownershipHistory: ownershipHistory, | 131 | ownershipHistory: ownershipHistory, |
| 124 | ownershipHistoryError: ownershipHistoryError, | 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 | ptrRecord: ptrRecord, | 141 | ptrRecord: ptrRecord, |
| 126 | ptrError: ptrError, | 142 | ptrError: ptrError, |
| 127 | redirectChain: redirectChain, | 143 | redirectChain: redirectChain, |