Commit 237a72a031

237a72a03102319638c5b0572ca3ab543238b821

parent: cc69cbd7e5

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-26 05:39 UTC

DomainDig v3.5.0: Expand the Pro+ Data+ intelligence layer with deeper local historical context
and inferred enrichment.

- add derived intelligence fields for provider fingerprinting, classification,
  ownership transitions, hosting transitions, subdomain history, risk signals,
  and inferred timeline events
- expand DNS history beyond A/NS snapshots to retain A, AAAA, MX, NS, TXT, and
  CNAME change state
- persist enriched intelligence in snapshots and history entries so analysis is
  local-first and incremental
- add a dedicated Data+ Intelligence panel to current and historical domain
  detail views
- surface intelligence events in timeline rows and include Data+ changes in diff
  output
- preserve non-blocking inspection behavior by keeping enrichment additive to
  the main lookup path

This makes Pro+ materially deeper for investigative workflows by improving
historical ownership visibility, infrastructure context, hosting change
detection, subdomain intelligence, and explainable risk signals.

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
12enum ResultSection: String, Hashable { 12enum 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
1574struct 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
1561struct SubdomainsSectionView: View { 1694struct 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
530struct 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
546struct InferredProviderFingerprint: Codable, Equatable, Sendable {
547 let name: String
548 let confidence: ConfidenceLevel
549 let evidence: [String]
550}
551
552enum 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
573struct DomainClassificationSummary: Codable, Equatable, Sendable {
574 let kind: DomainClassificationKind
575 let confidence: ConfidenceLevel
576 let reasons: [String]
577}
578
579struct 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
613struct 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
629struct 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
659struct 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
684enum IntelligenceTimelineEventCategory: String, Codable, Sendable {
685 case ownership
686 case dns
687 case hosting
688 case subdomain
689 case classification
690 case risk
691}
692
693struct 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
498struct DomainPricingInsight: Codable, Equatable, Sendable { 718struct 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 {
102private struct TimelineRow: View { 102private 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
453struct 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
464enum 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,