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 @@
378378 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
379379 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
380380 CODE_SIGN_STYLE = Automatic;
381 CURRENT_PROJECT_VERSION = 34;
381 CURRENT_PROJECT_VERSION = 35;
382382 DEVELOPMENT_TEAM = ZCNAX3VL9D;
383383 ENABLE_PREVIEWS = YES;
384384 GENERATE_INFOPLIST_FILE = YES;
@@ -395,7 +395,7 @@
395395 "$(inherited)",
396396 "@executable_path/Frameworks",
397397 );
398 MARKETING_VERSION = 4.2.0;
398 MARKETING_VERSION = 4.3.0;
399399 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
400400 PRODUCT_NAME = "$(TARGET_NAME)";
401401 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -415,7 +415,7 @@
415415 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
416416 CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements;
417417 CODE_SIGN_STYLE = Automatic;
418 CURRENT_PROJECT_VERSION = 34;
418 CURRENT_PROJECT_VERSION = 35;
419419 DEVELOPMENT_TEAM = ZCNAX3VL9D;
420420 ENABLE_PREVIEWS = YES;
421421 GENERATE_INFOPLIST_FILE = YES;
@@ -432,7 +432,7 @@
432432 "$(inherited)",
433433 "@executable_path/Frameworks",
434434 );
435 MARKETING_VERSION = 4.2.0;
435 MARKETING_VERSION = 4.3.0;
436436 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
437437 PRODUCT_NAME = "$(TARGET_NAME)";
438438 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +133
@@ -11,6 +11,7 @@ enum LookupInputMode: String, CaseIterable, Identifiable {
1111
1212enum ResultSection: String, Hashable {
1313 case domain
14 case intelligence
1415 case ownership
1516 case dns
1617 case web
@@ -78,6 +79,10 @@ struct ContentView: View {
7879 .padding(.top, appDensity.metrics.cardSpacing)
7980 }
8081 }
82 if let report = viewModel.currentReport {
83 intelligenceSection(report: report)
84 .padding(.top, appDensity.metrics.sectionSpacing)
85 }
8186 domainOverviewSection
8287 .padding(.top, appDensity.metrics.sectionSpacing)
8388 ownershipSection
@@ -415,6 +420,14 @@ struct ContentView: View {
415420 )
416421 }
417422
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
418431 private var ownershipSection: some View {
419432 OwnershipSectionView(
420433 isCollapsed: sectionCollapsedBinding(.ownership),
@@ -1558,6 +1571,126 @@ struct OwnershipSectionView: View {
15581571 }
15591572}
15601573
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
15611694struct SubdomainsSectionView: View {
15621695 @Environment(\.appDensity) private var appDensity
15631696 @Binding var isCollapsed: Bool
DomainDig/DiffService.swift +15
@@ -115,6 +115,7 @@ enum DiffService {
115115 emailSection(from: oldReport, to: newReport),
116116 networkSection(from: oldReport, to: newReport),
117117 subdomainsSection(from: oldReport, to: newReport),
118 intelligenceSection(from: oldReport, to: newReport),
118119 riskSection(from: oldReport, to: newReport)
119120 ]
120121
@@ -384,6 +385,20 @@ enum DiffService {
384385 )
385386 }
386387
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
387402 private static func compare(
388403 id: String,
389404 label: String,
DomainDig/DomainMonitoringService.swift +8
@@ -910,6 +910,14 @@ final class DomainMonitoringService {
910910 ownershipError: previousSnapshot.ownershipError,
911911 ownershipHistory: previousSnapshot.ownershipHistory,
912912 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,
913921 ptrRecord: previousSnapshot.ptrRecord,
914922 ptrError: previousSnapshot.ptrError,
915923 redirectChain: previousSnapshot.redirectChain,
DomainDig/DomainViewModel.swift +53 −3
@@ -621,6 +621,14 @@ final class DomainViewModel {
621621 ownershipError: ownershipError,
622622 ownershipHistory: ownershipHistory,
623623 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 ?? [],
624632 ptrRecord: ptrRecord,
625633 ptrError: ptrError,
626634 redirectChain: redirectChain,
@@ -1750,7 +1758,8 @@ final class DomainViewModel {
17501758 for: snapshot.domain,
17511759 trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id,
17521760 replacingLatest: false
1753 )
1761 ),
1762 historyEntries: historyEntries(for: snapshot.domain)
17541763 )
17551764 DomainDebugLog.signpostEnd("DomainViewModel.reportBuilder.build", start: reportStartedAt, domain: snapshot.domain)
17561765 currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary
@@ -1873,6 +1882,14 @@ final class DomainViewModel {
18731882 ownershipError: previousSnapshot.ownershipError,
18741883 ownershipHistory: previousSnapshot.ownershipHistory,
18751884 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,
18761893 ptrRecord: previousSnapshot.ptrRecord,
18771894 ptrError: previousSnapshot.ptrError,
18781895 redirectChain: previousSnapshot.redirectChain,
@@ -2228,7 +2245,15 @@ final class DomainViewModel {
22282245 ) -> HistoryEntry? {
22292246 let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
22302247 let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
2248 let domainHistoryEntries = history.filter {
2249 $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame
2250 }
22312251 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 )
22322257 let changeSummary = reuseCurrentAnalysis
22332258 ? currentChangeSummary ?? snapshot.changeSummary
22342259 : previousSnapshot.map {
@@ -2262,7 +2287,11 @@ final class DomainViewModel {
22622287 currentDiffSections = diffSections
22632288 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
22642289 if !reuseCurrentAnalysis {
2265 currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
2290 currentReport = reportBuilder.build(
2291 from: snapshot,
2292 previousSnapshot: previousSnapshot,
2293 historyEntries: domainHistoryEntries
2294 )
22662295 }
22672296 }
22682297
@@ -2280,6 +2309,14 @@ final class DomainViewModel {
22802309 mtaSts: snapshot.emailSecurity?.mtaSts,
22812310 ownership: snapshot.ownership,
22822311 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,
22832320 ptrRecord: snapshot.ptrRecord,
22842321 redirectChain: snapshot.redirectChain,
22852322 subdomains: snapshot.subdomains,
@@ -3509,7 +3546,12 @@ final class DomainViewModel {
35093546 }
35103547
35113548 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 )
35133555 }
35143556
35153557 private var activeWorkflowContext: DomainWorkflowContext? {
@@ -3572,6 +3614,14 @@ final class DomainViewModel {
35723614 ownershipError: nil,
35733615 ownershipHistory: [],
35743616 ownershipHistoryError: nil,
3617 inferredProvider: nil,
3618 priorProviders: [],
3619 domainClassification: nil,
3620 ownershipTransitions: [],
3621 hostingTransitions: [],
3622 subdomainHistory: [],
3623 riskSignals: [],
3624 intelligenceTimeline: [],
35753625 ptrRecord: nil,
35763626 ptrError: nil,
35773627 redirectChain: [],
DomainDig/ExternalDataService.swift +85 −31
@@ -364,6 +364,8 @@ actor ExternalDataService {
364364 summary: event["summary"] as? String ?? "DNS change observed",
365365 aRecords: event["a_records"] as? [String] ?? [],
366366 nameservers: event["nameservers"] as? [String] ?? [],
367 recordSnapshots: parseDNSRecordSnapshots(from: event),
368 changedRecordTypes: parseDNSChangedRecordTypes(from: event),
367369 source: event["source"] as? String ?? "Configured external history feed",
368370 isExternal: true
369371 )
@@ -459,46 +461,44 @@ actor ExternalDataService {
459461 .sorted { $0.timestamp < $1.timestamp }
460462
461463 var events: [DNSHistoryEvent] = []
462 var previousARecords: [String] = []
463 var previousNameservers: [String] = []
464 var previousRecordValues: [DNSRecordType: [String]] = [:]
464465
465466 for entry in domainHistory {
466 let aRecords = Self.dnsValues(for: .A, in: entry.dnsSections)
467 let nameservers = Self.dnsValues(for: .NS, in: entry.dnsSections)
468 let summary = dnsSummaryChange(
469 previousARecords: previousARecords,
470 currentARecords: aRecords,
471 previousNameservers: previousNameservers,
472 currentNameservers: nameservers
473 )
467 let currentRecordValues = Self.historyRecordValues(in: entry.dnsSections)
468 let changedRecordTypes = Self.changedRecordTypes(previous: previousRecordValues, current: currentRecordValues)
469 let summary = dnsSummaryChange(previous: previousRecordValues, current: currentRecordValues)
474470
475471 if let summary {
476472 events.append(
477473 DNSHistoryEvent(
478474 date: entry.timestamp,
479475 summary: summary,
480 aRecords: aRecords,
481 nameservers: nameservers,
476 aRecords: currentRecordValues[.A] ?? [],
477 nameservers: currentRecordValues[.NS] ?? [],
478 recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) }
479 .sorted { $0.recordType.rawValue < $1.recordType.rawValue },
480 changedRecordTypes: changedRecordTypes,
482481 source: "Local observations",
483482 isExternal: false
484483 )
485484 )
486485 }
487486
488 previousARecords = aRecords
489 previousNameservers = nameservers
487 previousRecordValues = currentRecordValues
490488 }
491489
492490 if events.isEmpty {
493 let currentARecords = Self.dnsValues(for: .A, in: dnsSections)
494 let currentNameservers = Self.dnsValues(for: .NS, in: dnsSections)
495 if !currentARecords.isEmpty || !currentNameservers.isEmpty {
491 let currentRecordValues = Self.historyRecordValues(in: dnsSections)
492 if !currentRecordValues.isEmpty {
496493 events.append(
497494 DNSHistoryEvent(
498495 date: Date(),
499496 summary: "Current DNS snapshot",
500 aRecords: currentARecords,
501 nameservers: currentNameservers,
497 aRecords: currentRecordValues[.A] ?? [],
498 nameservers: currentRecordValues[.NS] ?? [],
499 recordSnapshots: currentRecordValues.map { DNSHistoryRecordSnapshot(recordType: $0.key, values: $0.value) }
500 .sorted { $0.recordType.rawValue < $1.recordType.rawValue },
501 changedRecordTypes: Array(currentRecordValues.keys).sorted { $0.rawValue < $1.rawValue },
502502 source: "Local observations",
503503 isExternal: false
504504 )
@@ -540,8 +540,7 @@ actor ExternalDataService {
540540 let duplicate = partialResult.contains {
541541 $0.date == event.date
542542 && $0.summary == event.summary
543 && $0.aRecords == event.aRecords
544 && $0.nameservers == event.nameservers
543 && compareDNSRecordSnapshots($0.recordSnapshots, event.recordSnapshots)
545544 }
546545 if !duplicate {
547546 partialResult.append(event)
@@ -585,19 +584,18 @@ actor ExternalDataService {
585584 }
586585
587586 private static func dnsSummaryChange(
588 previousARecords: [String],
589 currentARecords: [String],
590 previousNameservers: [String],
591 currentNameservers: [String]
587 previous: [DNSRecordType: [String]],
588 current: [DNSRecordType: [String]]
592589 ) -> String? {
593590 var changes: [String] = []
594 if previousARecords != currentARecords, !currentARecords.isEmpty {
595 changes.append("A records changed")
596 }
597 if previousNameservers != currentNameservers, !currentNameservers.isEmpty {
598 changes.append("NS records changed")
591 for type in [DNSRecordType.A, .AAAA, .MX, .NS, .TXT, .CNAME] {
592 let previousValues = previous[type] ?? []
593 let currentValues = current[type] ?? []
594 if previousValues != currentValues, !currentValues.isEmpty {
595 changes.append("\(type.rawValue) records changed")
596 }
599597 }
600 if previousARecords.isEmpty && previousNameservers.isEmpty && (!currentARecords.isEmpty || !currentNameservers.isEmpty) {
598 if previous.isEmpty && !current.isEmpty {
601599 changes.append("Initial DNS observation")
602600 }
603601 return changes.isEmpty ? nil : changes.joined(separator: " • ")
@@ -619,6 +617,62 @@ actor ExternalDataService {
619617 .sorted() ?? []
620618 }
621619
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
622676 private static let iso8601DateFormatter: ISO8601DateFormatter = {
623677 let formatter = ISO8601DateFormatter()
624678 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
DomainDig/HistoryView.swift +11 −1
@@ -159,7 +159,11 @@ struct HistoryDetailView: View {
159159 }
160160
161161 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 )
163167 }
164168
165169 private var trackedDomain: TrackedDomain? {
@@ -176,6 +180,12 @@ struct HistoryDetailView: View {
176180 .padding(.top, 8)
177181 InsightsSummaryCardView(insights: report.insights)
178182 .padding(.top, 8)
183 IntelligenceSectionView(
184 isCollapsed: .constant(false),
185 report: report,
186 showsPlaceholder: FeatureAccessService.currentTier != .proPlus
187 )
188 .padding(.top, 8)
179189 DomainSectionView(
180190 isCollapsed: .constant(false),
181191 rows: DomainViewModel.domainRows(from: snapshot),
DomainDig/Models.swift +251
@@ -473,6 +473,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
473473 let summary: String
474474 let aRecords: [String]
475475 let nameservers: [String]
476 let recordSnapshots: [DNSHistoryRecordSnapshot]
477 let changedRecordTypes: [DNSRecordType]
476478 let source: String
477479 let isExternal: Bool
478480
@@ -482,6 +484,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
482484 summary: String,
483485 aRecords: [String] = [],
484486 nameservers: [String] = [],
487 recordSnapshots: [DNSHistoryRecordSnapshot] = [],
488 changedRecordTypes: [DNSRecordType] = [],
485489 source: String,
486490 isExternal: Bool
487491 ) {
@@ -490,9 +494,225 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable {
490494 self.summary = summary
491495 self.aRecords = aRecords
492496 self.nameservers = nameservers
497 self.recordSnapshots = recordSnapshots
498 self.changedRecordTypes = changedRecordTypes
493499 self.source = source
494500 self.isExternal = isExternal
495501 }
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 }
496716}
497717
498718struct DomainPricingInsight: Codable, Equatable, Sendable {
@@ -2051,6 +2271,14 @@ struct HistoryEntry: Identifiable, Codable {
20512271 var mtaSts: MTASTSResult?
20522272 var ownership: DomainOwnership?
20532273 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]
20542282 var ptrRecord: String?
20552283 var redirectChain: [RedirectHop]
20562284 var subdomains: [DiscoveredSubdomain]
@@ -2106,6 +2334,13 @@ struct HistoryEntry: Identifiable, Codable {
21062334 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
21072335 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
21082336 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] = [],
21092344 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
21102345 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
21112346 domainPricing: DomainPricingInsight? = nil,
@@ -2141,6 +2376,14 @@ struct HistoryEntry: Identifiable, Codable {
21412376 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
21422377 self.ownership = ownership
21432378 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
21442387 self.ptrRecord = ptrRecord
21452388 self.redirectChain = redirectChain
21462389 self.subdomains = subdomains
@@ -2208,6 +2451,14 @@ struct HistoryEntry: Identifiable, Codable {
22082451 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
22092452 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
22102453 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) ?? []
22112462 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
22122463 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
22132464 subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
DomainDig/TimelineView.swift +13 −1
@@ -25,7 +25,7 @@ struct TimelineView: View {
2525 NavigationLink {
2626 HistoryDetailView(viewModel: viewModel, entry: entry)
2727 } label: {
28 TimelineRow(summary: summary)
28 TimelineRow(summary: summary, entry: entry)
2929 }
3030 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
3131 Button {
@@ -102,6 +102,7 @@ struct TimelineView: View {
102102private struct TimelineRow: View {
103103 @Environment(\.appDensity) private var appDensity
104104 let summary: SnapshotSummary
105 let entry: HistoryEntry
105106
106107 var body: some View {
107108 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
@@ -138,6 +139,17 @@ private struct TimelineRow: View {
138139 .font(appDensity.font(.caption2))
139140 .foregroundStyle(.secondary)
140141
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
141153 HStack(spacing: 8) {
142154 if let primaryIP = summary.primaryIP {
143155 Text(primaryIP)
DomainDigCLI.swift +8
@@ -219,6 +219,14 @@ struct DomainDigCLI {
219219 ownershipError: snapshot.ownershipError,
220220 ownershipHistory: ownershipHistory,
221221 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,
222230 ptrRecord: snapshot.ptrRecord,
223231 ptrError: snapshot.ptrError,
224232 redirectChain: snapshot.redirectChain,
DomainInspectionService.swift +8
@@ -349,6 +349,14 @@ struct DomainInspectionService {
349349 ownershipError: ownership.message,
350350 ownershipHistory: [],
351351 ownershipHistoryError: nil,
352 inferredProvider: nil,
353 priorProviders: [],
354 domainClassification: nil,
355 ownershipTransitions: [],
356 hostingTransitions: [],
357 subdomainHistory: [],
358 riskSignals: [],
359 intelligenceTimeline: [],
352360 ptrRecord: ptrRecord.value,
353361 ptrError: ptrRecord.message,
354362 redirectChain: redirectChain.value,
DomainReportBuilder.swift +456 −1
@@ -22,6 +22,14 @@ struct DomainReport: Codable {
2222 let geolocationConfidence: ConfidenceLevel?
2323 let ownership: DomainOwnership?
2424 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]
2533 let dns: DNSResultSummary
2634 let web: WebResultSummary
2735 let email: EmailSecuritySummary
@@ -133,6 +141,7 @@ struct DomainReportBuilder {
133141 from snapshot: LookupSnapshot,
134142 previousSnapshot: LookupSnapshot? = nil,
135143 workflowContext: DomainWorkflowContext? = nil,
144 historyEntries: [HistoryEntry] = [],
136145 deriveChangeSummary: Bool = true
137146 ) -> DomainReport {
138147 let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain)
@@ -145,12 +154,14 @@ struct DomainReportBuilder {
145154 let previousReport = build(
146155 from: previousSnapshot,
147156 workflowContext: workflowContext,
157 historyEntries: historyEntries,
148158 deriveChangeSummary: false
149159 )
150160 let currentReport = buildBaseReport(
151161 from: snapshot,
152162 previousSnapshot: previousSnapshot,
153163 workflowContext: workflowContext,
164 historyEntries: historyEntries,
154165 analysis: analysis,
155166 primaryIP: primaryIP,
156167 changeSummary: nil as DomainChangeSummary?
@@ -193,6 +204,7 @@ struct DomainReportBuilder {
193204 from: snapshot,
194205 previousSnapshot: previousSnapshot,
195206 workflowContext: workflowContext,
207 historyEntries: historyEntries,
196208 analysis: analysis,
197209 primaryIP: primaryIP,
198210 changeSummary: changeSummary
@@ -210,12 +222,14 @@ struct DomainReportBuilder {
210222 from entry: HistoryEntry,
211223 previousSnapshot: LookupSnapshot? = nil,
212224 workflowContext: DomainWorkflowContext? = nil,
225 historyEntries: [HistoryEntry] = [],
213226 deriveChangeSummary: Bool = true
214227 ) -> DomainReport {
215228 build(
216229 from: entry.snapshot,
217230 previousSnapshot: previousSnapshot,
218231 workflowContext: workflowContext,
232 historyEntries: historyEntries,
219233 deriveChangeSummary: deriveChangeSummary
220234 )
221235 }
@@ -224,10 +238,16 @@ struct DomainReportBuilder {
224238 from snapshot: LookupSnapshot,
225239 previousSnapshot: LookupSnapshot?,
226240 workflowContext: DomainWorkflowContext?,
241 historyEntries: [HistoryEntry],
227242 analysis: DomainAnalysisBundle,
228243 primaryIP: String?,
229244 changeSummary: DomainChangeSummary?
230245 ) -> DomainReport {
246 let intelligence = DomainIntelligenceService.derive(
247 snapshot: snapshot,
248 previousSnapshot: previousSnapshot,
249 historyEntries: historyEntries
250 )
231251 let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot)
232252 let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0
233253 let instabilityScore = DomainHealth.instabilityScore(
@@ -277,6 +297,14 @@ struct DomainReportBuilder {
277297 geolocationConfidence: snapshot.geolocationConfidence,
278298 ownership: snapshot.ownership,
279299 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,
280308 dns: DNSResultSummary(
281309 resolverDisplayName: snapshot.resolverDisplayName,
282310 resolverURLString: snapshot.resolverURLString,
@@ -343,7 +371,7 @@ struct DomainReportBuilder {
343371 certificateExpiryState: certificateExpiryState,
344372 workflowContext: workflowContext,
345373 metadata: DomainReportMetadata(
346 schemaVersion: "3.7.0",
374 schemaVersion: "4.3.0",
347375 resolverDisplayName: snapshot.resolverDisplayName,
348376 resolverURLString: snapshot.resolverURLString,
349377 appVersion: snapshot.appVersion,
@@ -421,3 +449,430 @@ struct DomainReportBuilder {
421449 return geolocation.ip
422450 }
423451}
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 {
4848 let ownershipError: String?
4949 let ownershipHistory: [DomainOwnershipHistoryEvent]
5050 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]
5159 let ptrRecord: String?
5260 let ptrError: String?
5361 let redirectChain: [RedirectHop]
@@ -122,6 +130,14 @@ extension HistoryEntry {
122130 ownershipError: ownershipError,
123131 ownershipHistory: ownershipHistory,
124132 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,
125141 ptrRecord: ptrRecord,
126142 ptrError: ptrError,
127143 redirectChain: redirectChain,