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 @@
267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
269 CODE_SIGN_STYLE = Automatic; 269 CODE_SIGN_STYLE = Automatic;
270 CURRENT_PROJECT_VERSION = 15; 270 CURRENT_PROJECT_VERSION = 16;
271 DEVELOPMENT_TEAM = ZCNAX3VL9D; 271 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272 ENABLE_PREVIEWS = YES; 272 ENABLE_PREVIEWS = YES;
273 GENERATE_INFOPLIST_FILE = YES; 273 GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
284 "$(inherited)", 284 "$(inherited)",
285 "@executable_path/Frameworks", 285 "@executable_path/Frameworks",
286 ); 286 );
287 MARKETING_VERSION = 2.2.0; 287 MARKETING_VERSION = 2.3.0;
288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
289 PRODUCT_NAME = "$(TARGET_NAME)"; 289 PRODUCT_NAME = "$(TARGET_NAME)";
290 STRING_CATALOG_GENERATE_SYMBOLS = YES; 290 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
305 CODE_SIGN_STYLE = Automatic; 305 CODE_SIGN_STYLE = Automatic;
306 CURRENT_PROJECT_VERSION = 15; 306 CURRENT_PROJECT_VERSION = 16;
307 DEVELOPMENT_TEAM = ZCNAX3VL9D; 307 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308 ENABLE_PREVIEWS = YES; 308 ENABLE_PREVIEWS = YES;
309 GENERATE_INFOPLIST_FILE = YES; 309 GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
320 "$(inherited)", 320 "$(inherited)",
321 "@executable_path/Frameworks", 321 "@executable_path/Frameworks",
322 ); 322 );
323 MARKETING_VERSION = 2.2.0; 323 MARKETING_VERSION = 2.3.0;
324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325 PRODUCT_NAME = "$(TARGET_NAME)"; 325 PRODUCT_NAME = "$(TARGET_NAME)";
326 STRING_CATALOG_GENERATE_SYMBOLS = YES; 326 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/BatchResultsView.swift +3
@@ -115,6 +115,9 @@ struct BatchResultRowView: View {
115 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning { 115 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
116 return .yellow 116 return .yellow
117 } 117 }
118 if result.quickStatus == "Changed" {
119 return .blue
120 }
118 return .green 121 return .green
119 case .failed: 122 case .failed:
120 return .red 123 return .red
DomainDig/BatchSweepSummaryView.swift +4 −1
@@ -12,7 +12,10 @@ struct BatchSweepSummaryView: View {
12 } 12 }
13 13
14 return summary.results.filter { 14 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
16 } 19 }
17 } 20 }
18 21
DomainDig/ContentView.swift +100
@@ -60,6 +60,20 @@ struct ContentView: View {
60 } 60 }
61 ) 61 )
62 .padding(.top, 16) 62 .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)
63 if !viewModel.currentDiffSections.isEmpty { 77 if !viewModel.currentDiffSections.isEmpty {
64 DomainDiffView( 78 DomainDiffView(
65 title: "Latest Changes", 79 title: "Latest Changes",
@@ -772,6 +786,92 @@ struct DomainSectionView: View {
772 } 786 }
773} 787}
774 788
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
775struct DNSSectionView: View { 875struct DNSSectionView: View {
776 let dnssecLabel: String? 876 let dnssecLabel: String?
777 let sections: [DNSRecordSectionViewData] 877 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 {
35 } 35 }
36 36
37 private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? { 37 private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
38 guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { 38 let status = await RDAPService.registrationStatus(for: domain)
39 return nil 39 if status == nil {
40 } 40 debugLog("rdap-not-found", domain: domain, details: "Ignoring unavailable response from rdap.org")
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
63 } 41 }
42 return status
64 } 43 }
65 44
66 private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus { 45 private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus {
@@ -85,28 +64,6 @@ struct DomainAvailabilityService {
85 } 64 }
86 } 65 }
87 66
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
110 private static func suggestionCandidates(for domain: String, limit: Int) -> [String] { 67 private static func suggestionCandidates(for domain: String, limit: Int) -> [String] {
111 let parts = domain.split(separator: ".") 68 let parts = domain.split(separator: ".")
112 guard parts.count >= 2 else { return [] } 69 guard parts.count >= 2 else { return [] }
DomainDig/DomainDiffService.swift +96 −8
@@ -43,11 +43,13 @@ enum DomainDiffService {
43 [ 43 [
44 availabilitySection(from: oldSnapshot, to: newSnapshot), 44 availabilitySection(from: oldSnapshot, to: newSnapshot),
45 primaryIPSection(from: oldSnapshot, to: newSnapshot), 45 primaryIPSection(from: oldSnapshot, to: newSnapshot),
46 ownershipSection(from: oldSnapshot, to: newSnapshot),
46 dnsSection(from: oldSnapshot, to: newSnapshot), 47 dnsSection(from: oldSnapshot, to: newSnapshot),
47 redirectSection(from: oldSnapshot, to: newSnapshot), 48 redirectSection(from: oldSnapshot, to: newSnapshot),
48 tlsSection(from: oldSnapshot, to: newSnapshot), 49 tlsSection(from: oldSnapshot, to: newSnapshot),
49 httpSection(from: oldSnapshot, to: newSnapshot), 50 httpSection(from: oldSnapshot, to: newSnapshot),
50 emailSection(from: oldSnapshot, to: newSnapshot) 51 emailSection(from: oldSnapshot, to: newSnapshot),
52 subdomainSection(from: oldSnapshot, to: newSnapshot)
51 ] 53 ]
52 .filter { !$0.items.isEmpty } 54 .filter { !$0.items.isEmpty }
53 } 55 }
@@ -58,19 +60,16 @@ enum DomainDiffService {
58 generatedAt: Date = Date() 60 generatedAt: Date = Date()
59 ) -> DomainChangeSummary { 61 ) -> DomainChangeSummary {
60 let sections = diff(from: oldSnapshot, to: newSnapshot) 62 let sections = diff(from: oldSnapshot, to: newSnapshot)
61 let meaningfulItems = sections
62 .flatMap(\.items)
63 .filter(\.isMeaningful)
64 let allChangedItems = sections 63 let allChangedItems = sections
65 .flatMap(\.items) 64 .flatMap(\.items)
66 .filter(\.hasChanges) 65 .filter(\.hasChanges)
67 66
68 let highlights = summaryHighlights(from: meaningfulItems) 67 let highlights = summaryHighlights(from: allChangedItems)
69 let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low) 68 let severity = allChangedItems.map(\.severity).max() ?? .low
70 let message = summaryMessage(from: meaningfulItems, highlights: highlights) 69 let message = summaryMessage(from: allChangedItems, highlights: highlights)
71 70
72 return DomainChangeSummary( 71 return DomainChangeSummary(
73 hasChanges: !meaningfulItems.isEmpty, 72 hasChanges: !allChangedItems.isEmpty,
74 changedSections: highlights, 73 changedSections: highlights,
75 message: message, 74 message: message,
76 severity: severity, 75 severity: severity,
@@ -152,6 +151,50 @@ enum DomainDiffService {
152 return DomainDiffSection(title: "DNS", items: items) 151 return DomainDiffSection(title: "DNS", items: items)
153 } 152 }
154 153
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
155 private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { 198 private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
156 DomainDiffSection( 199 DomainDiffSection(
157 title: "Redirect", 200 title: "Redirect",
@@ -252,6 +295,20 @@ enum DomainDiffService {
252 ) 295 )
253 } 296 }
254 297
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
255 private static func compare( 312 private static func compare(
256 label: String, 313 label: String,
257 oldValue: String?, 314 oldValue: String?,
@@ -306,6 +363,13 @@ enum DomainDiffService {
306 if labels.contains("Redirect Target") { 363 if labels.contains("Redirect Target") {
307 highlights.append("Redirect target changed") 364 highlights.append("Redirect target changed")
308 } 365 }
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 }
309 if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }), 373 if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
310 let message = certificateItem.newValue { 374 let message = certificateItem.newValue {
311 highlights.append(message) 375 highlights.append(message)
@@ -323,6 +387,9 @@ enum DomainDiffService {
323 if labels.contains("Email Security") { 387 if labels.contains("Email Security") {
324 highlights.append("Email security changed") 388 highlights.append("Email security changed")
325 } 389 }
390 if labels.contains("Passive Subdomains") {
391 highlights.append("Subdomains changed")
392 }
326 393
327 var deduplicated: [String] = [] 394 var deduplicated: [String] = []
328 for highlight in highlights where !deduplicated.contains(highlight) { 395 for highlight in highlights where !deduplicated.contains(highlight) {
@@ -380,6 +447,10 @@ enum DomainDiffService {
380 return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)" 447 return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
381 } 448 }
382 449
450 private static func ownershipDateLabel(_ date: Date?) -> String? {
451 date?.formatted(date: .abbreviated, time: .omitted)
452 }
453
383 private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { 454 private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
384 if let httpStatusCode = snapshot.httpStatusCode { 455 if let httpStatusCode = snapshot.httpStatusCode {
385 return "\(httpStatusCode)" 456 return "\(httpStatusCode)"
@@ -423,4 +494,21 @@ enum DomainDiffService {
423 .sorted() 494 .sorted()
424 return headers.isEmpty ? nil : headers.joined(separator: "|") 495 return headers.isEmpty ? nil : headers.joined(separator: "|")
425 } 496 }
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 }
426} 514}
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 {
72 let durationLabel: String? 72 let durationLabel: String?
73} 73}
74 74
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
75struct DomainSuggestionViewData: Identifiable { 87struct DomainSuggestionViewData: Identifiable {
76 let id: UUID 88 let id: UUID
77 let domain: String 89 let domain: String
@@ -107,10 +119,14 @@ struct LookupSnapshot {
107 let ipGeolocationError: String? 119 let ipGeolocationError: String?
108 let emailSecurity: EmailSecurityResult? 120 let emailSecurity: EmailSecurityResult?
109 let emailSecurityError: String? 121 let emailSecurityError: String?
122 let ownership: DomainOwnership?
123 let ownershipError: String?
110 let ptrRecord: String? 124 let ptrRecord: String?
111 let ptrError: String? 125 let ptrError: String?
112 let redirectChain: [RedirectHop] 126 let redirectChain: [RedirectHop]
113 let redirectChainError: String? 127 let redirectChainError: String?
128 let subdomains: [DiscoveredSubdomain]
129 let subdomainsError: String?
114 let portScanResults: [PortScanResult] 130 let portScanResults: [PortScanResult]
115 let portScanError: String? 131 let portScanError: String?
116 let changeSummary: DomainChangeSummary? 132 let changeSummary: DomainChangeSummary?
@@ -147,10 +163,14 @@ extension HistoryEntry {
147 ipGeolocationError: ipGeolocationError, 163 ipGeolocationError: ipGeolocationError,
148 emailSecurity: emailSecurity, 164 emailSecurity: emailSecurity,
149 emailSecurityError: emailSecurityError, 165 emailSecurityError: emailSecurityError,
166 ownership: ownership,
167 ownershipError: ownershipError,
150 ptrRecord: ptrRecord, 168 ptrRecord: ptrRecord,
151 ptrError: ptrError, 169 ptrError: ptrError,
152 redirectChain: redirectChain, 170 redirectChain: redirectChain,
153 redirectChainError: redirectChainError, 171 redirectChainError: redirectChainError,
172 subdomains: subdomains,
173 subdomainsError: subdomainsError,
154 portScanResults: portScanResults, 174 portScanResults: portScanResults,
155 portScanError: portScanError, 175 portScanError: portScanError,
156 changeSummary: changeSummary, 176 changeSummary: changeSummary,
@@ -204,6 +224,10 @@ final class DomainViewModel {
204 var emailSecurityLoading = false 224 var emailSecurityLoading = false
205 var emailSecurityError: String? 225 var emailSecurityError: String?
206 226
227 var ownershipResult: DomainOwnership?
228 var ownershipLoading = false
229 var ownershipError: String?
230
207 var ptrRecord: String? 231 var ptrRecord: String?
208 var ptrLoading = false 232 var ptrLoading = false
209 var ptrError: String? 233 var ptrError: String?
@@ -212,6 +236,10 @@ final class DomainViewModel {
212 var redirectChainLoading = false 236 var redirectChainLoading = false
213 var redirectChainError: String? 237 var redirectChainError: String?
214 238
239 var subdomains: [DiscoveredSubdomain] = []
240 var subdomainsLoading = false
241 var subdomainsError: String?
242
215 var portScanResults: [PortScanResult] = [] 243 var portScanResults: [PortScanResult] = []
216 var portScanLoading = false 244 var portScanLoading = false
217 var portScanError: String? 245 var portScanError: String?
@@ -224,6 +252,7 @@ final class DomainViewModel {
224 private(set) var lastLookupDurationMs: Int? 252 private(set) var lastLookupDurationMs: Int?
225 private(set) var currentDiffSections: [DomainDiffSection] = [] 253 private(set) var currentDiffSections: [DomainDiffSection] = []
226 private(set) var currentChangeSummary: DomainChangeSummary? 254 private(set) var currentChangeSummary: DomainChangeSummary?
255 private(set) var ownershipDiff: [DomainDiffItem] = []
227 private(set) var refreshingTrackedDomainID: UUID? 256 private(set) var refreshingTrackedDomainID: UUID?
228 private(set) var rerunNavigationToken = UUID() 257 private(set) var rerunNavigationToken = UUID()
229 private(set) var batchResults: [BatchLookupResult] = [] 258 private(set) var batchResults: [BatchLookupResult] = []
@@ -290,8 +319,10 @@ final class DomainViewModel {
290 !reachabilityLoading && 319 !reachabilityLoading &&
291 !ipGeolocationLoading && 320 !ipGeolocationLoading &&
292 !emailSecurityLoading && 321 !emailSecurityLoading &&
322 !ownershipLoading &&
293 !ptrLoading && 323 !ptrLoading &&
294 !redirectChainLoading && 324 !redirectChainLoading &&
325 !subdomainsLoading &&
295 !portScanLoading && 326 !portScanLoading &&
296 !customPortScanLoading 327 !customPortScanLoading
297 } 328 }
@@ -438,10 +469,14 @@ final class DomainViewModel {
438 ipGeolocationError: ipGeolocationError, 469 ipGeolocationError: ipGeolocationError,
439 emailSecurity: emailSecurity, 470 emailSecurity: emailSecurity,
440 emailSecurityError: emailSecurityError, 471 emailSecurityError: emailSecurityError,
472 ownership: ownershipResult,
473 ownershipError: ownershipError,
441 ptrRecord: ptrRecord, 474 ptrRecord: ptrRecord,
442 ptrError: ptrError, 475 ptrError: ptrError,
443 redirectChain: redirectChain, 476 redirectChain: redirectChain,
444 redirectChainError: redirectChainError, 477 redirectChainError: redirectChainError,
478 subdomains: subdomains,
479 subdomainsError: subdomainsError,
445 portScanResults: allPortScanResults, 480 portScanResults: allPortScanResults,
446 portScanError: combinedPortScanError, 481 portScanError: combinedPortScanError,
447 changeSummary: currentChangeSummary, 482 changeSummary: currentChangeSummary,
@@ -489,6 +524,14 @@ final class DomainViewModel {
489 Self.emailRows(from: currentSnapshot) 524 Self.emailRows(from: currentSnapshot)
490 } 525 }
491 526
527 var ownershipRows: [InfoRowViewData] {
528 Self.ownershipRows(from: currentSnapshot)
529 }
530
531 var subdomainRows: [SubdomainRowViewData] {
532 Self.subdomainRows(from: currentSnapshot)
533 }
534
492 var reachabilityRows: [ReachabilityRowViewData] { 535 var reachabilityRows: [ReachabilityRowViewData] {
493 Self.reachabilityRows(from: currentSnapshot) 536 Self.reachabilityRows(from: currentSnapshot)
494 } 537 }
@@ -643,6 +686,7 @@ final class DomainViewModel {
643 lastLookupDurationMs = nil 686 lastLookupDurationMs = nil
644 currentDiffSections = [] 687 currentDiffSections = []
645 currentChangeSummary = nil 688 currentChangeSummary = nil
689 ownershipDiff = []
646 refreshingTrackedDomainID = nil 690 refreshingTrackedDomainID = nil
647 clearBatchState() 691 clearBatchState()
648 clearLookupState() 692 clearLookupState()
@@ -791,6 +835,8 @@ final class DomainViewModel {
791 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } 835 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
792 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } 836 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
793 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } 837 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) }
794 } 840 }
795 841
796 await withTaskGroup(of: Void.self) { group in 842 await withTaskGroup(of: Void.self) { group in
@@ -946,6 +992,23 @@ final class DomainViewModel {
946 emailSecurityLoading = false 992 emailSecurityLoading = false
947 } 993 }
948 994
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
949 private func runReverseDNS(ip: String, lookupID: UUID) async { 1012 private func runReverseDNS(ip: String, lookupID: UUID) async {
950 let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString) 1013 let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
951 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } 1014 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
@@ -980,6 +1043,23 @@ final class DomainViewModel {
980 redirectChainLoading = false 1043 redirectChainLoading = false
981 } 1044 }
982 1045
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
983 private func runPortScan(domain: String, lookupID: UUID) async { 1063 private func runPortScan(domain: String, lookupID: UUID) async {
984 let result = await PortScanService.scanAll(domain: domain) 1064 let result = await PortScanService.scanAll(domain: domain)
985 switch result { 1065 switch result {
@@ -1061,7 +1141,9 @@ final class DomainViewModel {
1061 async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain) 1141 async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
1062 async let httpResult = HTTPHeadersService.fetch(domain: domain) 1142 async let httpResult = HTTPHeadersService.fetch(domain: domain)
1063 async let reachabilityResult = ReachabilityService.checkAll(domain: domain) 1143 async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
1144 async let ownershipResult = DomainOwnershipService.lookup(domain: domain)
1064 async let redirectResult = RedirectChainService.trace(domain: domain) 1145 async let redirectResult = RedirectChainService.trace(domain: domain)
1146 async let subdomainResult = SubdomainDiscoveryService.discover(for: domain)
1065 async let portScanResult = PortScanService.scanAll(domain: domain) 1147 async let portScanResult = PortScanService.scanAll(domain: domain)
1066 1148
1067 let resolvedDNS = await dnsResult 1149 let resolvedDNS = await dnsResult
@@ -1070,7 +1152,9 @@ final class DomainViewModel {
1070 let hsts = await hstsResult 1152 let hsts = await hstsResult
1071 let http = await httpResult 1153 let http = await httpResult
1072 let reachability = await reachabilityResult 1154 let reachability = await reachabilityResult
1155 let resolvedOwnership = await ownershipResult
1073 let redirects = await redirectResult 1156 let redirects = await redirectResult
1157 let resolvedSubdomains = await subdomainResult
1074 let ports = await portScanResult 1158 let ports = await portScanResult
1075 1159
1076 guard !Task.isCancelled else { return nil } 1160 guard !Task.isCancelled else { return nil }
@@ -1187,6 +1271,17 @@ final class DomainViewModel {
1187 emailSecurityError = message 1271 emailSecurityError = message
1188 } 1272 }
1189 1273
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
1190 let ptrRecord: String? 1285 let ptrRecord: String?
1191 let ptrError: String? 1286 let ptrError: String?
1192 switch resolvedPTR { 1287 switch resolvedPTR {
@@ -1215,6 +1310,17 @@ final class DomainViewModel {
1215 ipGeolocationError = "No A record available" 1310 ipGeolocationError = "No A record available"
1216 } 1311 }
1217 1312
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
1218 let snapshot = LookupSnapshot( 1324 let snapshot = LookupSnapshot(
1219 historyEntryID: nil, 1325 historyEntryID: nil,
1220 domain: domain, 1326 domain: domain,
@@ -1243,10 +1349,14 @@ final class DomainViewModel {
1243 ipGeolocationError: ipGeolocationError, 1349 ipGeolocationError: ipGeolocationError,
1244 emailSecurity: emailSecurity, 1350 emailSecurity: emailSecurity,
1245 emailSecurityError: emailSecurityError, 1351 emailSecurityError: emailSecurityError,
1352 ownership: ownership,
1353 ownershipError: ownershipError,
1246 ptrRecord: ptrRecord, 1354 ptrRecord: ptrRecord,
1247 ptrError: ptrError, 1355 ptrError: ptrError,
1248 redirectChain: redirectChain, 1356 redirectChain: redirectChain,
1249 redirectChainError: redirectChainError, 1357 redirectChainError: redirectChainError,
1358 subdomains: subdomains,
1359 subdomainsError: subdomainsError,
1250 portScanResults: portScanResults, 1360 portScanResults: portScanResults,
1251 portScanError: portScanError, 1361 portScanError: portScanError,
1252 changeSummary: nil, 1362 changeSummary: nil,
@@ -1303,6 +1413,7 @@ final class DomainViewModel {
1303 if updateCurrentState { 1413 if updateCurrentState {
1304 currentChangeSummary = changeSummary 1414 currentChangeSummary = changeSummary
1305 currentDiffSections = diffSections 1415 currentDiffSections = diffSections
1416 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
1306 } 1417 }
1307 1418
1308 let entry = HistoryEntry( 1419 let entry = HistoryEntry(
@@ -1316,8 +1427,10 @@ final class DomainViewModel {
1316 ipGeolocation: snapshot.ipGeolocation, 1427 ipGeolocation: snapshot.ipGeolocation,
1317 emailSecurity: snapshot.emailSecurity, 1428 emailSecurity: snapshot.emailSecurity,
1318 mtaSts: snapshot.emailSecurity?.mtaSts, 1429 mtaSts: snapshot.emailSecurity?.mtaSts,
1430 ownership: snapshot.ownership,
1319 ptrRecord: snapshot.ptrRecord, 1431 ptrRecord: snapshot.ptrRecord,
1320 redirectChain: snapshot.redirectChain, 1432 redirectChain: snapshot.redirectChain,
1433 subdomains: snapshot.subdomains,
1321 portScanResults: snapshot.portScanResults, 1434 portScanResults: snapshot.portScanResults,
1322 hstsPreloaded: snapshot.hstsPreloaded, 1435 hstsPreloaded: snapshot.hstsPreloaded,
1323 availabilityResult: snapshot.availabilityResult, 1436 availabilityResult: snapshot.availabilityResult,
@@ -1336,8 +1449,10 @@ final class DomainViewModel {
1336 reachabilityError: snapshot.reachabilityError, 1449 reachabilityError: snapshot.reachabilityError,
1337 ipGeolocationError: snapshot.ipGeolocationError, 1450 ipGeolocationError: snapshot.ipGeolocationError,
1338 emailSecurityError: snapshot.emailSecurityError, 1451 emailSecurityError: snapshot.emailSecurityError,
1452 ownershipError: snapshot.ownershipError,
1339 ptrError: snapshot.ptrError, 1453 ptrError: snapshot.ptrError,
1340 redirectChainError: snapshot.redirectChainError, 1454 redirectChainError: snapshot.redirectChainError,
1455 subdomainsError: snapshot.subdomainsError,
1341 portScanError: snapshot.portScanError 1456 portScanError: snapshot.portScanError
1342 ) 1457 )
1343 1458
@@ -1520,6 +1635,7 @@ final class DomainViewModel {
1520 hasRun = true 1635 hasRun = true
1521 currentDiffSections = [] 1636 currentDiffSections = []
1522 currentChangeSummary = nil 1637 currentChangeSummary = nil
1638 ownershipDiff = []
1523 clearLookupState() 1639 clearLookupState()
1524 setAllLoadingStates(true) 1640 setAllLoadingStates(true)
1525 customPortScanLoading = false 1641 customPortScanLoading = false
@@ -1677,8 +1793,8 @@ final class DomainViewModel {
1677 let summary = BatchSweepSummary( 1793 let summary = BatchSweepSummary(
1678 source: source, 1794 source: source,
1679 totalDomains: batchResults.count, 1795 totalDomains: batchResults.count,
1680 changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count, 1796 changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count,
1681 unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count, 1797 unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count,
1682 warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count, 1798 warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
1683 results: batchResults.sorted { lhs, rhs in 1799 results: batchResults.sorted { lhs, rhs in
1684 if lhs.status != rhs.status { 1800 if lhs.status != rhs.status {
@@ -1754,12 +1870,18 @@ final class DomainViewModel {
1754 emailSecurity = nil 1870 emailSecurity = nil
1755 emailSecurityError = nil 1871 emailSecurityError = nil
1756 emailSecurityLoading = false 1872 emailSecurityLoading = false
1873 ownershipResult = nil
1874 ownershipError = nil
1875 ownershipLoading = false
1757 ptrRecord = nil 1876 ptrRecord = nil
1758 ptrError = nil 1877 ptrError = nil
1759 ptrLoading = false 1878 ptrLoading = false
1760 redirectChain = [] 1879 redirectChain = []
1761 redirectChainError = nil 1880 redirectChainError = nil
1762 redirectChainLoading = false 1881 redirectChainLoading = false
1882 subdomains = []
1883 subdomainsError = nil
1884 subdomainsLoading = false
1763 portScanResults = [] 1885 portScanResults = []
1764 portScanError = nil 1886 portScanError = nil
1765 portScanLoading = false 1887 portScanLoading = false
@@ -1778,8 +1900,10 @@ final class DomainViewModel {
1778 reachabilityLoading = loading 1900 reachabilityLoading = loading
1779 ipGeolocationLoading = loading 1901 ipGeolocationLoading = loading
1780 emailSecurityLoading = loading 1902 emailSecurityLoading = loading
1903 ownershipLoading = loading
1781 ptrLoading = loading 1904 ptrLoading = loading
1782 redirectChainLoading = loading 1905 redirectChainLoading = loading
1906 subdomainsLoading = loading
1783 portScanLoading = loading 1907 portScanLoading = loading
1784 } 1908 }
1785 1909
@@ -1872,10 +1996,14 @@ final class DomainViewModel {
1872 ipGeolocationError: nil, 1996 ipGeolocationError: nil,
1873 emailSecurity: nil, 1997 emailSecurity: nil,
1874 emailSecurityError: nil, 1998 emailSecurityError: nil,
1999 ownership: nil,
2000 ownershipError: nil,
1875 ptrRecord: nil, 2001 ptrRecord: nil,
1876 ptrError: nil, 2002 ptrError: nil,
1877 redirectChain: [], 2003 redirectChain: [],
1878 redirectChainError: nil, 2004 redirectChainError: nil,
2005 subdomains: [],
2006 subdomainsError: nil,
1879 portScanResults: [], 2007 portScanResults: [],
1880 portScanError: nil, 2008 portScanError: nil,
1881 changeSummary: trackedDomain.lastChangeSummary, 2009 changeSummary: trackedDomain.lastChangeSummary,
@@ -2100,6 +2228,28 @@ final class DomainViewModel {
2100 ] 2228 ]
2101 } 2229 }
2102 2230
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
2103 static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] { 2253 static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
2104 snapshot.reachabilityResults.map { 2254 snapshot.reachabilityResults.map {
2105 ReachabilityRowViewData( 2255 ReachabilityRowViewData(
@@ -2179,6 +2329,12 @@ final class DomainViewModel {
2179 "tls_status", 2329 "tls_status",
2180 "http_status_grade", 2330 "http_status_grade",
2181 "email_security_summary", 2331 "email_security_summary",
2332 "registrar",
2333 "ownership_expires",
2334 "ownership_status",
2335 "ownership_nameservers",
2336 "subdomain_count",
2337 "subdomains",
2182 "last_updated" 2338 "last_updated"
2183 ] 2339 ]
2184 2340
@@ -2191,6 +2347,12 @@ final class DomainViewModel {
2191 httpsSummary(from: snapshot), 2347 httpsSummary(from: snapshot),
2192 httpStatusGradeSummary(from: snapshot), 2348 httpStatusGradeSummary(from: snapshot),
2193 emailSummary(from: snapshot), 2349 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: " | "),
2194 csvDateFormatter.string(from: snapshot.timestamp) 2356 csvDateFormatter.string(from: snapshot.timestamp)
2195 ] 2357 ]
2196 } 2358 }
@@ -2282,6 +2444,33 @@ final class DomainViewModel {
2282 } 2444 }
2283 } 2445 }
2284 2446
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
2285 appendSection("DNS") { 2474 appendSection("DNS") {
2286 if let dnsError = snapshot.dnsError { 2475 if let dnsError = snapshot.dnsError {
2287 lines.append(" Error: \(dnsError)") 2476 lines.append(" Error: \(dnsError)")
@@ -2544,6 +2733,13 @@ final class DomainViewModel {
2544 return formatter 2733 return formatter
2545 }() 2734 }()
2546 2735
2736 private static let ownershipDateFormatter: DateFormatter = {
2737 let formatter = DateFormatter()
2738 formatter.dateStyle = .medium
2739 formatter.timeStyle = .none
2740 return formatter
2741 }()
2742
2547 private static let csvDateFormatter: ISO8601DateFormatter = { 2743 private static let csvDateFormatter: ISO8601DateFormatter = {
2548 let formatter = ISO8601DateFormatter() 2744 let formatter = ISO8601DateFormatter()
2549 formatter.formatOptions = [.withInternetDateTime] 2745 formatter.formatOptions = [.withInternetDateTime]
@@ -2554,6 +2750,14 @@ final class DomainViewModel {
2554 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") 2750 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
2555 return "\"\(escaped)\"" 2751 return "\"\(escaped)\""
2556 } 2752 }
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 }
2557} 2761}
2558 2762
2559private extension String { 2763private extension String {
@@ -2565,3 +2769,9 @@ private extension String {
2565 isEmpty ? nil : self 2769 isEmpty ? nil : self
2566 } 2770 }
2567} 2771}
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 {
147 onEditNote: nil 147 onEditNote: nil
148 ) 148 )
149 .padding(.top, 16) 149 .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)
150 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) { 164 if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
151 if let changeSummary = entry.changeSummary { 165 if let changeSummary = entry.changeSummary {
152 DomainChangeSummaryView(summary: changeSummary) 166 DomainChangeSummaryView(summary: changeSummary)
DomainDig/Models.swift +66 −4
@@ -184,6 +184,13 @@ struct BatchSweepSummary: Identifiable, Equatable {
184 let generatedAt: Date 184 let generatedAt: Date
185} 185}
186 186
187enum DataCapability: String, Codable {
188 case ownershipHistory
189 case dnsHistory
190 case extendedSubdomains
191 case domainPricing
192}
193
187enum HistoryDateFilter: String, CaseIterable, Identifiable { 194enum HistoryDateFilter: String, CaseIterable, Identifiable {
188 case today 195 case today
189 case last7Days 196 case last7Days
@@ -501,6 +508,48 @@ struct IPGeolocation: Codable {
501 let longitude: Double? 508 let longitude: Double?
502} 509}
503 510
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
504// MARK: - Email Security Models 553// MARK: - Email Security Models
505 554
506struct EmailSecurityResult: Codable { 555struct EmailSecurityResult: Codable {
@@ -627,8 +676,10 @@ struct HistoryEntry: Identifiable, Codable {
627 let ipGeolocation: IPGeolocation? 676 let ipGeolocation: IPGeolocation?
628 var emailSecurity: EmailSecurityResult? 677 var emailSecurity: EmailSecurityResult?
629 var mtaSts: MTASTSResult? 678 var mtaSts: MTASTSResult?
679 var ownership: DomainOwnership?
630 var ptrRecord: String? 680 var ptrRecord: String?
631 var redirectChain: [RedirectHop] 681 var redirectChain: [RedirectHop]
682 var subdomains: [DiscoveredSubdomain]
632 var portScanResults: [PortScanResult] 683 var portScanResults: [PortScanResult]
633 var hstsPreloaded: Bool? 684 var hstsPreloaded: Bool?
634 var availabilityResult: DomainAvailabilityResult? 685 var availabilityResult: DomainAvailabilityResult?
@@ -647,23 +698,26 @@ struct HistoryEntry: Identifiable, Codable {
647 var reachabilityError: String? 698 var reachabilityError: String?
648 var ipGeolocationError: String? 699 var ipGeolocationError: String?
649 var emailSecurityError: String? 700 var emailSecurityError: String?
701 var ownershipError: String?
650 var ptrError: String? 702 var ptrError: String?
651 var redirectChainError: String? 703 var redirectChainError: String?
704 var subdomainsError: String?
652 var portScanError: String? 705 var portScanError: String?
653 706
654 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection], 707 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection],
655 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], 708 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
656 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, 709 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
657 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, 710 emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
658 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], 711 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
712 portScanResults: [PortScanResult] = [],
659 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, 713 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
660 suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, 714 suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String,
661 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, 715 totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
662 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, 716 tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
663 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, 717 changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
664 reachabilityError: String? = nil, ipGeolocationError: String? = nil, 718 reachabilityError: String? = nil, ipGeolocationError: String? = nil,
665 emailSecurityError: String? = nil, ptrError: String? = nil, 719 emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil,
666 redirectChainError: String? = nil, portScanError: String? = nil) { 720 redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) {
667 self.domain = domain 721 self.domain = domain
668 self.timestamp = timestamp 722 self.timestamp = timestamp
669 self.trackedDomainID = trackedDomainID 723 self.trackedDomainID = trackedDomainID
@@ -674,8 +728,10 @@ struct HistoryEntry: Identifiable, Codable {
674 self.ipGeolocation = ipGeolocation 728 self.ipGeolocation = ipGeolocation
675 self.emailSecurity = emailSecurity 729 self.emailSecurity = emailSecurity
676 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts 730 self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
731 self.ownership = ownership
677 self.ptrRecord = ptrRecord 732 self.ptrRecord = ptrRecord
678 self.redirectChain = redirectChain 733 self.redirectChain = redirectChain
734 self.subdomains = subdomains
679 self.portScanResults = portScanResults 735 self.portScanResults = portScanResults
680 self.hstsPreloaded = hstsPreloaded 736 self.hstsPreloaded = hstsPreloaded
681 self.availabilityResult = availabilityResult 737 self.availabilityResult = availabilityResult
@@ -694,8 +750,10 @@ struct HistoryEntry: Identifiable, Codable {
694 self.reachabilityError = reachabilityError 750 self.reachabilityError = reachabilityError
695 self.ipGeolocationError = ipGeolocationError 751 self.ipGeolocationError = ipGeolocationError
696 self.emailSecurityError = emailSecurityError 752 self.emailSecurityError = emailSecurityError
753 self.ownershipError = ownershipError
697 self.ptrError = ptrError 754 self.ptrError = ptrError
698 self.redirectChainError = redirectChainError 755 self.redirectChainError = redirectChainError
756 self.subdomainsError = subdomainsError
699 self.portScanError = portScanError 757 self.portScanError = portScanError
700 } 758 }
701 759
@@ -712,8 +770,10 @@ struct HistoryEntry: Identifiable, Codable {
712 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) 770 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
713 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) 771 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
714 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts 772 mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
773 ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
715 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) 774 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
716 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] 775 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
776 subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
717 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] 777 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
718 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) 778 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
719 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) 779 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -732,8 +792,10 @@ struct HistoryEntry: Identifiable, Codable {
732 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) 792 reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
733 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError) 793 ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
734 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError) 794 emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
795 ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
735 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError) 796 ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
736 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError) 797 redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
798 subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
737 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) 799 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
738 } 800 }
739} 801}
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}