diff options
| author | Christian Cleberg <[email protected]> | 2026-04-21 22:08:09 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-21 22:08:09 -0500 |
| commit | 22ecca12b4fe0e0850401754c674632f322d919d (patch) | |
| tree | 52845c3020c2471d607e7a1ad9681de83cbcbb85 /DomainDig/DomainViewModel.swift | |
| parent | 1f58092dcb67cded75a850db364f3b46d86181f3 (diff) | |
| download | domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.tar.gz domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.tar.bz2 domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.zip | |
feat(v2.3.0): add ownership intelligence and subdomain discovery
* implement RDAP-based ownership section
* add ownership diffing and change classification
* add passive subdomain discovery via certificate transparency
* highlight interesting subdomains
* introduce Data+ scaffolding for future features
* include ownership and subdomains in export and history
Diffstat (limited to 'DomainDig/DomainViewModel.swift')
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 214 |
1 files changed, 212 insertions, 2 deletions
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 031c062..e895ce6 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -72,6 +72,18 @@ struct PortScanRowViewData: Identifiable { let durationLabel: String? } +struct SubdomainRowViewData: Identifiable { + let id: String + let hostname: String + let isInteresting: Bool + + init(hostname: String, isInteresting: Bool) { + self.id = hostname + self.hostname = hostname + self.isInteresting = isInteresting + } +} + struct DomainSuggestionViewData: Identifiable { let id: UUID let domain: String @@ -107,10 +119,14 @@ struct LookupSnapshot { let ipGeolocationError: String? let emailSecurity: EmailSecurityResult? let emailSecurityError: String? + let ownership: DomainOwnership? + let ownershipError: String? let ptrRecord: String? let ptrError: String? let redirectChain: [RedirectHop] let redirectChainError: String? + let subdomains: [DiscoveredSubdomain] + let subdomainsError: String? let portScanResults: [PortScanResult] let portScanError: String? let changeSummary: DomainChangeSummary? @@ -147,10 +163,14 @@ extension HistoryEntry { ipGeolocationError: ipGeolocationError, emailSecurity: emailSecurity, emailSecurityError: emailSecurityError, + ownership: ownership, + ownershipError: ownershipError, ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, redirectChainError: redirectChainError, + subdomains: subdomains, + subdomainsError: subdomainsError, portScanResults: portScanResults, portScanError: portScanError, changeSummary: changeSummary, @@ -204,6 +224,10 @@ final class DomainViewModel { var emailSecurityLoading = false var emailSecurityError: String? + var ownershipResult: DomainOwnership? + var ownershipLoading = false + var ownershipError: String? + var ptrRecord: String? var ptrLoading = false var ptrError: String? @@ -212,6 +236,10 @@ final class DomainViewModel { var redirectChainLoading = false var redirectChainError: String? + var subdomains: [DiscoveredSubdomain] = [] + var subdomainsLoading = false + var subdomainsError: String? + var portScanResults: [PortScanResult] = [] var portScanLoading = false var portScanError: String? @@ -224,6 +252,7 @@ final class DomainViewModel { private(set) var lastLookupDurationMs: Int? private(set) var currentDiffSections: [DomainDiffSection] = [] private(set) var currentChangeSummary: DomainChangeSummary? + private(set) var ownershipDiff: [DomainDiffItem] = [] private(set) var refreshingTrackedDomainID: UUID? private(set) var rerunNavigationToken = UUID() private(set) var batchResults: [BatchLookupResult] = [] @@ -290,8 +319,10 @@ final class DomainViewModel { !reachabilityLoading && !ipGeolocationLoading && !emailSecurityLoading && + !ownershipLoading && !ptrLoading && !redirectChainLoading && + !subdomainsLoading && !portScanLoading && !customPortScanLoading } @@ -438,10 +469,14 @@ final class DomainViewModel { ipGeolocationError: ipGeolocationError, emailSecurity: emailSecurity, emailSecurityError: emailSecurityError, + ownership: ownershipResult, + ownershipError: ownershipError, ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, redirectChainError: redirectChainError, + subdomains: subdomains, + subdomainsError: subdomainsError, portScanResults: allPortScanResults, portScanError: combinedPortScanError, changeSummary: currentChangeSummary, @@ -489,6 +524,14 @@ final class DomainViewModel { Self.emailRows(from: currentSnapshot) } + var ownershipRows: [InfoRowViewData] { + Self.ownershipRows(from: currentSnapshot) + } + + var subdomainRows: [SubdomainRowViewData] { + Self.subdomainRows(from: currentSnapshot) + } + var reachabilityRows: [ReachabilityRowViewData] { Self.reachabilityRows(from: currentSnapshot) } @@ -643,6 +686,7 @@ final class DomainViewModel { lastLookupDurationMs = nil currentDiffSections = [] currentChangeSummary = nil + ownershipDiff = [] refreshingTrackedDomainID = nil clearBatchState() clearLookupState() @@ -791,6 +835,8 @@ final class DomainViewModel { group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } + group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) } + group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) } } await withTaskGroup(of: Void.self) { group in @@ -946,6 +992,23 @@ final class DomainViewModel { emailSecurityLoading = false } + private func runOwnership(domain: String, lookupID: UUID) async { + let result = await DomainOwnershipService.lookup(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(ownership): + ownershipResult = ownership + ownershipError = nil + case let .empty(message): + ownershipResult = nil + ownershipError = message + case let .error(message): + ownershipResult = nil + ownershipError = message + } + ownershipLoading = false + } + private func runReverseDNS(ip: String, lookupID: UUID) async { let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString) guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } @@ -980,6 +1043,23 @@ final class DomainViewModel { redirectChainLoading = false } + private func runSubdomains(domain: String, lookupID: UUID) async { + let result = await SubdomainDiscoveryService.discover(for: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(results): + subdomains = results + subdomainsError = nil + case let .empty(message): + subdomains = [] + subdomainsError = message + case let .error(message): + subdomains = [] + subdomainsError = message + } + subdomainsLoading = false + } + private func runPortScan(domain: String, lookupID: UUID) async { let result = await PortScanService.scanAll(domain: domain) switch result { @@ -1061,7 +1141,9 @@ final class DomainViewModel { async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain) async let httpResult = HTTPHeadersService.fetch(domain: domain) async let reachabilityResult = ReachabilityService.checkAll(domain: domain) + async let ownershipResult = DomainOwnershipService.lookup(domain: domain) async let redirectResult = RedirectChainService.trace(domain: domain) + async let subdomainResult = SubdomainDiscoveryService.discover(for: domain) async let portScanResult = PortScanService.scanAll(domain: domain) let resolvedDNS = await dnsResult @@ -1070,7 +1152,9 @@ final class DomainViewModel { let hsts = await hstsResult let http = await httpResult let reachability = await reachabilityResult + let resolvedOwnership = await ownershipResult let redirects = await redirectResult + let resolvedSubdomains = await subdomainResult let ports = await portScanResult guard !Task.isCancelled else { return nil } @@ -1187,6 +1271,17 @@ final class DomainViewModel { emailSecurityError = message } + let ownership: DomainOwnership? + let ownershipError: String? + switch resolvedOwnership { + case let .success(result): + ownership = result + ownershipError = nil + case let .empty(message), let .error(message): + ownership = nil + ownershipError = message + } + let ptrRecord: String? let ptrError: String? switch resolvedPTR { @@ -1215,6 +1310,17 @@ final class DomainViewModel { ipGeolocationError = "No A record available" } + let subdomains: [DiscoveredSubdomain] + let subdomainsError: String? + switch resolvedSubdomains { + case let .success(result): + subdomains = result + subdomainsError = nil + case let .empty(message), let .error(message): + subdomains = [] + subdomainsError = message + } + let snapshot = LookupSnapshot( historyEntryID: nil, domain: domain, @@ -1243,10 +1349,14 @@ final class DomainViewModel { ipGeolocationError: ipGeolocationError, emailSecurity: emailSecurity, emailSecurityError: emailSecurityError, + ownership: ownership, + ownershipError: ownershipError, ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, redirectChainError: redirectChainError, + subdomains: subdomains, + subdomainsError: subdomainsError, portScanResults: portScanResults, portScanError: portScanError, changeSummary: nil, @@ -1303,6 +1413,7 @@ final class DomainViewModel { if updateCurrentState { currentChangeSummary = changeSummary currentDiffSections = diffSections + ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] } let entry = HistoryEntry( @@ -1316,8 +1427,10 @@ final class DomainViewModel { ipGeolocation: snapshot.ipGeolocation, emailSecurity: snapshot.emailSecurity, mtaSts: snapshot.emailSecurity?.mtaSts, + ownership: snapshot.ownership, ptrRecord: snapshot.ptrRecord, redirectChain: snapshot.redirectChain, + subdomains: snapshot.subdomains, portScanResults: snapshot.portScanResults, hstsPreloaded: snapshot.hstsPreloaded, availabilityResult: snapshot.availabilityResult, @@ -1336,8 +1449,10 @@ final class DomainViewModel { reachabilityError: snapshot.reachabilityError, ipGeolocationError: snapshot.ipGeolocationError, emailSecurityError: snapshot.emailSecurityError, + ownershipError: snapshot.ownershipError, ptrError: snapshot.ptrError, redirectChainError: snapshot.redirectChainError, + subdomainsError: snapshot.subdomainsError, portScanError: snapshot.portScanError ) @@ -1520,6 +1635,7 @@ final class DomainViewModel { hasRun = true currentDiffSections = [] currentChangeSummary = nil + ownershipDiff = [] clearLookupState() setAllLoadingStates(true) customPortScanLoading = false @@ -1677,8 +1793,8 @@ final class DomainViewModel { let summary = BatchSweepSummary( source: source, totalDomains: batchResults.count, - changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count, - unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count, + changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count, + unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count, warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count, results: batchResults.sorted { lhs, rhs in if lhs.status != rhs.status { @@ -1754,12 +1870,18 @@ final class DomainViewModel { emailSecurity = nil emailSecurityError = nil emailSecurityLoading = false + ownershipResult = nil + ownershipError = nil + ownershipLoading = false ptrRecord = nil ptrError = nil ptrLoading = false redirectChain = [] redirectChainError = nil redirectChainLoading = false + subdomains = [] + subdomainsError = nil + subdomainsLoading = false portScanResults = [] portScanError = nil portScanLoading = false @@ -1778,8 +1900,10 @@ final class DomainViewModel { reachabilityLoading = loading ipGeolocationLoading = loading emailSecurityLoading = loading + ownershipLoading = loading ptrLoading = loading redirectChainLoading = loading + subdomainsLoading = loading portScanLoading = loading } @@ -1872,10 +1996,14 @@ final class DomainViewModel { ipGeolocationError: nil, emailSecurity: nil, emailSecurityError: nil, + ownership: nil, + ownershipError: nil, ptrRecord: nil, ptrError: nil, redirectChain: [], redirectChainError: nil, + subdomains: [], + subdomainsError: nil, portScanResults: [], portScanError: nil, changeSummary: trackedDomain.lastChangeSummary, @@ -2100,6 +2228,28 @@ final class DomainViewModel { ] } + static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { + let ownership = snapshot.ownership + + return [ + InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary), + InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary), + InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary), + InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary), + InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary), + InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary) + ] + } + + static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] { + snapshot.subdomains.map { subdomain in + SubdomainRowViewData( + hostname: subdomain.hostname, + isInteresting: isInterestingSubdomain(subdomain.hostname) + ) + } + } + static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] { snapshot.reachabilityResults.map { ReachabilityRowViewData( @@ -2179,6 +2329,12 @@ final class DomainViewModel { "tls_status", "http_status_grade", "email_security_summary", + "registrar", + "ownership_expires", + "ownership_status", + "ownership_nameservers", + "subdomain_count", + "subdomains", "last_updated" ] @@ -2191,6 +2347,12 @@ final class DomainViewModel { httpsSummary(from: snapshot), httpStatusGradeSummary(from: snapshot), emailSummary(from: snapshot), + snapshot.ownership?.registrar ?? "", + snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "", + snapshot.ownership?.status.joined(separator: " | ") ?? "", + snapshot.ownership?.nameservers.joined(separator: " | ") ?? "", + "\(snapshot.subdomains.count)", + snapshot.subdomains.map(\.hostname).joined(separator: " | "), csvDateFormatter.string(from: snapshot.timestamp) ] } @@ -2282,6 +2444,33 @@ final class DomainViewModel { } } + appendSection("Ownership") { + for row in ownershipRows(from: snapshot) { + lines.append(" \(row.label): \(row.value)") + } + if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil { + lines.append(" Source: \(ownershipError)") + } + if !DataAccessService.hasAccess(to: .ownershipHistory) { + lines.append(" Ownership history (coming soon)") + } + } + + appendSection("Subdomains") { + lines.append(" Count: \(snapshot.subdomains.count)") + if snapshot.subdomains.isEmpty { + lines.append(" \(snapshot.subdomainsError ?? "No passive subdomains found")") + } else { + for subdomain in subdomainRows(from: snapshot) { + let marker = subdomain.isInteresting ? " [interesting]" : "" + lines.append(" \(subdomain.hostname)\(marker)") + } + } + if !DataAccessService.hasAccess(to: .extendedSubdomains) { + lines.append(" Extended subdomain discovery (Data+)") + } + } + appendSection("DNS") { if let dnsError = snapshot.dnsError { lines.append(" Error: \(dnsError)") @@ -2544,6 +2733,13 @@ final class DomainViewModel { return formatter }() + private static let ownershipDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() + private static let csvDateFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] @@ -2554,6 +2750,14 @@ final class DomainViewModel { let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" } + + private static func isInterestingSubdomain(_ hostname: String) -> Bool { + let keywords = ["admin", "api", "dev", "staging", "test", "internal"] + let labels = hostname.lowercased().split(separator: ".").map(String.init) + return labels.contains { label in + keywords.contains(where: { label.contains($0) }) + } + } } private extension String { @@ -2565,3 +2769,9 @@ private extension String { isEmpty ? nil : self } } + +private extension Array where Element == String { + var nilIfEmpty: [String]? { + isEmpty ? nil : self + } +} |
