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