Commit 22ecca12b4
Verified · cmc
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 | ||
| 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 | struct DNSSectionView: View { | 875 | struct 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 @@ | |||
| 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 | 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 @@ | |||
| 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 | let durationLabel: String? | 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 | struct DomainSuggestionViewData: Identifiable { | 87 | struct 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 | ||
| 2559 | private extension String { | 2763 | private extension String { |
| @@ -2565,3 +2769,9 @@ private extension String { | |||
| 2565 | isEmpty ? nil : self | 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 | 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 | ||
| 187 | enum DataCapability: String, Codable { | ||
| 188 | case ownershipHistory | ||
| 189 | case dnsHistory | ||
| 190 | case extendedSubdomains | ||
| 191 | case domainPricing | ||
| 192 | } | ||
| 193 | |||
| 187 | enum HistoryDateFilter: String, CaseIterable, Identifiable { | 194 | enum 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 | |||
| 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 | // MARK: - Email Security Models | 553 | // MARK: - Email Security Models |
| 505 | 554 | ||
| 506 | struct EmailSecurityResult: Codable { | 555 | struct 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 @@ | |||
| 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 | } | ||