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. --- DomainDig/Models.swift | 251 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) (limited to 'DomainDig/Models.swift') diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 8e1d494..376d264 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -473,6 +473,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { let summary: String let aRecords: [String] let nameservers: [String] + let recordSnapshots: [DNSHistoryRecordSnapshot] + let changedRecordTypes: [DNSRecordType] let source: String let isExternal: Bool @@ -482,6 +484,8 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { summary: String, aRecords: [String] = [], nameservers: [String] = [], + recordSnapshots: [DNSHistoryRecordSnapshot] = [], + changedRecordTypes: [DNSRecordType] = [], source: String, isExternal: Bool ) { @@ -490,9 +494,225 @@ struct DNSHistoryEvent: Identifiable, Codable, Equatable, Sendable { self.summary = summary self.aRecords = aRecords self.nameservers = nameservers + self.recordSnapshots = recordSnapshots + self.changedRecordTypes = changedRecordTypes self.source = source self.isExternal = isExternal } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + date = try container.decode(Date.self, forKey: .date) + summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "DNS change observed" + aRecords = try container.decodeIfPresent([String].self, forKey: .aRecords) ?? [] + nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? [] + let decodedRecordSnapshots = try container.decodeIfPresent([DNSHistoryRecordSnapshot].self, forKey: .recordSnapshots) ?? [] + if decodedRecordSnapshots.isEmpty { + var synthesizedSnapshots: [DNSHistoryRecordSnapshot] = [] + if !aRecords.isEmpty { + synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .A, values: aRecords)) + } + if !nameservers.isEmpty { + synthesizedSnapshots.append(DNSHistoryRecordSnapshot(recordType: .NS, values: nameservers)) + } + recordSnapshots = synthesizedSnapshots + } else { + recordSnapshots = decodedRecordSnapshots + } + changedRecordTypes = try container.decodeIfPresent([DNSRecordType].self, forKey: .changedRecordTypes) + ?? recordSnapshots.map(\.recordType) + source = try container.decodeIfPresent(String.self, forKey: .source) ?? "Unknown" + isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false + } +} + +struct DNSHistoryRecordSnapshot: Identifiable, Codable, Sendable, Equatable { + let id: UUID + let recordType: DNSRecordType + let values: [String] + + nonisolated init(id: UUID = UUID(), recordType: DNSRecordType, values: [String]) { + self.id = id + self.recordType = recordType + self.values = values + } + + static func == (lhs: DNSHistoryRecordSnapshot, rhs: DNSHistoryRecordSnapshot) -> Bool { + lhs.recordType == rhs.recordType && lhs.values == rhs.values + } +} + +struct InferredProviderFingerprint: Codable, Equatable, Sendable { + let name: String + let confidence: ConfidenceLevel + let evidence: [String] +} + +enum DomainClassificationKind: String, Codable, CaseIterable, Sendable { + case marketing + case app + case api + case auth + case docs + case staticSite = "static" + case infrastructure + case status + case unknown + + var title: String { + switch self { + case .staticSite: + return "Static" + default: + return rawValue.capitalized + } + } +} + +struct DomainClassificationSummary: Codable, Equatable, Sendable { + let kind: DomainClassificationKind + let confidence: ConfidenceLevel + let reasons: [String] +} + +struct OwnershipTransitionEvent: Identifiable, Codable, Equatable, Sendable { + let id: UUID + let date: Date + let summary: String + let previousRegistrar: String? + let currentRegistrar: String? + let previousRegistrant: String? + let currentRegistrant: String? + let previousNameservers: [String] + let currentNameservers: [String] + + nonisolated init( + id: UUID = UUID(), + date: Date, + summary: String, + previousRegistrar: String? = nil, + currentRegistrar: String? = nil, + previousRegistrant: String? = nil, + currentRegistrant: String? = nil, + previousNameservers: [String] = [], + currentNameservers: [String] = [] + ) { + self.id = id + self.date = date + self.summary = summary + self.previousRegistrar = previousRegistrar + self.currentRegistrar = currentRegistrar + self.previousRegistrant = previousRegistrant + self.currentRegistrant = currentRegistrant + self.previousNameservers = previousNameservers + self.currentNameservers = currentNameservers + } +} + +struct HostingTransitionEvent: Identifiable, Codable, Equatable, Sendable { + let id: UUID + let date: Date + let fromProvider: String + let toProvider: String + let summary: String + + nonisolated init(id: UUID = UUID(), date: Date, fromProvider: String, toProvider: String, summary: String) { + self.id = id + self.date = date + self.fromProvider = fromProvider + self.toProvider = toProvider + self.summary = summary + } +} + +struct SubdomainHistoryEntry: Identifiable, Codable, Equatable, Sendable { + let id: String + let hostname: String + let firstSeen: Date + let lastSeen: Date + let recurrenceCount: Int + let statusChangeCount: Int + let lastKnownStatus: String + let isEphemeral: Bool + + nonisolated init( + hostname: String, + firstSeen: Date, + lastSeen: Date, + recurrenceCount: Int, + statusChangeCount: Int, + lastKnownStatus: String, + isEphemeral: Bool + ) { + id = hostname.lowercased() + self.hostname = hostname + self.firstSeen = firstSeen + self.lastSeen = lastSeen + self.recurrenceCount = recurrenceCount + self.statusChangeCount = statusChangeCount + self.lastKnownStatus = lastKnownStatus + self.isEphemeral = isEphemeral + } +} + +struct IntelligenceRiskSignal: Identifiable, Codable, Equatable, Sendable { + let id: String + let title: String + let detail: String + let severity: ChangeSeverity + let firstObserved: Date? + let lastObserved: Date? + + nonisolated init( + id: String, + title: String, + detail: String, + severity: ChangeSeverity, + firstObserved: Date? = nil, + lastObserved: Date? = nil + ) { + self.id = id + self.title = title + self.detail = detail + self.severity = severity + self.firstObserved = firstObserved + self.lastObserved = lastObserved + } +} + +enum IntelligenceTimelineEventCategory: String, Codable, Sendable { + case ownership + case dns + case hosting + case subdomain + case classification + case risk +} + +struct IntelligenceTimelineEvent: Identifiable, Codable, Equatable, Sendable { + let id: UUID + let date: Date + let category: IntelligenceTimelineEventCategory + let title: String + let detail: String + let severity: ChangeSeverity + + nonisolated init( + id: UUID = UUID(), + date: Date, + category: IntelligenceTimelineEventCategory, + title: String, + detail: String, + severity: ChangeSeverity + ) { + self.id = id + self.date = date + self.category = category + self.title = title + self.detail = detail + self.severity = severity + } } struct DomainPricingInsight: Codable, Equatable, Sendable { @@ -2051,6 +2271,14 @@ struct HistoryEntry: Identifiable, Codable { var mtaSts: MTASTSResult? var ownership: DomainOwnership? var ownershipHistory: [DomainOwnershipHistoryEvent] + var inferredProvider: InferredProviderFingerprint? + var priorProviders: [String] + var domainClassification: DomainClassificationSummary? + var ownershipTransitions: [OwnershipTransitionEvent] + var hostingTransitions: [HostingTransitionEvent] + var subdomainHistory: [SubdomainHistoryEntry] + var riskSignals: [IntelligenceRiskSignal] + var intelligenceTimeline: [IntelligenceTimelineEvent] var ptrRecord: String? var redirectChain: [RedirectHop] var subdomains: [DiscoveredSubdomain] @@ -2106,6 +2334,13 @@ struct HistoryEntry: Identifiable, Codable { reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, ownershipHistory: [DomainOwnershipHistoryEvent] = [], + inferredProvider: InferredProviderFingerprint? = nil, priorProviders: [String] = [], + domainClassification: DomainClassificationSummary? = nil, + ownershipTransitions: [OwnershipTransitionEvent] = [], + hostingTransitions: [HostingTransitionEvent] = [], + subdomainHistory: [SubdomainHistoryEntry] = [], + riskSignals: [IntelligenceRiskSignal] = [], + intelligenceTimeline: [IntelligenceTimelineEvent] = [], ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], domainPricing: DomainPricingInsight? = nil, @@ -2141,6 +2376,14 @@ struct HistoryEntry: Identifiable, Codable { self.mtaSts = mtaSts ?? emailSecurity?.mtaSts self.ownership = ownership self.ownershipHistory = ownershipHistory + self.inferredProvider = inferredProvider + self.priorProviders = priorProviders + self.domainClassification = domainClassification + self.ownershipTransitions = ownershipTransitions + self.hostingTransitions = hostingTransitions + self.subdomainHistory = subdomainHistory + self.riskSignals = riskSignals + self.intelligenceTimeline = intelligenceTimeline self.ptrRecord = ptrRecord self.redirectChain = redirectChain self.subdomains = subdomains @@ -2208,6 +2451,14 @@ struct HistoryEntry: Identifiable, Codable { mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) ownershipHistory = try container.decodeIfPresent([DomainOwnershipHistoryEvent].self, forKey: .ownershipHistory) ?? [] + inferredProvider = try container.decodeIfPresent(InferredProviderFingerprint.self, forKey: .inferredProvider) + priorProviders = try container.decodeIfPresent([String].self, forKey: .priorProviders) ?? [] + domainClassification = try container.decodeIfPresent(DomainClassificationSummary.self, forKey: .domainClassification) + ownershipTransitions = try container.decodeIfPresent([OwnershipTransitionEvent].self, forKey: .ownershipTransitions) ?? [] + hostingTransitions = try container.decodeIfPresent([HostingTransitionEvent].self, forKey: .hostingTransitions) ?? [] + subdomainHistory = try container.decodeIfPresent([SubdomainHistoryEntry].self, forKey: .subdomainHistory) ?? [] + riskSignals = try container.decodeIfPresent([IntelligenceRiskSignal].self, forKey: .riskSignals) ?? [] + intelligenceTimeline = try container.decodeIfPresent([IntelligenceTimelineEvent].self, forKey: .intelligenceTimeline) ?? [] ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] -- cgit v1.2.3