Commit 22ecca12b4

22ecca12b4fe0e0850401754c674632f322d919d

parent: 1f58092dcb

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 03:08 UTC

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

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -267,7 +267,7 @@
267267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
268268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
269269 CODE_SIGN_STYLE = Automatic;
270 CURRENT_PROJECT_VERSION = 15;
270 CURRENT_PROJECT_VERSION = 16;
271271 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272272 ENABLE_PREVIEWS = YES;
273273 GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
284284 "$(inherited)",
285285 "@executable_path/Frameworks",
286286 );
287 MARKETING_VERSION = 2.2.0;
287 MARKETING_VERSION = 2.3.0;
288288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
289289 PRODUCT_NAME = "$(TARGET_NAME)";
290290 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
303303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
304304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
305305 CODE_SIGN_STYLE = Automatic;
306 CURRENT_PROJECT_VERSION = 15;
306 CURRENT_PROJECT_VERSION = 16;
307307 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308308 ENABLE_PREVIEWS = YES;
309309 GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
320320 "$(inherited)",
321321 "@executable_path/Frameworks",
322322 );
323 MARKETING_VERSION = 2.2.0;
323 MARKETING_VERSION = 2.3.0;
324324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325325 PRODUCT_NAME = "$(TARGET_NAME)";
326326 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/BatchResultsView.swift +3
@@ -115,6 +115,9 @@ struct BatchResultRowView: View {
115115 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
116116 return .yellow
117117 }
118 if result.quickStatus == "Changed" {
119 return .blue
120 }
118121 return .green
119122 case .failed:
120123 return .red
DomainDig/BatchSweepSummaryView.swift +4 −1
@@ -12,7 +12,10 @@ struct BatchSweepSummaryView: View {
1212 }
1313
1414 return summary.results.filter {
15 ($0.changeSeverity ?? .low) >= .medium || $0.certificateWarningLevel != .none || $0.status == .failed
15 $0.quickStatus == "Changed" ||
16 $0.quickStatus == "High" ||
17 $0.certificateWarningLevel != .none ||
18 $0.status == .failed
1619 }
1720 }
1821
DomainDig/ContentView.swift +100
@@ -60,6 +60,20 @@ struct ContentView: View {
6060 }
6161 )
6262 .padding(.top, 16)
63 OwnershipSectionView(
64 rows: viewModel.ownershipRows,
65 loading: viewModel.ownershipLoading,
66 error: viewModel.ownershipError,
67 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
68 )
69 .padding(.top, 16)
70 SubdomainsSectionView(
71 rows: viewModel.subdomainRows,
72 loading: viewModel.subdomainsLoading,
73 error: viewModel.subdomainsError,
74 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
75 )
76 .padding(.top, 16)
6377 if !viewModel.currentDiffSections.isEmpty {
6478 DomainDiffView(
6579 title: "Latest Changes",
@@ -772,6 +786,92 @@ struct DomainSectionView: View {
772786 }
773787}
774788
789struct OwnershipSectionView: View {
790 let rows: [InfoRowViewData]
791 let loading: Bool
792 let error: String?
793 let showsHistoryPlaceholder: Bool
794
795 var body: some View {
796 VStack(alignment: .leading, spacing: 12) {
797 SectionTitleView(title: "Ownership")
798 CardView(allowsHorizontalScroll: false) {
799 if loading {
800 ProgressView("Fetching RDAP ownership…")
801 .appLoadingStyle()
802 } else {
803 ForEach(rows) { row in
804 LabeledValueRow(row: row)
805 }
806 if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
807 MessageRowView(text: error, isError: error != "Unavailable")
808 .padding(.top, 4)
809 }
810 if showsHistoryPlaceholder {
811 MessageRowView(text: "Ownership history (coming soon)", isError: false)
812 .padding(.top, 4)
813 }
814 }
815 }
816 }
817 }
818}
819
820struct SubdomainsSectionView: View {
821 let rows: [SubdomainRowViewData]
822 let loading: Bool
823 let error: String?
824 let showsExtendedPlaceholder: Bool
825
826 var body: some View {
827 VStack(alignment: .leading, spacing: 12) {
828 HStack {
829 SectionTitleView(title: "Subdomains")
830 Spacer()
831 Text("\(rows.count)")
832 .font(.system(.caption2, design: .monospaced))
833 .foregroundStyle(.secondary)
834 }
835
836 CardView(allowsHorizontalScroll: false) {
837 if loading {
838 ProgressView("Checking certificate transparency…")
839 .appLoadingStyle()
840 } else if rows.isEmpty {
841 MessageRowView(text: error ?? "No passive subdomains found", isError: false)
842 if showsExtendedPlaceholder {
843 MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
844 .padding(.top, 4)
845 }
846 } else {
847 ForEach(rows) { row in
848 HStack(spacing: 8) {
849 Text(row.hostname)
850 .font(.system(.caption, design: .monospaced))
851 .foregroundStyle(.primary)
852 .textSelection(.enabled)
853 Spacer()
854 if row.isInteresting {
855 Text("Interesting")
856 .font(.system(.caption2, design: .monospaced))
857 .foregroundStyle(.yellow)
858 .padding(.horizontal, 8)
859 .padding(.vertical, 4)
860 .background(Color.yellow.opacity(0.14))
861 .clipShape(Capsule())
862 }
863 }
864 }
865 if showsExtendedPlaceholder {
866 MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
867 .padding(.top, 4)
868 }
869 }
870 }
871 }
872 }
873}
874
775875struct DNSSectionView: View {
776876 let dnssecLabel: String?
777877 let sections: [DNSRecordSectionViewData]
DomainDig/DataAccessService.swift added +7
@@ -0,0 +1,7 @@
1import Foundation
2
3enum DataAccessService {
4 static func hasAccess(to capability: DataCapability) -> Bool {
5 false
6 }
7}
DomainDig/DomainAvailabilityService.swift +4 −47
@@ -35,32 +35,11 @@ struct DomainAvailabilityService {
3535 }
3636
3737 private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
38 guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
39 return nil
40 }
41
42 do {
43 var request = URLRequest(url: url, timeoutInterval: 8)
44 request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
45
46 let (data, response) = try await URLSession.shared.data(for: request)
47 guard let httpResponse = response as? HTTPURLResponse else {
48 return nil
49 }
50
51 switch httpResponse.statusCode {
52 case 200:
53 return isValidRDAPDomainResponse(data) ? .registered : nil
54 case 404:
55 debugLog("rdap-not-found", domain: domain, details: "Ignoring not-found response from rdap.org")
56 return nil
57 default:
58 return nil
59 }
60 } catch {
61 debugLog("rdap-error", domain: domain, details: error.localizedDescription)
62 return nil
38 let status = await RDAPService.registrationStatus(for: domain)
39 if status == nil {
40 debugLog("rdap-not-found", domain: domain, details: "Ignoring unavailable response from rdap.org")
6341 }
42 return status
6443 }
6544
6645 private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus {
@@ -85,28 +64,6 @@ struct DomainAvailabilityService {
8564 }
8665 }
8766
88 private static func isValidRDAPDomainResponse(_ data: Data) -> Bool {
89 guard
90 let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
91 else {
92 return false
93 }
94
95 if object["ldhName"] as? String != nil {
96 return true
97 }
98
99 if object["objectClassName"] as? String == "domain" {
100 return true
101 }
102
103 if object["handle"] as? String != nil, object["unicodeName"] as? String != nil {
104 return true
105 }
106
107 return false
108 }
109
11067 private static func suggestionCandidates(for domain: String, limit: Int) -> [String] {
11168 let parts = domain.split(separator: ".")
11269 guard parts.count >= 2 else { return [] }
DomainDig/DomainDiffService.swift +96 −8
@@ -43,11 +43,13 @@ enum DomainDiffService {
4343 [
4444 availabilitySection(from: oldSnapshot, to: newSnapshot),
4545 primaryIPSection(from: oldSnapshot, to: newSnapshot),
46 ownershipSection(from: oldSnapshot, to: newSnapshot),
4647 dnsSection(from: oldSnapshot, to: newSnapshot),
4748 redirectSection(from: oldSnapshot, to: newSnapshot),
4849 tlsSection(from: oldSnapshot, to: newSnapshot),
4950 httpSection(from: oldSnapshot, to: newSnapshot),
50 emailSection(from: oldSnapshot, to: newSnapshot)
51 emailSection(from: oldSnapshot, to: newSnapshot),
52 subdomainSection(from: oldSnapshot, to: newSnapshot)
5153 ]
5254 .filter { !$0.items.isEmpty }
5355 }
@@ -58,19 +60,16 @@ enum DomainDiffService {
5860 generatedAt: Date = Date()
5961 ) -> DomainChangeSummary {
6062 let sections = diff(from: oldSnapshot, to: newSnapshot)
61 let meaningfulItems = sections
62 .flatMap(\.items)
63 .filter(\.isMeaningful)
6463 let allChangedItems = sections
6564 .flatMap(\.items)
6665 .filter(\.hasChanges)
6766
68 let highlights = summaryHighlights(from: meaningfulItems)
69 let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low)
70 let message = summaryMessage(from: meaningfulItems, highlights: highlights)
67 let highlights = summaryHighlights(from: allChangedItems)
68 let severity = allChangedItems.map(\.severity).max() ?? .low
69 let message = summaryMessage(from: allChangedItems, highlights: highlights)
7170
7271 return DomainChangeSummary(
73 hasChanges: !meaningfulItems.isEmpty,
72 hasChanges: !allChangedItems.isEmpty,
7473 changedSections: highlights,
7574 message: message,
7675 severity: severity,
@@ -152,6 +151,50 @@ enum DomainDiffService {
152151 return DomainDiffSection(title: "DNS", items: items)
153152 }
154153
154 private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
155 DomainDiffSection(
156 title: "Ownership",
157 items: [
158 compare(
159 label: "Registrar",
160 oldValue: normalized(oldSnapshot.ownership?.registrar),
161 newValue: normalized(newSnapshot.ownership?.registrar),
162 severity: .high
163 ),
164 compare(
165 label: "Registration Date",
166 oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate),
167 newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate),
168 severity: .low
169 ),
170 compare(
171 label: "Expiration Date",
172 oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate),
173 newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate),
174 severity: .low
175 ),
176 compare(
177 label: "Ownership Status",
178 oldValue: ownershipList(oldSnapshot.ownership?.status),
179 newValue: ownershipList(newSnapshot.ownership?.status),
180 severity: .low
181 ),
182 compare(
183 label: "Nameservers",
184 oldValue: ownershipList(oldSnapshot.ownership?.nameservers),
185 newValue: ownershipList(newSnapshot.ownership?.nameservers),
186 severity: .medium
187 ),
188 compare(
189 label: "Abuse Contact",
190 oldValue: normalized(oldSnapshot.ownership?.abuseEmail),
191 newValue: normalized(newSnapshot.ownership?.abuseEmail),
192 severity: .low
193 )
194 ].compactMap { $0 }
195 )
196 }
197
155198 private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
156199 DomainDiffSection(
157200 title: "Redirect",
@@ -252,6 +295,20 @@ enum DomainDiffService {
252295 )
253296 }
254297
298 private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
299 DomainDiffSection(
300 title: "Subdomains",
301 items: [
302 compare(
303 label: "Passive Subdomains",
304 oldValue: subdomainList(from: oldSnapshot),
305 newValue: subdomainList(from: newSnapshot),
306 severity: .low
307 )
308 ].compactMap { $0 }
309 )
310 }
311
255312 private static func compare(
256313 label: String,
257314 oldValue: String?,
@@ -306,6 +363,13 @@ enum DomainDiffService {
306363 if labels.contains("Redirect Target") {
307364 highlights.append("Redirect target changed")
308365 }
366 if labels.contains("Registrar") {
367 highlights.append("Registrar changed")
368 } else if labels.contains("Nameservers") {
369 highlights.append("Nameservers changed")
370 } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") {
371 highlights.append("Ownership metadata changed")
372 }
309373 if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
310374 let message = certificateItem.newValue {
311375 highlights.append(message)
@@ -323,6 +387,9 @@ enum DomainDiffService {
323387 if labels.contains("Email Security") {
324388 highlights.append("Email security changed")
325389 }
390 if labels.contains("Passive Subdomains") {
391 highlights.append("Subdomains changed")
392 }
326393
327394 var deduplicated: [String] = []
328395 for highlight in highlights where !deduplicated.contains(highlight) {
@@ -380,6 +447,10 @@ enum DomainDiffService {
380447 return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
381448 }
382449
450 private static func ownershipDateLabel(_ date: Date?) -> String? {
451 date?.formatted(date: .abbreviated, time: .omitted)
452 }
453
383454 private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
384455 if let httpStatusCode = snapshot.httpStatusCode {
385456 return "\(httpStatusCode)"
@@ -423,4 +494,21 @@ enum DomainDiffService {
423494 .sorted()
424495 return headers.isEmpty ? nil : headers.joined(separator: "|")
425496 }
497
498 private static func ownershipList(_ values: [String]?) -> String? {
499 guard let values else { return nil }
500 let normalizedValues = values
501 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
502 .filter { !$0.isEmpty }
503 .sorted()
504 return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",")
505 }
506
507 private static func subdomainList(from snapshot: LookupSnapshot) -> String? {
508 let values = snapshot.subdomains
509 .map(\.hostname)
510 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
511 .sorted()
512 return values.isEmpty ? nil : values.joined(separator: ",")
513 }
426514}
DomainDig/DomainOwnershipService.swift added +7
@@ -0,0 +1,7 @@
1import Foundation
2
3enum DomainOwnershipService {
4 static func lookup(domain: String) async -> ServiceResult<DomainOwnership> {
5 await RDAPService.ownership(for: domain)
6 }
7}
DomainDig/DomainViewModel.swift +212 −2
@@ -72,6 +72,18 @@ struct PortScanRowViewData: Identifiable {
7272 let durationLabel: String?
7373}
7474
75struct SubdomainRowViewData: Identifiable {
76 let id: String
77 let hostname: String
78 let isInteresting: Bool
79
80 init(hostname: String, isInteresting: Bool) {
81 self.id = hostname
82 self.hostname = hostname
83 self.isInteresting = isInteresting
84 }
85}
86
7587struct DomainSuggestionViewData: Identifiable {
7688 let id: UUID
7789 let domain: String
@@ -107,10 +119,14 @@ struct LookupSnapshot {
107119 let ipGeolocationError: String?
108120 let emailSecurity: EmailSecurityResult?
109121 let emailSecurityError: String?
122 let ownership: DomainOwnership?
123 let ownershipError: String?
110124 let ptrRecord: String?
111125 let ptrError: String?
112126 let redirectChain: [RedirectHop]
113127 let redirectChainError: String?
128 let subdomains: [DiscoveredSubdomain]
129 let subdomainsError: String?
114130 let portScanResults: [PortScanResult]
115131 let portScanError: String?
116132 let changeSummary: DomainChangeSummary?
@@ -147,10 +163,14 @@ extension HistoryEntry {
147163 ipGeolocationError: ipGeolocationError,
148164 emailSecurity: emailSecurity,
149165 emailSecurityError: emailSecurityError,
166 ownership: ownership,
167 ownershipError: ownershipError,
150168 ptrRecord: ptrRecord,
151169 ptrError: ptrError,
152170 redirectChain: redirectChain,
153171 redirectChainError: redirectChainError,
172 subdomains: subdomains,
173 subdomainsError: subdomainsError,
154174 portScanResults: portScanResults,
155175 portScanError: portScanError,
156176 changeSummary: changeSummary,
@@ -204,6 +224,10 @@ final class DomainViewModel {
204224 var emailSecurityLoading = false
205225 var emailSecurityError: String?
206226
227 var ownershipResult: DomainOwnership?
228 var ownershipLoading = false
229 var ownershipError: String?
230
207231 var ptrRecord: String?
208232 var ptrLoading = false
209233 var ptrError: String?
@@ -212,6 +236,10 @@ final class DomainViewModel {
212236 var redirectChainLoading = false
213237 var redirectChainError: String?
214238
239 var subdomains: [DiscoveredSubdomain] = []
240 var subdomainsLoading = false
241 var subdomainsError: String?
242
215243 var portScanResults: [PortScanResult] = []
216244 var portScanLoading = false
217245 var portScanError: String?
@@ -224,6 +252,7 @@ final class DomainViewModel {
224252 private(set) var lastLookupDurationMs: Int?
225253 private(set) var currentDiffSections: [DomainDiffSection] = []
226254 private(set) var currentChangeSummary: DomainChangeSummary?
255 private(set) var ownershipDiff: [DomainDiffItem] = []
227256 private(set) var refreshingTrackedDomainID: UUID?
228257 private(set) var rerunNavigationToken = UUID()
229258 private(set) var batchResults: [BatchLookupResult] = []
@@ -290,8 +319,10 @@ final class DomainViewModel {
290319 !reachabilityLoading &&
291320 !ipGeolocationLoading &&
292321 !emailSecurityLoading &&
322 !ownershipLoading &&
293323 !ptrLoading &&
294324 !redirectChainLoading &&
325 !subdomainsLoading &&
295326 !portScanLoading &&
296327 !customPortScanLoading
297328 }
@@ -438,10 +469,14 @@ final class DomainViewModel {
438469 ipGeolocationError: ipGeolocationError,
439470 emailSecurity: emailSecurity,
440471 emailSecurityError: emailSecurityError,
472 ownership: ownershipResult,
473 ownershipError: ownershipError,
441474 ptrRecord: ptrRecord,
442475 ptrError: ptrError,
443476 redirectChain: redirectChain,
444477 redirectChainError: redirectChainError,
478 subdomains: subdomains,
479 subdomainsError: subdomainsError,
445480 portScanResults: allPortScanResults,
446481 portScanError: combinedPortScanError,
447482 changeSummary: currentChangeSummary,
@@ -489,6 +524,14 @@ final class DomainViewModel {
489524 Self.emailRows(from: currentSnapshot)
490525 }
491526
527 var ownershipRows: [InfoRowViewData] {
528 Self.ownershipRows(from: currentSnapshot)
529 }
530
531 var subdomainRows: [SubdomainRowViewData] {
532 Self.subdomainRows(from: currentSnapshot)
533 }
534
492535 var reachabilityRows: [ReachabilityRowViewData] {
493536 Self.reachabilityRows(from: currentSnapshot)
494537 }
@@ -643,6 +686,7 @@ final class DomainViewModel {
643686 lastLookupDurationMs = nil
644687 currentDiffSections = []
645688 currentChangeSummary = nil
689 ownershipDiff = []
646690 refreshingTrackedDomainID = nil
647691 clearBatchState()
648692 clearLookupState()
@@ -791,6 +835,8 @@ final class DomainViewModel {
791835 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
792836 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
793837 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
838 group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) }
839 group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) }
794840 }
795841
796842 await withTaskGroup(of: Void.self) { group in
@@ -946,6 +992,23 @@ final class DomainViewModel {
946992 emailSecurityLoading = false
947993 }
948994
995 private func runOwnership(domain: String, lookupID: UUID) async {
996 let result = await DomainOwnershipService.lookup(domain: domain)
997 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
998 switch result {
999 case let .success(ownership):
1000 ownershipResult = ownership
1001 ownershipError = nil
1002 case let .empty(message):
1003 ownershipResult = nil
1004 ownershipError = message
1005 case let .error(message):
1006 ownershipResult = nil
1007 ownershipError = message
1008 }
1009 ownershipLoading = false
1010 }
1011
9491012 private func runReverseDNS(ip: String, lookupID: UUID) async {
9501013 let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
9511014 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
@@ -980,6 +1043,23 @@ final class DomainViewModel {
9801043 redirectChainLoading = false
9811044 }
9821045
1046 private func runSubdomains(domain: String, lookupID: UUID) async {
1047 let result = await SubdomainDiscoveryService.discover(for: domain)
1048 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
1049 switch result {
1050 case let .success(results):
1051 subdomains = results
1052 subdomainsError = nil
1053 case let .empty(message):
1054 subdomains = []
1055 subdomainsError = message
1056 case let .error(message):
1057 subdomains = []
1058 subdomainsError = message
1059 }
1060 subdomainsLoading = false
1061 }
1062
9831063 private func runPortScan(domain: String, lookupID: UUID) async {
9841064 let result = await PortScanService.scanAll(domain: domain)
9851065 switch result {
@@ -1061,7 +1141,9 @@ final class DomainViewModel {
10611141 async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
10621142 async let httpResult = HTTPHeadersService.fetch(domain: domain)
10631143 async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
1144 async let ownershipResult = DomainOwnershipService.lookup(domain: domain)
10641145 async let redirectResult = RedirectChainService.trace(domain: domain)
1146 async let subdomainResult = SubdomainDiscoveryService.discover(for: domain)
10651147 async let portScanResult = PortScanService.scanAll(domain: domain)
10661148
10671149 let resolvedDNS = await dnsResult
@@ -1070,7 +1152,9 @@ final class DomainViewModel {
10701152 let hsts = await hstsResult
10711153 let http = await httpResult
10721154 let reachability = await reachabilityResult
1155 let resolvedOwnership = await ownershipResult
10731156 let redirects = await redirectResult
1157 let resolvedSubdomains = await subdomainResult
10741158 let ports = await portScanResult
10751159
10761160 guard !Task.isCancelled else { return nil }
@@ -1187,6 +1271,17 @@ final class DomainViewModel {
11871271 emailSecurityError = message
11881272 }
11891273
1274 let ownership: DomainOwnership?
1275 let ownershipError: String?
1276 switch resolvedOwnership {
1277 case let .success(result):
1278 ownership = result
1279 ownershipError = nil
1280 case let .empty(message), let .error(message):
1281 ownership = nil
1282 ownershipError = message
1283 }
1284
11901285 let ptrRecord: String?
11911286 let ptrError: String?
11921287 switch resolvedPTR {
@@ -1215,6 +1310,17 @@ final class DomainViewModel {
12151310 ipGeolocationError = "No A record available"
12161311 }
12171312
1313 let subdomains: [DiscoveredSubdomain]
1314 let subdomainsError: String?
1315 switch resolvedSubdomains {
1316 case let .success(result):
1317 subdomains = result
1318 subdomainsError = nil
1319 case let .empty(message), let .error(message):
1320 subdomains = []
1321 subdomainsError = message
1322 }
1323
12181324 let snapshot = LookupSnapshot(
12191325 historyEntryID: nil,
12201326 domain: domain,
@@ -1243,10 +1349,14 @@ final class DomainViewModel {
12431349 ipGeolocationError: ipGeolocationError,
12441350 emailSecurity: emailSecurity,
12451351 emailSecurityError: emailSecurityError,
1352 ownership: ownership,
1353 ownershipError: ownershipError,
12461354 ptrRecord: ptrRecord,
12471355 ptrError: ptrError,
12481356 redirectChain: redirectChain,
12491357 redirectChainError: redirectChainError,
1358 subdomains: subdomains,
1359 subdomainsError: subdomainsError,
12501360 portScanResults: portScanResults,
12511361 portScanError: portScanError,
12521362 changeSummary: nil,
@@ -1303,6 +1413,7 @@ final class DomainViewModel {
13031413 if updateCurrentState {
13041414 currentChangeSummary = changeSummary
13051415 currentDiffSections = diffSections
1416 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
13061417 }
13071418
13081419 let entry = HistoryEntry(
@@ -1316,8 +1427,10 @@ final class DomainViewModel {
13161427 ipGeolocation: snapshot.ipGeolocation,
13171428 emailSecurity: snapshot.emailSecurity,
13181429 mtaSts: snapshot.emailSecurity?.mtaSts,
1430 ownership: snapshot.ownership,
13191431 ptrRecord: snapshot.ptrRecord,
13201432 redirectChain: snapshot.redirectChain,
1433 subdomains: snapshot.subdomains,
13211434 portScanResults: snapshot.portScanResults,
13221435 hstsPreloaded: snapshot.hstsPreloaded,
13231436 availabilityResult: snapshot.availabilityResult,
@@ -1336,8 +1449,10 @@ final class DomainViewModel {
13361449 reachabilityError: snapshot.reachabilityError,
13371450 ipGeolocationError: snapshot.ipGeolocationError,
13381451 emailSecurityError: snapshot.emailSecurityError,
1452 ownershipError: snapshot.ownershipError,
13391453 ptrError: snapshot.ptrError,
13401454 redirectChainError: snapshot.redirectChainError,
1455 subdomainsError: snapshot.subdomainsError,
13411456 portScanError: snapshot.portScanError
13421457 )
13431458
@@ -1520,6 +1635,7 @@ final class DomainViewModel {
15201635 hasRun = true
15211636 currentDiffSections = []
15221637 currentChangeSummary = nil
1638 ownershipDiff = []
15231639 clearLookupState()
15241640 setAllLoadingStates(true)
15251641 customPortScanLoading = false
@@ -1677,8 +1793,8 @@ final class DomainViewModel {
16771793 let summary = BatchSweepSummary(
16781794 source: source,
16791795 totalDomains: batchResults.count,
1680 changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count,
1681 unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count,
1796 changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count,
1797 unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count,
16821798 warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
16831799 results: batchResults.sorted { lhs, rhs in
16841800 if lhs.status != rhs.status {
@@ -1754,12 +1870,18 @@ final class DomainViewModel {
17541870 emailSecurity = nil
17551871 emailSecurityError = nil
17561872 emailSecurityLoading = false
1873 ownershipResult = nil
1874 ownershipError = nil
1875 ownershipLoading = false
17571876 ptrRecord = nil
17581877 ptrError = nil
17591878 ptrLoading = false
17601879 redirectChain = []
17611880 redirectChainError = nil
17621881 redirectChainLoading = false
1882 subdomains = []
1883 subdomainsError = nil
1884 subdomainsLoading = false
17631885 portScanResults = []
17641886 portScanError = nil
17651887 portScanLoading = false
@@ -1778,8 +1900,10 @@ final class DomainViewModel {
17781900 reachabilityLoading = loading
17791901 ipGeolocationLoading = loading
17801902 emailSecurityLoading = loading
1903 ownershipLoading = loading
17811904 ptrLoading = loading
17821905 redirectChainLoading = loading
1906 subdomainsLoading = loading
17831907 portScanLoading = loading
17841908 }
17851909
@@ -1872,10 +1996,14 @@ final class DomainViewModel {
18721996 ipGeolocationError: nil,
18731997 emailSecurity: nil,
18741998 emailSecurityError: nil,
1999 ownership: nil,
2000 ownershipError: nil,
18752001 ptrRecord: nil,
18762002 ptrError: nil,
18772003 redirectChain: [],
18782004 redirectChainError: nil,
2005 subdomains: [],
2006 subdomainsError: nil,
18792007 portScanResults: [],
18802008 portScanError: nil,
18812009 changeSummary: trackedDomain.lastChangeSummary,
@@ -2100,6 +2228,28 @@ final class DomainViewModel {
21002228 ]
21012229 }
21022230
2231 static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
2232 let ownership = snapshot.ownership
2233
2234 return [
2235 InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary),
2236 InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary),
2237 InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary),
2238 InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary),
2239 InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary),
2240 InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary)
2241 ]
2242 }
2243
2244 static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] {
2245 snapshot.subdomains.map { subdomain in
2246 SubdomainRowViewData(
2247 hostname: subdomain.hostname,
2248 isInteresting: isInterestingSubdomain(subdomain.hostname)
2249 )
2250 }
2251 }
2252
21032253 static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
21042254 snapshot.reachabilityResults.map {
21052255 ReachabilityRowViewData(
@@ -2179,6 +2329,12 @@ final class DomainViewModel {
21792329 "tls_status",
21802330 "http_status_grade",
21812331 "email_security_summary",
2332 "registrar",
2333 "ownership_expires",
2334 "ownership_status",
2335 "ownership_nameservers",
2336 "subdomain_count",
2337 "subdomains",
21822338 "last_updated"
21832339 ]
21842340
@@ -2191,6 +2347,12 @@ final class DomainViewModel {
21912347 httpsSummary(from: snapshot),
21922348 httpStatusGradeSummary(from: snapshot),
21932349 emailSummary(from: snapshot),
2350 snapshot.ownership?.registrar ?? "",
2351 snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "",
2352 snapshot.ownership?.status.joined(separator: " | ") ?? "",
2353 snapshot.ownership?.nameservers.joined(separator: " | ") ?? "",
2354 "\(snapshot.subdomains.count)",
2355 snapshot.subdomains.map(\.hostname).joined(separator: " | "),
21942356 csvDateFormatter.string(from: snapshot.timestamp)
21952357 ]
21962358 }
@@ -2282,6 +2444,33 @@ final class DomainViewModel {
22822444 }
22832445 }
22842446
2447 appendSection("Ownership") {
2448 for row in ownershipRows(from: snapshot) {
2449 lines.append(" \(row.label): \(row.value)")
2450 }
2451 if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil {
2452 lines.append(" Source: \(ownershipError)")
2453 }
2454 if !DataAccessService.hasAccess(to: .ownershipHistory) {
2455 lines.append(" Ownership history (coming soon)")
2456 }
2457 }
2458
2459 appendSection("Subdomains") {
2460 lines.append(" Count: \(snapshot.subdomains.count)")
2461 if snapshot.subdomains.isEmpty {
2462 lines.append(" \(snapshot.subdomainsError ?? "No passive subdomains found")")
2463 } else {
2464 for subdomain in subdomainRows(from: snapshot) {
2465 let marker = subdomain.isInteresting ? " [interesting]" : ""
2466 lines.append(" \(subdomain.hostname)\(marker)")
2467 }
2468 }
2469 if !DataAccessService.hasAccess(to: .extendedSubdomains) {
2470 lines.append(" Extended subdomain discovery (Data+)")
2471 }
2472 }
2473
22852474 appendSection("DNS") {
22862475 if let dnsError = snapshot.dnsError {
22872476 lines.append(" Error: \(dnsError)")
@@ -2544,6 +2733,13 @@ final class DomainViewModel {
25442733 return formatter
25452734 }()
25462735
2736 private static let ownershipDateFormatter: DateFormatter = {
2737 let formatter = DateFormatter()
2738 formatter.dateStyle = .medium
2739 formatter.timeStyle = .none
2740 return formatter
2741 }()
2742
25472743 private static let csvDateFormatter: ISO8601DateFormatter = {
25482744 let formatter = ISO8601DateFormatter()
25492745 formatter.formatOptions = [.withInternetDateTime]
@@ -2554,6 +2750,14 @@ final class DomainViewModel {
25542750 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
25552751 return "\"\(escaped)\""
25562752 }
2753
2754 private static func isInterestingSubdomain(_ hostname: String) -> Bool {
2755 let keywords = ["admin", "api", "dev", "staging", "test", "internal"]
2756 let labels = hostname.lowercased().split(separator: ".").map(String.init)
2757 return labels.contains { label in
2758 keywords.contains(where: { label.contains($0) })
2759 }
2760 }
25572761}
25582762
25592763private extension String {
@@ -2565,3 +2769,9 @@ private extension String {
25652769 isEmpty ? nil : self
25662770 }
25672771}
2772
2773private extension Array where Element == String {
2774 var nilIfEmpty: [String]? {
2775 isEmpty ? nil : self
2776 }
2777}
DomainDig/HistoryView.swift +14
@@ -147,6 +147,20 @@ struct HistoryDetailView: View {
147147 onEditNote: nil
148148 )
149149 .padding(.top, 16)
150 OwnershipSectionView(
151 rows: DomainViewModel.ownershipRows(from: snapshot),
152 loading: false,
153 error: snapshot.ownershipError,
154 showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
155 )
156 .padding(.top, 16)
157 SubdomainsSectionView(
158 rows: DomainViewModel.subdomainRows(from: snapshot),
159 loading: false,
160 error: snapshot.subdomainsError,
161 showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
162 )
163 .padding(.top, 16)
150164 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
151165 if let changeSummary = entry.changeSummary {
152166 DomainChangeSummaryView(summary: changeSummary)
DomainDig/Models.swift +66 −4
@@ -184,6 +184,13 @@ struct BatchSweepSummary: Identifiable, Equatable {
184184 let generatedAt: Date
185185}
186186
187enum DataCapability: String, Codable {
188 case ownershipHistory
189 case dnsHistory
190 case extendedSubdomains
191 case domainPricing
192}
193
187194enum HistoryDateFilter: String, CaseIterable, Identifiable {
188195 case today
189196 case last7Days
@@ -501,6 +508,48 @@ struct IPGeolocation: Codable {
501508 let longitude: Double?
502509}
503510
511// MARK: - Ownership Models
512
513struct DomainOwnership: Codable, Equatable {
514 let registrar: String?
515 let createdDate: Date?
516 let expirationDate: Date?
517 let status: [String]
518 let nameservers: [String]
519 let abuseEmail: String?
520
521 init(
522 registrar: String? = nil,
523 createdDate: Date? = nil,
524 expirationDate: Date? = nil,
525 status: [String] = [],
526 nameservers: [String] = [],
527 abuseEmail: String? = nil
528 ) {
529 self.registrar = registrar
530 self.createdDate = createdDate
531 self.expirationDate = expirationDate
532 self.status = status
533 self.nameservers = nameservers
534 self.abuseEmail = abuseEmail
535 }
536
537 init(from decoder: Decoder) throws {
538 let container = try decoder.container(keyedBy: CodingKeys.self)
539 registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
540 createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
541 expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
542 status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
543 nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
544 abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail)
545 }
546}
547
548struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable {
549 var id: String { hostname }
550 let hostname: String
551}
552
504553// MARK: - Email Security Models
505554
506555struct EmailSecurityResult: Codable {
@@ -627,8 +676,10 @@ struct HistoryEntry: Identifiable, Codable {
627676 let ipGeolocation: IPGeolocation?
628677 var emailSecurity: EmailSecurityResult?
629678 var mtaSts: MTASTSResult?
679 var ownership: DomainOwnership?
630680 var ptrRecord: String?
631681 var redirectChain: [RedirectHop]
682 var subdomains: [DiscoveredSubdomain]
632683 var portScanResults: [PortScanResult]
633684 var hstsPreloaded: Bool?
634685 var availabilityResult: DomainAvailabilityResult?
@@ -647,23 +698,26 @@ struct HistoryEntry: Identifiable, Codable {
647698 var reachabilityError: String?
648699 var ipGeolocationError: String?
649700 var emailSecurityError: String?
701 var ownershipError: String?
650702 var ptrError: String?
651703 var redirectChainError: String?
704 var subdomainsError: String?
652705 var portScanError: String?
653706
654707 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection],
655708 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
656709 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
657 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
658 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
710 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
711 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
712 portScanResults: [PortScanResult] = [],
659713 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
660714 suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String,
661715 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
662716 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
663717 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
664718 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
665 emailSecurityError: String? = nil, ptrError: String? = nil,
666 redirectChainError: String? = nil, portScanError: String? = nil) {
719 emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil,
720 redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) {
667721 self.domain = domain
668722 self.timestamp = timestamp
669723 self.trackedDomainID = trackedDomainID
@@ -674,8 +728,10 @@ struct HistoryEntry: Identifiable, Codable {
674728 self.ipGeolocation = ipGeolocation
675729 self.emailSecurity = emailSecurity
676730 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
731 self.ownership = ownership
677732 self.ptrRecord = ptrRecord
678733 self.redirectChain = redirectChain
734 self.subdomains = subdomains
679735 self.portScanResults = portScanResults
680736 self.hstsPreloaded = hstsPreloaded
681737 self.availabilityResult = availabilityResult
@@ -694,8 +750,10 @@ struct HistoryEntry: Identifiable, Codable {
694750 self.reachabilityError = reachabilityError
695751 self.ipGeolocationError = ipGeolocationError
696752 self.emailSecurityError = emailSecurityError
753 self.ownershipError = ownershipError
697754 self.ptrError = ptrError
698755 self.redirectChainError = redirectChainError
756 self.subdomainsError = subdomainsError
699757 self.portScanError = portScanError
700758 }
701759
@@ -712,8 +770,10 @@ struct HistoryEntry: Identifiable, Codable {
712770 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
713771 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
714772 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
773 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
715774 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
716775 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
776 subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
717777 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
718778 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
719779 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -732,8 +792,10 @@ struct HistoryEntry: Identifiable, Codable {
732792 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
733793 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
734794 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
795 ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
735796 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
736797 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
798 subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
737799 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
738800 }
739801}
DomainDig/RDAPService.swift added +288
@@ -0,0 +1,288 @@
1import Foundation
2
3enum RDAPService {
4 static func registrationStatus(for domain: String) async -> DomainAvailabilityStatus? {
5 let normalizedDomain = normalize(domain)
6 guard !normalizedDomain.isEmpty else { return nil }
7
8 switch await cache.response(for: normalizedDomain) {
9 case let .success(response):
10 return response.isDomainRecord ? .registered : nil
11 case .empty:
12 return nil
13 case .error:
14 return nil
15 }
16 }
17
18 static func ownership(for domain: String) async -> ServiceResult<DomainOwnership> {
19 let normalizedDomain = normalize(domain)
20 guard !normalizedDomain.isEmpty else {
21 return .empty("Unavailable")
22 }
23
24 switch await cache.response(for: normalizedDomain) {
25 case let .success(response):
26 let ownership = DomainOwnership(
27 registrar: response.registrarName,
28 createdDate: response.createdDate,
29 expirationDate: response.expirationDate,
30 status: response.status,
31 nameservers: response.nameservers,
32 abuseEmail: response.abuseEmail
33 )
34 return .success(ownership)
35 case let .empty(message):
36 return .empty(message)
37 case let .error(message):
38 return .error(message)
39 }
40 }
41
42 private static let cache = RDAPCache()
43
44 private static func normalize(_ domain: String) -> String {
45 domain
46 .trimmingCharacters(in: .whitespacesAndNewlines)
47 .lowercased()
48 }
49}
50
51private actor RDAPCache {
52 private var cachedResponses: [String: ServiceResult<RDAPDomainResponse>] = [:]
53 private var inFlightTasks: [String: Task<ServiceResult<RDAPDomainResponse>, Never>] = [:]
54
55 func response(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
56 if let cachedResponse = cachedResponses[domain] {
57 return cachedResponse
58 }
59
60 if let inFlightTask = inFlightTasks[domain] {
61 return await inFlightTask.value
62 }
63
64 let task = Task<ServiceResult<RDAPDomainResponse>, Never> {
65 await fetchRDAPResponse(for: domain)
66 }
67 inFlightTasks[domain] = task
68
69 let result = await task.value
70 cachedResponses[domain] = result
71 inFlightTasks[domain] = nil
72 return result
73 }
74}
75
76private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
77 guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
78 return .error("Unavailable")
79 }
80
81 do {
82 var request = URLRequest(url: url, timeoutInterval: 8)
83 request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
84
85 let (data, response) = try await URLSession.shared.data(for: request)
86 guard let httpResponse = response as? HTTPURLResponse else {
87 return .error(URLError(.badServerResponse).localizedDescription)
88 }
89
90 switch httpResponse.statusCode {
91 case 200:
92 let decoder = JSONDecoder()
93 let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data)
94 guard rdapResponse.isDomainRecord else {
95 return .empty("Unavailable")
96 }
97 return .success(rdapResponse)
98 case 404:
99 return .empty("Unavailable")
100 default:
101 return .error("Unavailable")
102 }
103 } catch {
104 return .error(error.localizedDescription)
105 }
106}
107
108private struct RDAPDomainResponse: Decodable, Sendable {
109 let ldhName: String?
110 let objectClassName: String?
111 let unicodeName: String?
112 let handle: String?
113 let rawStatus: [String]?
114 let rawNameservers: [RDAPNameserver]?
115 let events: [RDAPEvent]?
116 let entities: [RDAPEntity]?
117
118 enum CodingKeys: String, CodingKey {
119 case ldhName
120 case objectClassName
121 case unicodeName
122 case handle
123 case rawStatus = "status"
124 case rawNameservers = "nameservers"
125 case events
126 case entities
127 }
128
129 var isDomainRecord: Bool {
130 if ldhName?.isEmpty == false {
131 return true
132 }
133 if objectClassName == "domain" {
134 return true
135 }
136 return handle != nil && unicodeName != nil
137 }
138
139 var registrarName: String? {
140 entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName
141 }
142
143 var createdDate: Date? {
144 eventDate(for: ["registration", "registered"])
145 }
146
147 var expirationDate: Date? {
148 eventDate(for: ["expiration", "expiry", "expired"])
149 }
150
151 var abuseEmail: String? {
152 entities?.first(where: { $0.roles.contains("abuse") })?.email
153 ?? entities?.first(where: { $0.roles.contains("registrar") })?.abuseEntity?.email
154 }
155
156 var nameservers: [String] {
157 let rawValues = rawNameservers?.compactMap { $0.ldhName ?? $0.unicodeName } ?? []
158 return deduplicated(rawValues)
159 }
160
161 var status: [String] {
162 deduplicated(rawStatus ?? [])
163 }
164
165 private func eventDate(for actions: [String]) -> Date? {
166 let normalizedActions = Set(actions)
167 return events?
168 .first(where: { normalizedActions.contains($0.eventAction.lowercased()) })?
169 .parsedDate
170 }
171
172 private func deduplicated(_ values: [String]) -> [String] {
173 var seen = Set<String>()
174 return values
175 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
176 .filter { !$0.isEmpty }
177 .filter { seen.insert($0.lowercased()).inserted }
178 }
179}
180
181private struct RDAPNameserver: Decodable, Sendable {
182 let ldhName: String?
183 let unicodeName: String?
184}
185
186private struct RDAPEvent: Decodable, Sendable {
187 let eventAction: String
188 let eventDate: String
189
190 var parsedDate: Date? {
191 RDAPDateParser.parse(eventDate)
192 }
193}
194
195private struct RDAPEntity: Decodable, Sendable {
196 let roles: [String]
197 let vcardArray: RDAPVCardArray?
198 let entities: [RDAPEntity]?
199
200 var bestDisplayName: String? {
201 vcardArray?.fullName ?? vcardArray?.organization ?? vcardArray?.email
202 }
203
204 var email: String? {
205 vcardArray?.email
206 }
207
208 var abuseEntity: RDAPEntity? {
209 entities?.first(where: { $0.roles.contains("abuse") })
210 }
211}
212
213private struct RDAPVCardArray: Decodable, Sendable {
214 let values: [[RDAPJSONValue]]
215
216 init(from decoder: Decoder) throws {
217 var container = try decoder.unkeyedContainer()
218 _ = try container.decode(String.self)
219 values = try container.decode([[RDAPJSONValue]].self)
220 }
221
222 var fullName: String? {
223 value(for: "fn")
224 }
225
226 var organization: String? {
227 value(for: "org")
228 }
229
230 var email: String? {
231 value(for: "email")
232 }
233
234 private func value(for key: String) -> String? {
235 values.first(where: { $0.first?.stringValue?.lowercased() == key })?.last?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
236 }
237}
238
239private enum RDAPJSONValue: Decodable, Sendable {
240 case string(String)
241 case bool(Bool)
242 case number(Double)
243 case null
244
245 init(from decoder: Decoder) throws {
246 let container = try decoder.singleValueContainer()
247 if let value = try? container.decode(String.self) {
248 self = .string(value)
249 } else if let value = try? container.decode(Bool.self) {
250 self = .bool(value)
251 } else if let value = try? container.decode(Double.self) {
252 self = .number(value)
253 } else {
254 self = .null
255 }
256 }
257
258 var stringValue: String? {
259 switch self {
260 case let .string(value):
261 return value
262 case let .bool(value):
263 return value ? "true" : "false"
264 case let .number(value):
265 return String(value)
266 case .null:
267 return nil
268 }
269 }
270}
271
272private enum RDAPDateParser {
273 private static let iso8601WithFractional: ISO8601DateFormatter = {
274 let formatter = ISO8601DateFormatter()
275 formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
276 return formatter
277 }()
278
279 private static let iso8601: ISO8601DateFormatter = {
280 let formatter = ISO8601DateFormatter()
281 formatter.formatOptions = [.withInternetDateTime]
282 return formatter
283 }()
284
285 static func parse(_ value: String) -> Date? {
286 iso8601WithFractional.date(from: value) ?? iso8601.date(from: value)
287 }
288}
DomainDig/SubdomainDiscoveryService.swift added +118
@@ -0,0 +1,118 @@
1import Foundation
2
3enum SubdomainDiscoveryService {
4 static func discover(for domain: String, limit: Int = 25) async -> ServiceResult<[DiscoveredSubdomain]> {
5 let normalizedDomain = normalize(domain)
6 guard !normalizedDomain.isEmpty else {
7 return .empty("No passive subdomains found")
8 }
9
10 return await cache.subdomains(for: normalizedDomain, limit: limit)
11 }
12
13 private static let cache = SubdomainDiscoveryCache()
14
15 private static func normalize(_ domain: String) -> String {
16 domain
17 .trimmingCharacters(in: .whitespacesAndNewlines)
18 .lowercased()
19 }
20}
21
22private actor SubdomainDiscoveryCache {
23 private var cachedResults: [String: ServiceResult<[DiscoveredSubdomain]>] = [:]
24 private var inFlightTasks: [String: Task<ServiceResult<[DiscoveredSubdomain]>, Never>] = [:]
25 private var lastRequestAt: Date?
26
27 func subdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
28 if let cachedResult = cachedResults[domain] {
29 return cachedResult
30 }
31
32 if let inFlightTask = inFlightTasks[domain] {
33 return await inFlightTask.value
34 }
35
36 let task = Task<ServiceResult<[DiscoveredSubdomain]>, Never> {
37 await enforceRateLimit()
38 return await fetchSubdomains(for: domain, limit: limit)
39 }
40 inFlightTasks[domain] = task
41
42 let result = await task.value
43 cachedResults[domain] = result
44 inFlightTasks[domain] = nil
45 return result
46 }
47
48 private func enforceRateLimit() async {
49 if let lastRequestAt {
50 let delay = max(0, 0.75 - Date().timeIntervalSince(lastRequestAt))
51 if delay > 0 {
52 try? await Task.sleep(for: .seconds(delay))
53 }
54 }
55 lastRequestAt = Date()
56 }
57
58 private func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
59 var components = URLComponents(string: "https://crt.sh/")!
60 components.queryItems = [
61 URLQueryItem(name: "q", value: "%.\(domain)"),
62 URLQueryItem(name: "output", value: "json")
63 ]
64
65 guard let url = components.url else {
66 return .error("Subdomain discovery unavailable")
67 }
68
69 do {
70 let request = URLRequest(url: url, timeoutInterval: 10)
71 let (data, response) = try await URLSession.shared.data(for: request)
72 guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
73 return .error("Subdomain discovery unavailable")
74 }
75
76 let entries = try JSONDecoder().decode([CRTShEntry].self, from: data)
77 let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit)
78 return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains)
79 } catch {
80 return .error(error.localizedDescription)
81 }
82 }
83
84 private func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] {
85 var seen = Set<String>()
86 var results: [DiscoveredSubdomain] = []
87
88 for entry in entries {
89 let names = entry.nameValue
90 .split(separator: "\n")
91 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
92
93 for name in names {
94 let sanitized = name.hasPrefix("*.") ? String(name.dropFirst(2)) : name
95 guard sanitized != domain, sanitized.hasSuffix(".\(domain)") else {
96 continue
97 }
98 guard seen.insert(sanitized).inserted else {
99 continue
100 }
101 results.append(DiscoveredSubdomain(hostname: sanitized))
102 if results.count == limit {
103 return results
104 }
105 }
106 }
107
108 return results
109 }
110}
111
112private struct CRTShEntry: Decodable {
113 let nameValue: String
114
115 enum CodingKeys: String, CodingKey {
116 case nameValue = "name_value"
117 }
118}