From 237a72a03102319638c5b0572ca3ab543238b821 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 26 Apr 2026 00:39:28 -0500 Subject: 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. --- DomainReportBuilder.swift | 457 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 456 insertions(+), 1 deletion(-) (limited to 'DomainReportBuilder.swift') diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift index afed9db..50c46db 100644 --- a/DomainReportBuilder.swift +++ b/DomainReportBuilder.swift @@ -22,6 +22,14 @@ struct DomainReport: Codable { let geolocationConfidence: ConfidenceLevel? let ownership: DomainOwnership? let ownershipHistory: [DomainOwnershipHistoryEvent] + let inferredProvider: InferredProviderFingerprint? + let priorProviders: [String] + let domainClassification: DomainClassificationSummary? + let ownershipTransitions: [OwnershipTransitionEvent] + let hostingTransitions: [HostingTransitionEvent] + let subdomainHistory: [SubdomainHistoryEntry] + let riskSignals: [IntelligenceRiskSignal] + let intelligenceTimeline: [IntelligenceTimelineEvent] let dns: DNSResultSummary let web: WebResultSummary let email: EmailSecuritySummary @@ -133,6 +141,7 @@ struct DomainReportBuilder { from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil, workflowContext: DomainWorkflowContext? = nil, + historyEntries: [HistoryEntry] = [], deriveChangeSummary: Bool = true ) -> DomainReport { let buildStartedAt = DomainDebugLog.signpostStart("DomainReportBuilder.build", domain: snapshot.domain) @@ -145,12 +154,14 @@ struct DomainReportBuilder { let previousReport = build( from: previousSnapshot, workflowContext: workflowContext, + historyEntries: historyEntries, deriveChangeSummary: false ) let currentReport = buildBaseReport( from: snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext, + historyEntries: historyEntries, analysis: analysis, primaryIP: primaryIP, changeSummary: nil as DomainChangeSummary? @@ -193,6 +204,7 @@ struct DomainReportBuilder { from: snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext, + historyEntries: historyEntries, analysis: analysis, primaryIP: primaryIP, changeSummary: changeSummary @@ -210,12 +222,14 @@ struct DomainReportBuilder { from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil, workflowContext: DomainWorkflowContext? = nil, + historyEntries: [HistoryEntry] = [], deriveChangeSummary: Bool = true ) -> DomainReport { build( from: entry.snapshot, previousSnapshot: previousSnapshot, workflowContext: workflowContext, + historyEntries: historyEntries, deriveChangeSummary: deriveChangeSummary ) } @@ -224,10 +238,16 @@ struct DomainReportBuilder { from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot?, workflowContext: DomainWorkflowContext?, + historyEntries: [HistoryEntry], analysis: DomainAnalysisBundle, primaryIP: String?, changeSummary: DomainChangeSummary? ) -> DomainReport { + let intelligence = DomainIntelligenceService.derive( + snapshot: snapshot, + previousSnapshot: previousSnapshot, + historyEntries: historyEntries + ) let certificateExpiryState = DomainDiffService.certificateWarningLevel(for: snapshot) let recentChangeCount = changeSummary?.hasChanges == true ? 1 : 0 let instabilityScore = DomainHealth.instabilityScore( @@ -277,6 +297,14 @@ struct DomainReportBuilder { geolocationConfidence: snapshot.geolocationConfidence, ownership: snapshot.ownership, ownershipHistory: snapshot.ownershipHistory, + inferredProvider: intelligence.inferredProvider, + priorProviders: intelligence.priorProviders, + domainClassification: intelligence.domainClassification, + ownershipTransitions: intelligence.ownershipTransitions, + hostingTransitions: intelligence.hostingTransitions, + subdomainHistory: intelligence.subdomainHistory, + riskSignals: intelligence.riskSignals, + intelligenceTimeline: intelligence.timelineEvents, dns: DNSResultSummary( resolverDisplayName: snapshot.resolverDisplayName, resolverURLString: snapshot.resolverURLString, @@ -343,7 +371,7 @@ struct DomainReportBuilder { certificateExpiryState: certificateExpiryState, workflowContext: workflowContext, metadata: DomainReportMetadata( - schemaVersion: "3.7.0", + schemaVersion: "4.3.0", resolverDisplayName: snapshot.resolverDisplayName, resolverURLString: snapshot.resolverURLString, appVersion: snapshot.appVersion, @@ -421,3 +449,430 @@ struct DomainReportBuilder { return geolocation.ip } } + +struct DerivedDomainIntelligence { + let inferredProvider: InferredProviderFingerprint? + let priorProviders: [String] + let domainClassification: DomainClassificationSummary? + let ownershipTransitions: [OwnershipTransitionEvent] + let hostingTransitions: [HostingTransitionEvent] + let subdomainHistory: [SubdomainHistoryEntry] + let riskSignals: [IntelligenceRiskSignal] + let timelineEvents: [IntelligenceTimelineEvent] +} + +enum DomainIntelligenceService { + static func derive( + snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot? = nil, + historyEntries: [HistoryEntry] + ) -> DerivedDomainIntelligence { + let orderedHistory = historyEntries + .filter { $0.domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame } + .sorted { $0.timestamp < $1.timestamp } + let observationSnapshots = mergeObservations(historyEntries: orderedHistory, currentSnapshot: snapshot) + let providerObservations = observationSnapshots.compactMap { observation -> (Date, InferredProviderFingerprint)? in + inferProvider(from: observation).map { (observation.timestamp, $0) } + } + let currentProvider = inferProvider(from: snapshot) + let priorProviders = Array(Set(providerObservations.dropLast().map { $0.1.name })).sorted() + let ownershipTransitions = ownershipTransitions(from: observationSnapshots) + let hostingTransitions = hostingTransitions(from: providerObservations) + let currentClassification = classify(snapshot: snapshot) + let subdomainHistory = buildSubdomainHistory(from: observationSnapshots) + let riskSignals = buildRiskSignals( + snapshot: snapshot, + previousSnapshot: previousSnapshot, + ownershipTransitions: ownershipTransitions, + hostingTransitions: hostingTransitions, + subdomainHistory: subdomainHistory + ) + let timelineEvents = buildTimelineEvents( + snapshot: snapshot, + observations: observationSnapshots, + providerObservations: providerObservations, + ownershipTransitions: ownershipTransitions, + hostingTransitions: hostingTransitions, + riskSignals: riskSignals + ) + return DerivedDomainIntelligence( + inferredProvider: currentProvider, + priorProviders: priorProviders, + domainClassification: currentClassification, + ownershipTransitions: ownershipTransitions, + hostingTransitions: hostingTransitions, + subdomainHistory: subdomainHistory, + riskSignals: riskSignals, + timelineEvents: timelineEvents + ) + } + + private static func mergeObservations(historyEntries: [HistoryEntry], currentSnapshot: LookupSnapshot) -> [LookupSnapshot] { + var snapshots = historyEntries.map(\.snapshot) + let alreadyIncluded = snapshots.contains { + $0.timestamp == currentSnapshot.timestamp && $0.domain.caseInsensitiveCompare(currentSnapshot.domain) == .orderedSame + } + if !alreadyIncluded { + snapshots.append(currentSnapshot) + } + return snapshots.sorted { $0.timestamp < $1.timestamp } + } + + private static func inferProvider(from snapshot: LookupSnapshot) -> InferredProviderFingerprint? { + let headerMap = Dictionary(uniqueKeysWithValues: snapshot.httpHeaders.map { ($0.name.lowercased(), $0.value.lowercased()) }) + let dnsProviders = dnsValues(for: .NS, in: snapshot.dnsSections) + dnsValues(for: .CNAME, in: snapshot.dnsSections) + let issuer = snapshot.sslInfo?.issuer.lowercased() ?? "" + let org = snapshot.ipGeolocation?.org?.lowercased() ?? "" + + if headerMap["cf-ray"] != nil || containsAny(in: dnsProviders, matching: ["cloudflare"]) || issuer.contains("cloudflare") { + return provider("Cloudflare", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["cf-ray", "cloudflare"])) + } + if headerMap["x-vercel-id"] != nil || containsAny(in: dnsProviders, matching: ["vercel"]) { + return provider("Vercel", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-vercel-id", "vercel"])) + } + if containsHeaderValue(headerMap, value: "netlify") || containsAny(in: dnsProviders, matching: ["netlify"]) { + return provider("Netlify", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["netlify"])) + } + if containsHeaderValue(headerMap, value: "fastly") || headerMap["x-served-by"]?.contains("cache") == true { + return provider("Fastly", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["fastly", "x-served-by"])) + } + if headerMap["x-amz-cf-id"] != nil || containsHeaderValue(headerMap, value: "cloudfront") || containsAny(in: dnsProviders, matching: ["cloudfront.net"]) { + return provider("CloudFront", confidence: .high, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["x-amz-cf-id", "cloudfront"])) + } + if containsAny(in: dnsProviders, matching: ["awsdns", "amazonaws.com"]) || org.contains("amazon") { + return provider("AWS", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["awsdns", "amazon"])) + } + if containsAny(in: dnsProviders, matching: ["github.io"]) || containsHeaderValue(headerMap, value: "github") { + return provider("GitHub Pages", confidence: .medium, evidence: providerEvidence(headerMap: headerMap, dnsProviders: dnsProviders, matches: ["github"])) + } + return nil + } + + private static func classify(snapshot: LookupSnapshot) -> DomainClassificationSummary? { + let host = snapshot.domain.lowercased() + let headerValues = snapshot.httpHeaders.map { "\($0.name.lowercased()):\($0.value.lowercased())" } + let finalURL = snapshot.redirectChain.last?.url.lowercased() ?? "" + + if host.hasPrefix("api.") || host.contains(".api.") { + return .init(kind: .api, confidence: .high, reasons: ["Hostname pattern"]) + } + if containsAny(in: [host, finalURL], matching: ["auth", "login", "sso", "oauth"]) { + return .init(kind: .auth, confidence: .high, reasons: ["Auth-oriented host or redirect"]) + } + if containsAny(in: [host, finalURL], matching: ["docs", "developer", "developers", "help"]) { + return .init(kind: .docs, confidence: .high, reasons: ["Docs-oriented host or redirect"]) + } + if containsAny(in: [host, finalURL], matching: ["status", "statuspage", "health"]) { + return .init(kind: .status, confidence: .medium, reasons: ["Status-oriented host or redirect"]) + } + if containsAny(in: [host], matching: ["cdn.", "static.", "assets.", "img."]) { + return .init(kind: .staticSite, confidence: .medium, reasons: ["Static asset hostname"]) + } + if containsAny(in: [host], matching: ["app.", "portal.", "admin.", "dashboard."]) { + return .init(kind: .app, confidence: .medium, reasons: ["Application hostname"]) + } + if containsAny(in: [host], matching: ["vpn.", "internal.", "infra."]) || headerValues.contains(where: { $0.contains("x-envoy") }) { + return .init(kind: .infrastructure, confidence: .medium, reasons: ["Infrastructure-oriented hostname or headers"]) + } + if host == apexDomain(for: host) || host.hasPrefix("www.") { + return .init(kind: .marketing, confidence: .low, reasons: ["Apex or www host"]) + } + return nil + } + + private static func ownershipTransitions(from snapshots: [LookupSnapshot]) -> [OwnershipTransitionEvent] { + zip(snapshots, snapshots.dropFirst()).compactMap { previous, current in + guard let previousOwnership = previous.ownership, let currentOwnership = current.ownership else { + return nil + } + var changeParts: [String] = [] + if previousOwnership.registrar != currentOwnership.registrar { + changeParts.append("registrar") + } + if previousOwnership.registrant != currentOwnership.registrant { + changeParts.append("ownership") + } + if normalized(previousOwnership.nameservers) != normalized(currentOwnership.nameservers) { + changeParts.append("nameservers") + } + guard !changeParts.isEmpty else { return nil } + return OwnershipTransitionEvent( + date: current.timestamp, + summary: "Changed \(changeParts.joined(separator: ", "))", + previousRegistrar: previousOwnership.registrar, + currentRegistrar: currentOwnership.registrar, + previousRegistrant: previousOwnership.registrant, + currentRegistrant: currentOwnership.registrant, + previousNameservers: previousOwnership.nameservers, + currentNameservers: currentOwnership.nameservers + ) + } + .sorted { $0.date > $1.date } + } + + private static func hostingTransitions(from observations: [(Date, InferredProviderFingerprint)]) -> [HostingTransitionEvent] { + zip(observations, observations.dropFirst()).compactMap { previous, current in + guard previous.1.name != current.1.name else { return nil } + return HostingTransitionEvent( + date: current.0, + fromProvider: previous.1.name, + toProvider: current.1.name, + summary: "Hosting moved from \(previous.1.name) to \(current.1.name)" + ) + } + .sorted { $0.date > $1.date } + } + + private static func buildSubdomainHistory(from snapshots: [LookupSnapshot]) -> [SubdomainHistoryEntry] { + struct WorkingState { + var firstSeen: Date + var lastSeen: Date + var recurrenceCount: Int + var statusChangeCount: Int + var lastSeenInPreviousSnapshot: Bool + } + + var states: [String: WorkingState] = [:] + for snapshot in snapshots { + let currentHosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() }) + let knownHosts = Set(states.keys).union(currentHosts) + for host in knownHosts { + let isPresent = currentHosts.contains(host) + if var state = states[host] { + if isPresent { + state.lastSeen = snapshot.timestamp + state.recurrenceCount += 1 + } + if state.lastSeenInPreviousSnapshot != isPresent { + state.statusChangeCount += 1 + } + state.lastSeenInPreviousSnapshot = isPresent + states[host] = state + } else if isPresent { + states[host] = WorkingState( + firstSeen: snapshot.timestamp, + lastSeen: snapshot.timestamp, + recurrenceCount: 1, + statusChangeCount: 0, + lastSeenInPreviousSnapshot: true + ) + } + } + } + + return states.map { host, state in + let isEphemeral = state.recurrenceCount <= 2 || state.statusChangeCount >= 2 + return SubdomainHistoryEntry( + hostname: host, + firstSeen: state.firstSeen, + lastSeen: state.lastSeen, + recurrenceCount: state.recurrenceCount, + statusChangeCount: state.statusChangeCount, + lastKnownStatus: state.lastSeenInPreviousSnapshot ? "Active" : "Inactive", + isEphemeral: isEphemeral + ) + } + .sorted { lhs, rhs in + if lhs.isEphemeral != rhs.isEphemeral { + return lhs.isEphemeral && !rhs.isEphemeral + } + return lhs.hostname < rhs.hostname + } + } + + private static func buildRiskSignals( + snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot?, + ownershipTransitions: [OwnershipTransitionEvent], + hostingTransitions: [HostingTransitionEvent], + subdomainHistory: [SubdomainHistoryEntry] + ) -> [IntelligenceRiskSignal] { + var signals: [IntelligenceRiskSignal] = [] + let dnsInstabilityCount = snapshot.dnsHistory.filter { !$0.changedRecordTypes.isEmpty }.count + let ephemeralSubdomains = subdomainHistory.filter(\.isEphemeral) + + if ownershipTransitions.count >= 2 { + signals.append(.init( + id: "ownership-churn", + title: "Ownership churn", + detail: "Observed \(ownershipTransitions.count) ownership transitions in local history.", + severity: .high, + firstObserved: ownershipTransitions.last?.date, + lastObserved: ownershipTransitions.first?.date + )) + } + if dnsInstabilityCount >= 3 { + signals.append(.init( + id: "unstable-dns", + title: "Unstable DNS", + detail: "DNS history shows \(dnsInstabilityCount) recorded change events.", + severity: .medium, + firstObserved: snapshot.dnsHistory.last?.date, + lastObserved: snapshot.dnsHistory.first?.date + )) + } + if hostingTransitions.count >= 2 { + signals.append(.init( + id: "repeated-hosting-moves", + title: "Repeated hosting moves", + detail: "Infrastructure provider changed \(hostingTransitions.count) times across observations.", + severity: .medium, + firstObserved: hostingTransitions.last?.date, + lastObserved: hostingTransitions.first?.date + )) + } + if !ephemeralSubdomains.isEmpty { + signals.append(.init( + id: "ephemeral-subdomains", + title: "Ephemeral subdomains", + detail: "\(ephemeralSubdomains.count) subdomains appear short-lived or unstable.", + severity: ephemeralSubdomains.count >= 3 ? .medium : .low, + firstObserved: ephemeralSubdomains.map(\.firstSeen).min(), + lastObserved: ephemeralSubdomains.map(\.lastSeen).max() + )) + } + if let createdDate = snapshot.ownership?.createdDate { + let ageDays = Calendar.current.dateComponents([.day], from: createdDate, to: snapshot.timestamp).day ?? 0 + if ageDays <= 180 { + signals.append(.init( + id: "young-registration", + title: "Short registration age", + detail: "Domain registration is \(ageDays) days old.", + severity: ageDays <= 90 ? .high : .medium, + firstObserved: createdDate, + lastObserved: snapshot.timestamp + )) + } + } + if let previousSnapshot, + let previousProvider = inferProvider(from: previousSnapshot)?.name, + let currentProvider = inferProvider(from: snapshot)?.name, + previousProvider != currentProvider { + signals.append(.init( + id: "recent-hosting-move", + title: "Recent hosting move", + detail: "Latest snapshot moved from \(previousProvider) to \(currentProvider).", + severity: .medium, + firstObserved: snapshot.timestamp, + lastObserved: snapshot.timestamp + )) + } + return signals.sorted { ($0.lastObserved ?? .distantPast) > ($1.lastObserved ?? .distantPast) } + } + + private static func buildTimelineEvents( + snapshot: LookupSnapshot, + observations: [LookupSnapshot], + providerObservations: [(Date, InferredProviderFingerprint)], + ownershipTransitions: [OwnershipTransitionEvent], + hostingTransitions: [HostingTransitionEvent], + riskSignals: [IntelligenceRiskSignal] + ) -> [IntelligenceTimelineEvent] { + var events: [IntelligenceTimelineEvent] = [] + + events += ownershipTransitions.map { + .init(date: $0.date, category: .ownership, title: "Ownership transition", detail: $0.summary, severity: .high) + } + events += snapshot.dnsHistory.map { + .init(date: $0.date, category: .dns, title: "DNS change", detail: $0.summary, severity: $0.changedRecordTypes.contains(.A) || $0.changedRecordTypes.contains(.NS) ? .high : .medium) + } + events += hostingTransitions.map { + .init(date: $0.date, category: .hosting, title: "Hosting transition", detail: $0.summary, severity: .medium) + } + events += buildClassificationEvents(from: observations) + events += buildSubdomainDiscoveryEvents(from: observations) + events += riskSignals.compactMap { + guard let date = $0.lastObserved ?? $0.firstObserved else { return nil } + return IntelligenceTimelineEvent(date: date, category: .risk, title: $0.title, detail: $0.detail, severity: $0.severity) + } + if let latestProvider = providerObservations.last?.1 { + events.append(.init( + date: snapshot.timestamp, + category: .hosting, + title: "Current infrastructure", + detail: "Likely running on \(latestProvider.name)", + severity: .low + )) + } + return events.sorted { $0.date > $1.date } + } + + private static func buildClassificationEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] { + let classifications = observations.compactMap { snapshot -> (Date, DomainClassificationSummary)? in + classify(snapshot: snapshot).map { (snapshot.timestamp, $0) } + } + return zip(classifications, classifications.dropFirst()).compactMap { previous, current in + guard previous.1.kind != current.1.kind else { return nil } + return .init( + date: current.0, + category: .classification, + title: "Classification changed", + detail: "\(previous.1.kind.title) -> \(current.1.kind.title)", + severity: .medium + ) + } + } + + private static func buildSubdomainDiscoveryEvents(from observations: [LookupSnapshot]) -> [IntelligenceTimelineEvent] { + var seen = Set() + var events: [IntelligenceTimelineEvent] = [] + for snapshot in observations { + let hosts = Set((snapshot.subdomains + snapshot.extendedSubdomains).map { $0.hostname.lowercased() }) + for host in hosts where seen.insert(host).inserted { + events.append(.init( + date: snapshot.timestamp, + category: .subdomain, + title: "Subdomain observed", + detail: host, + severity: .low + )) + } + } + return events + } + + private static func provider(_ name: String, confidence: ConfidenceLevel, evidence: [String]) -> InferredProviderFingerprint { + .init(name: name, confidence: confidence, evidence: evidence) + } + + private static func providerEvidence(headerMap: [String: String], dnsProviders: [String], matches: [String]) -> [String] { + var evidence: [String] = [] + for match in matches { + if headerMap.keys.contains(match) || headerMap.values.contains(where: { $0.contains(match) }) { + evidence.append("HTTP \(match)") + } + if dnsProviders.contains(where: { $0.lowercased().contains(match) }) { + evidence.append("DNS \(match)") + } + } + return Array(Set(evidence)).sorted() + } + + private static func dnsValues(for type: DNSRecordType, in sections: [DNSSection]) -> [String] { + sections + .first(where: { $0.recordType == type })? + .records + .map(\.value) ?? [] + } + + private static func normalized(_ values: [String]) -> [String] { + values.map { $0.lowercased() }.sorted() + } + + private static func containsAny(in values: [String], matching patterns: [String]) -> Bool { + values.contains { value in + let normalized = value.lowercased() + return patterns.contains { normalized.contains($0) } + } + } + + private static func containsHeaderValue(_ headerMap: [String: String], value: String) -> Bool { + headerMap.values.contains(where: { $0.contains(value) }) + } + + private static func apexDomain(for host: String) -> String { + let parts = host.split(separator: ".") + guard parts.count > 2 else { return host } + return parts.suffix(2).joined(separator: ".") + } +} -- cgit v1.2.3