diff options
| author | Christian Cleberg <[email protected]> | 2026-04-21 22:08:09 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-21 22:08:09 -0500 |
| commit | 22ecca12b4fe0e0850401754c674632f322d919d (patch) | |
| tree | 52845c3020c2471d607e7a1ad9681de83cbcbb85 /DomainDig | |
| parent | 1f58092dcb67cded75a850db364f3b46d86181f3 (diff) | |
| download | domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.tar.gz domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.tar.bz2 domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.zip | |
feat(v2.3.0): add ownership intelligence and subdomain discovery
* implement RDAP-based ownership section
* add ownership diffing and change classification
* add passive subdomain discovery via certificate transparency
* highlight interesting subdomains
* introduce Data+ scaffolding for future features
* include ownership and subdomains in export and history
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/BatchResultsView.swift | 3 | ||||
| -rw-r--r-- | DomainDig/BatchSweepSummaryView.swift | 5 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 100 | ||||
| -rw-r--r-- | DomainDig/DataAccessService.swift | 7 | ||||
| -rw-r--r-- | DomainDig/DomainAvailabilityService.swift | 51 | ||||
| -rw-r--r-- | DomainDig/DomainDiffService.swift | 104 | ||||
| -rw-r--r-- | DomainDig/DomainOwnershipService.swift | 7 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 214 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 14 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 70 | ||||
| -rw-r--r-- | DomainDig/RDAPService.swift | 288 | ||||
| -rw-r--r-- | DomainDig/SubdomainDiscoveryService.swift | 118 |
12 files changed, 919 insertions, 62 deletions
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift index 03bc7c4..6bf18c0 100644 --- a/DomainDig/BatchResultsView.swift +++ b/DomainDig/BatchResultsView.swift @@ -115,6 +115,9 @@ struct BatchResultRowView: View { if result.changeSeverity == .medium || result.certificateWarningLevel == .warning { return .yellow } + if result.quickStatus == "Changed" { + return .blue + } return .green case .failed: return .red diff --git a/DomainDig/BatchSweepSummaryView.swift b/DomainDig/BatchSweepSummaryView.swift index fda2bc5..61b3e37 100644 --- a/DomainDig/BatchSweepSummaryView.swift +++ b/DomainDig/BatchSweepSummaryView.swift @@ -12,7 +12,10 @@ struct BatchSweepSummaryView: View { } return summary.results.filter { - ($0.changeSeverity ?? .low) >= .medium || $0.certificateWarningLevel != .none || $0.status == .failed + $0.quickStatus == "Changed" || + $0.quickStatus == "High" || + $0.certificateWarningLevel != .none || + $0.status == .failed } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 2be81b3..261b7ca 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -60,6 +60,20 @@ struct ContentView: View { } ) .padding(.top, 16) + OwnershipSectionView( + rows: viewModel.ownershipRows, + loading: viewModel.ownershipLoading, + error: viewModel.ownershipError, + showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) + ) + .padding(.top, 16) + SubdomainsSectionView( + rows: viewModel.subdomainRows, + loading: viewModel.subdomainsLoading, + error: viewModel.subdomainsError, + showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) + ) + .padding(.top, 16) if !viewModel.currentDiffSections.isEmpty { DomainDiffView( title: "Latest Changes", @@ -772,6 +786,92 @@ struct DomainSectionView: View { } } +struct OwnershipSectionView: View { + let rows: [InfoRowViewData] + let loading: Bool + let error: String? + let showsHistoryPlaceholder: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionTitleView(title: "Ownership") + CardView(allowsHorizontalScroll: false) { + if loading { + ProgressView("Fetching RDAP ownership…") + .appLoadingStyle() + } else { + ForEach(rows) { row in + LabeledValueRow(row: row) + } + if let error, rows.allSatisfy({ $0.value == "Unavailable" }) { + MessageRowView(text: error, isError: error != "Unavailable") + .padding(.top, 4) + } + if showsHistoryPlaceholder { + MessageRowView(text: "Ownership history (coming soon)", isError: false) + .padding(.top, 4) + } + } + } + } + } +} + +struct SubdomainsSectionView: View { + let rows: [SubdomainRowViewData] + let loading: Bool + let error: String? + let showsExtendedPlaceholder: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + SectionTitleView(title: "Subdomains") + Spacer() + Text("\(rows.count)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + + CardView(allowsHorizontalScroll: false) { + if loading { + ProgressView("Checking certificate transparency…") + .appLoadingStyle() + } else if rows.isEmpty { + MessageRowView(text: error ?? "No passive subdomains found", isError: false) + if showsExtendedPlaceholder { + MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false) + .padding(.top, 4) + } + } else { + ForEach(rows) { row in + HStack(spacing: 8) { + Text(row.hostname) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + Spacer() + if row.isInteresting { + Text("Interesting") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.yellow) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.yellow.opacity(0.14)) + .clipShape(Capsule()) + } + } + } + if showsExtendedPlaceholder { + MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false) + .padding(.top, 4) + } + } + } + } + } +} + struct DNSSectionView: View { let dnssecLabel: String? let sections: [DNSRecordSectionViewData] diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift new file mode 100644 index 0000000..fed4fde --- /dev/null +++ b/DomainDig/DataAccessService.swift @@ -0,0 +1,7 @@ +import Foundation + +enum DataAccessService { + static func hasAccess(to capability: DataCapability) -> Bool { + false + } +} diff --git a/DomainDig/DomainAvailabilityService.swift b/DomainDig/DomainAvailabilityService.swift index 7bef586..9a350ef 100644 --- a/DomainDig/DomainAvailabilityService.swift +++ b/DomainDig/DomainAvailabilityService.swift @@ -35,32 +35,11 @@ struct DomainAvailabilityService { } private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? { - guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { - return nil - } - - do { - var request = URLRequest(url: url, timeoutInterval: 8) - request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - return nil - } - - switch httpResponse.statusCode { - case 200: - return isValidRDAPDomainResponse(data) ? .registered : nil - case 404: - debugLog("rdap-not-found", domain: domain, details: "Ignoring not-found response from rdap.org") - return nil - default: - return nil - } - } catch { - debugLog("rdap-error", domain: domain, details: error.localizedDescription) - return nil + let status = await RDAPService.registrationStatus(for: domain) + if status == nil { + debugLog("rdap-not-found", domain: domain, details: "Ignoring unavailable response from rdap.org") } + return status } private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus { @@ -85,28 +64,6 @@ struct DomainAvailabilityService { } } - private static func isValidRDAPDomainResponse(_ data: Data) -> Bool { - guard - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { - return false - } - - if object["ldhName"] as? String != nil { - return true - } - - if object["objectClassName"] as? String == "domain" { - return true - } - - if object["handle"] as? String != nil, object["unicodeName"] as? String != nil { - return true - } - - return false - } - private static func suggestionCandidates(for domain: String, limit: Int) -> [String] { let parts = domain.split(separator: ".") guard parts.count >= 2 else { return [] } diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift index 667802a..340873d 100644 --- a/DomainDig/DomainDiffService.swift +++ b/DomainDig/DomainDiffService.swift @@ -43,11 +43,13 @@ enum DomainDiffService { [ availabilitySection(from: oldSnapshot, to: newSnapshot), primaryIPSection(from: oldSnapshot, to: newSnapshot), + ownershipSection(from: oldSnapshot, to: newSnapshot), dnsSection(from: oldSnapshot, to: newSnapshot), redirectSection(from: oldSnapshot, to: newSnapshot), tlsSection(from: oldSnapshot, to: newSnapshot), httpSection(from: oldSnapshot, to: newSnapshot), - emailSection(from: oldSnapshot, to: newSnapshot) + emailSection(from: oldSnapshot, to: newSnapshot), + subdomainSection(from: oldSnapshot, to: newSnapshot) ] .filter { !$0.items.isEmpty } } @@ -58,19 +60,16 @@ enum DomainDiffService { generatedAt: Date = Date() ) -> DomainChangeSummary { let sections = diff(from: oldSnapshot, to: newSnapshot) - let meaningfulItems = sections - .flatMap(\.items) - .filter(\.isMeaningful) let allChangedItems = sections .flatMap(\.items) .filter(\.hasChanges) - let highlights = summaryHighlights(from: meaningfulItems) - let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low) - let message = summaryMessage(from: meaningfulItems, highlights: highlights) + let highlights = summaryHighlights(from: allChangedItems) + let severity = allChangedItems.map(\.severity).max() ?? .low + let message = summaryMessage(from: allChangedItems, highlights: highlights) return DomainChangeSummary( - hasChanges: !meaningfulItems.isEmpty, + hasChanges: !allChangedItems.isEmpty, changedSections: highlights, message: message, severity: severity, @@ -152,6 +151,50 @@ enum DomainDiffService { return DomainDiffSection(title: "DNS", items: items) } + private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + DomainDiffSection( + title: "Ownership", + items: [ + compare( + label: "Registrar", + oldValue: normalized(oldSnapshot.ownership?.registrar), + newValue: normalized(newSnapshot.ownership?.registrar), + severity: .high + ), + compare( + label: "Registration Date", + oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate), + newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate), + severity: .low + ), + compare( + label: "Expiration Date", + oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate), + newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate), + severity: .low + ), + compare( + label: "Ownership Status", + oldValue: ownershipList(oldSnapshot.ownership?.status), + newValue: ownershipList(newSnapshot.ownership?.status), + severity: .low + ), + compare( + label: "Nameservers", + oldValue: ownershipList(oldSnapshot.ownership?.nameservers), + newValue: ownershipList(newSnapshot.ownership?.nameservers), + severity: .medium + ), + compare( + label: "Abuse Contact", + oldValue: normalized(oldSnapshot.ownership?.abuseEmail), + newValue: normalized(newSnapshot.ownership?.abuseEmail), + severity: .low + ) + ].compactMap { $0 } + ) + } + private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { DomainDiffSection( title: "Redirect", @@ -252,6 +295,20 @@ enum DomainDiffService { ) } + private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + DomainDiffSection( + title: "Subdomains", + items: [ + compare( + label: "Passive Subdomains", + oldValue: subdomainList(from: oldSnapshot), + newValue: subdomainList(from: newSnapshot), + severity: .low + ) + ].compactMap { $0 } + ) + } + private static func compare( label: String, oldValue: String?, @@ -306,6 +363,13 @@ enum DomainDiffService { if labels.contains("Redirect Target") { highlights.append("Redirect target changed") } + if labels.contains("Registrar") { + highlights.append("Registrar changed") + } else if labels.contains("Nameservers") { + highlights.append("Nameservers changed") + } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") { + highlights.append("Ownership metadata changed") + } if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }), let message = certificateItem.newValue { highlights.append(message) @@ -323,6 +387,9 @@ enum DomainDiffService { if labels.contains("Email Security") { highlights.append("Email security changed") } + if labels.contains("Passive Subdomains") { + highlights.append("Subdomains changed") + } var deduplicated: [String] = [] for highlight in highlights where !deduplicated.contains(highlight) { @@ -380,6 +447,10 @@ enum DomainDiffService { return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)" } + private static func ownershipDateLabel(_ date: Date?) -> String? { + date?.formatted(date: .abbreviated, time: .omitted) + } + private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { if let httpStatusCode = snapshot.httpStatusCode { return "\(httpStatusCode)" @@ -423,4 +494,21 @@ enum DomainDiffService { .sorted() return headers.isEmpty ? nil : headers.joined(separator: "|") } + + private static func ownershipList(_ values: [String]?) -> String? { + guard let values else { return nil } + let normalizedValues = values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + .sorted() + return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",") + } + + private static func subdomainList(from snapshot: LookupSnapshot) -> String? { + let values = snapshot.subdomains + .map(\.hostname) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .sorted() + return values.isEmpty ? nil : values.joined(separator: ",") + } } diff --git a/DomainDig/DomainOwnershipService.swift b/DomainDig/DomainOwnershipService.swift new file mode 100644 index 0000000..2c7b738 --- /dev/null +++ b/DomainDig/DomainOwnershipService.swift @@ -0,0 +1,7 @@ +import Foundation + +enum DomainOwnershipService { + static func lookup(domain: String) async -> ServiceResult<DomainOwnership> { + await RDAPService.ownership(for: domain) + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 031c062..e895ce6 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -72,6 +72,18 @@ struct PortScanRowViewData: Identifiable { let durationLabel: String? } +struct SubdomainRowViewData: Identifiable { + let id: String + let hostname: String + let isInteresting: Bool + + init(hostname: String, isInteresting: Bool) { + self.id = hostname + self.hostname = hostname + self.isInteresting = isInteresting + } +} + struct DomainSuggestionViewData: Identifiable { let id: UUID let domain: String @@ -107,10 +119,14 @@ struct LookupSnapshot { let ipGeolocationError: String? let emailSecurity: EmailSecurityResult? let emailSecurityError: String? + let ownership: DomainOwnership? + let ownershipError: String? let ptrRecord: String? let ptrError: String? let redirectChain: [RedirectHop] let redirectChainError: String? + let subdomains: [DiscoveredSubdomain] + let subdomainsError: String? let portScanResults: [PortScanResult] let portScanError: String? let changeSummary: DomainChangeSummary? @@ -147,10 +163,14 @@ extension HistoryEntry { ipGeolocationError: ipGeolocationError, emailSecurity: emailSecurity, emailSecurityError: emailSecurityError, + ownership: ownership, + ownershipError: ownershipError, ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, redirectChainError: redirectChainError, + subdomains: subdomains, + subdomainsError: subdomainsError, portScanResults: portScanResults, portScanError: portScanError, changeSummary: changeSummary, @@ -204,6 +224,10 @@ final class DomainViewModel { var emailSecurityLoading = false var emailSecurityError: String? + var ownershipResult: DomainOwnership? + var ownershipLoading = false + var ownershipError: String? + var ptrRecord: String? var ptrLoading = false var ptrError: String? @@ -212,6 +236,10 @@ final class DomainViewModel { var redirectChainLoading = false var redirectChainError: String? + var subdomains: [DiscoveredSubdomain] = [] + var subdomainsLoading = false + var subdomainsError: String? + var portScanResults: [PortScanResult] = [] var portScanLoading = false var portScanError: String? @@ -224,6 +252,7 @@ final class DomainViewModel { private(set) var lastLookupDurationMs: Int? private(set) var currentDiffSections: [DomainDiffSection] = [] private(set) var currentChangeSummary: DomainChangeSummary? + private(set) var ownershipDiff: [DomainDiffItem] = [] private(set) var refreshingTrackedDomainID: UUID? private(set) var rerunNavigationToken = UUID() private(set) var batchResults: [BatchLookupResult] = [] @@ -290,8 +319,10 @@ final class DomainViewModel { !reachabilityLoading && !ipGeolocationLoading && !emailSecurityLoading && + !ownershipLoading && !ptrLoading && !redirectChainLoading && + !subdomainsLoading && !portScanLoading && !customPortScanLoading } @@ -438,10 +469,14 @@ final class DomainViewModel { ipGeolocationError: ipGeolocationError, emailSecurity: emailSecurity, emailSecurityError: emailSecurityError, + ownership: ownershipResult, + ownershipError: ownershipError, ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, redirectChainError: redirectChainError, + subdomains: subdomains, + subdomainsError: subdomainsError, portScanResults: allPortScanResults, portScanError: combinedPortScanError, changeSummary: currentChangeSummary, @@ -489,6 +524,14 @@ final class DomainViewModel { Self.emailRows(from: currentSnapshot) } + var ownershipRows: [InfoRowViewData] { + Self.ownershipRows(from: currentSnapshot) + } + + var subdomainRows: [SubdomainRowViewData] { + Self.subdomainRows(from: currentSnapshot) + } + var reachabilityRows: [ReachabilityRowViewData] { Self.reachabilityRows(from: currentSnapshot) } @@ -643,6 +686,7 @@ final class DomainViewModel { lastLookupDurationMs = nil currentDiffSections = [] currentChangeSummary = nil + ownershipDiff = [] refreshingTrackedDomainID = nil clearBatchState() clearLookupState() @@ -791,6 +835,8 @@ final class DomainViewModel { group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } + group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) } + group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) } } await withTaskGroup(of: Void.self) { group in @@ -946,6 +992,23 @@ final class DomainViewModel { emailSecurityLoading = false } + private func runOwnership(domain: String, lookupID: UUID) async { + let result = await DomainOwnershipService.lookup(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(ownership): + ownershipResult = ownership + ownershipError = nil + case let .empty(message): + ownershipResult = nil + ownershipError = message + case let .error(message): + ownershipResult = nil + ownershipError = message + } + ownershipLoading = false + } + private func runReverseDNS(ip: String, lookupID: UUID) async { let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString) guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } @@ -980,6 +1043,23 @@ final class DomainViewModel { redirectChainLoading = false } + private func runSubdomains(domain: String, lookupID: UUID) async { + let result = await SubdomainDiscoveryService.discover(for: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(results): + subdomains = results + subdomainsError = nil + case let .empty(message): + subdomains = [] + subdomainsError = message + case let .error(message): + subdomains = [] + subdomainsError = message + } + subdomainsLoading = false + } + private func runPortScan(domain: String, lookupID: UUID) async { let result = await PortScanService.scanAll(domain: domain) switch result { @@ -1061,7 +1141,9 @@ final class DomainViewModel { async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain) async let httpResult = HTTPHeadersService.fetch(domain: domain) async let reachabilityResult = ReachabilityService.checkAll(domain: domain) + async let ownershipResult = DomainOwnershipService.lookup(domain: domain) async let redirectResult = RedirectChainService.trace(domain: domain) + async let subdomainResult = SubdomainDiscoveryService.discover(for: domain) async let portScanResult = PortScanService.scanAll(domain: domain) let resolvedDNS = await dnsResult @@ -1070,7 +1152,9 @@ final class DomainViewModel { let hsts = await hstsResult let http = await httpResult let reachability = await reachabilityResult + let resolvedOwnership = await ownershipResult let redirects = await redirectResult + let resolvedSubdomains = await subdomainResult let ports = await portScanResult guard !Task.isCancelled else { return nil } @@ -1187,6 +1271,17 @@ final class DomainViewModel { emailSecurityError = message } + let ownership: DomainOwnership? + let ownershipError: String? + switch resolvedOwnership { + case let .success(result): + ownership = result + ownershipError = nil + case let .empty(message), let .error(message): + ownership = nil + ownershipError = message + } + let ptrRecord: String? let ptrError: String? switch resolvedPTR { @@ -1215,6 +1310,17 @@ final class DomainViewModel { ipGeolocationError = "No A record available" } + let subdomains: [DiscoveredSubdomain] + let subdomainsError: String? + switch resolvedSubdomains { + case let .success(result): + subdomains = result + subdomainsError = nil + case let .empty(message), let .error(message): + subdomains = [] + subdomainsError = message + } + let snapshot = LookupSnapshot( historyEntryID: nil, domain: domain, @@ -1243,10 +1349,14 @@ final class DomainViewModel { ipGeolocationError: ipGeolocationError, emailSecurity: emailSecurity, emailSecurityError: emailSecurityError, + ownership: ownership, + ownershipError: ownershipError, ptrRecord: ptrRecord, ptrError: ptrError, redirectChain: redirectChain, redirectChainError: redirectChainError, + subdomains: subdomains, + subdomainsError: subdomainsError, portScanResults: portScanResults, portScanError: portScanError, changeSummary: nil, @@ -1303,6 +1413,7 @@ final class DomainViewModel { if updateCurrentState { currentChangeSummary = changeSummary currentDiffSections = diffSections + ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] } let entry = HistoryEntry( @@ -1316,8 +1427,10 @@ final class DomainViewModel { ipGeolocation: snapshot.ipGeolocation, emailSecurity: snapshot.emailSecurity, mtaSts: snapshot.emailSecurity?.mtaSts, + ownership: snapshot.ownership, ptrRecord: snapshot.ptrRecord, redirectChain: snapshot.redirectChain, + subdomains: snapshot.subdomains, portScanResults: snapshot.portScanResults, hstsPreloaded: snapshot.hstsPreloaded, availabilityResult: snapshot.availabilityResult, @@ -1336,8 +1449,10 @@ final class DomainViewModel { reachabilityError: snapshot.reachabilityError, ipGeolocationError: snapshot.ipGeolocationError, emailSecurityError: snapshot.emailSecurityError, + ownershipError: snapshot.ownershipError, ptrError: snapshot.ptrError, redirectChainError: snapshot.redirectChainError, + subdomainsError: snapshot.subdomainsError, portScanError: snapshot.portScanError ) @@ -1520,6 +1635,7 @@ final class DomainViewModel { hasRun = true currentDiffSections = [] currentChangeSummary = nil + ownershipDiff = [] clearLookupState() setAllLoadingStates(true) customPortScanLoading = false @@ -1677,8 +1793,8 @@ final class DomainViewModel { let summary = BatchSweepSummary( source: source, totalDomains: batchResults.count, - changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count, - unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count, + changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count, + unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count, warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count, results: batchResults.sorted { lhs, rhs in if lhs.status != rhs.status { @@ -1754,12 +1870,18 @@ final class DomainViewModel { emailSecurity = nil emailSecurityError = nil emailSecurityLoading = false + ownershipResult = nil + ownershipError = nil + ownershipLoading = false ptrRecord = nil ptrError = nil ptrLoading = false redirectChain = [] redirectChainError = nil redirectChainLoading = false + subdomains = [] + subdomainsError = nil + subdomainsLoading = false portScanResults = [] portScanError = nil portScanLoading = false @@ -1778,8 +1900,10 @@ final class DomainViewModel { reachabilityLoading = loading ipGeolocationLoading = loading emailSecurityLoading = loading + ownershipLoading = loading ptrLoading = loading redirectChainLoading = loading + subdomainsLoading = loading portScanLoading = loading } @@ -1872,10 +1996,14 @@ final class DomainViewModel { ipGeolocationError: nil, emailSecurity: nil, emailSecurityError: nil, + ownership: nil, + ownershipError: nil, ptrRecord: nil, ptrError: nil, redirectChain: [], redirectChainError: nil, + subdomains: [], + subdomainsError: nil, portScanResults: [], portScanError: nil, changeSummary: trackedDomain.lastChangeSummary, @@ -2100,6 +2228,28 @@ final class DomainViewModel { ] } + static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { + let ownership = snapshot.ownership + + return [ + InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary), + InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary), + InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary), + InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary), + InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary), + InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary) + ] + } + + static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] { + snapshot.subdomains.map { subdomain in + SubdomainRowViewData( + hostname: subdomain.hostname, + isInteresting: isInterestingSubdomain(subdomain.hostname) + ) + } + } + static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] { snapshot.reachabilityResults.map { ReachabilityRowViewData( @@ -2179,6 +2329,12 @@ final class DomainViewModel { "tls_status", "http_status_grade", "email_security_summary", + "registrar", + "ownership_expires", + "ownership_status", + "ownership_nameservers", + "subdomain_count", + "subdomains", "last_updated" ] @@ -2191,6 +2347,12 @@ final class DomainViewModel { httpsSummary(from: snapshot), httpStatusGradeSummary(from: snapshot), emailSummary(from: snapshot), + snapshot.ownership?.registrar ?? "", + snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "", + snapshot.ownership?.status.joined(separator: " | ") ?? "", + snapshot.ownership?.nameservers.joined(separator: " | ") ?? "", + "\(snapshot.subdomains.count)", + snapshot.subdomains.map(\.hostname).joined(separator: " | "), csvDateFormatter.string(from: snapshot.timestamp) ] } @@ -2282,6 +2444,33 @@ final class DomainViewModel { } } + appendSection("Ownership") { + for row in ownershipRows(from: snapshot) { + lines.append(" \(row.label): \(row.value)") + } + if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil { + lines.append(" Source: \(ownershipError)") + } + if !DataAccessService.hasAccess(to: .ownershipHistory) { + lines.append(" Ownership history (coming soon)") + } + } + + appendSection("Subdomains") { + lines.append(" Count: \(snapshot.subdomains.count)") + if snapshot.subdomains.isEmpty { + lines.append(" \(snapshot.subdomainsError ?? "No passive subdomains found")") + } else { + for subdomain in subdomainRows(from: snapshot) { + let marker = subdomain.isInteresting ? " [interesting]" : "" + lines.append(" \(subdomain.hostname)\(marker)") + } + } + if !DataAccessService.hasAccess(to: .extendedSubdomains) { + lines.append(" Extended subdomain discovery (Data+)") + } + } + appendSection("DNS") { if let dnsError = snapshot.dnsError { lines.append(" Error: \(dnsError)") @@ -2544,6 +2733,13 @@ final class DomainViewModel { return formatter }() + private static let ownershipDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter + }() + private static let csvDateFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] @@ -2554,6 +2750,14 @@ final class DomainViewModel { let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" } + + private static func isInterestingSubdomain(_ hostname: String) -> Bool { + let keywords = ["admin", "api", "dev", "staging", "test", "internal"] + let labels = hostname.lowercased().split(separator: ".").map(String.init) + return labels.contains { label in + keywords.contains(where: { label.contains($0) }) + } + } } private extension String { @@ -2565,3 +2769,9 @@ private extension String { isEmpty ? nil : self } } + +private extension Array where Element == String { + var nilIfEmpty: [String]? { + isEmpty ? nil : self + } +} diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 35ec723..6f3d1a9 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -147,6 +147,20 @@ struct HistoryDetailView: View { onEditNote: nil ) .padding(.top, 16) + OwnershipSectionView( + rows: DomainViewModel.ownershipRows(from: snapshot), + loading: false, + error: snapshot.ownershipError, + showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory) + ) + .padding(.top, 16) + SubdomainsSectionView( + rows: DomainViewModel.subdomainRows(from: snapshot), + loading: false, + error: snapshot.subdomainsError, + showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains) + ) + .padding(.top, 16) if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) { if let changeSummary = entry.changeSummary { DomainChangeSummaryView(summary: changeSummary) diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 2e0a5c2..b1e5432 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -184,6 +184,13 @@ struct BatchSweepSummary: Identifiable, Equatable { let generatedAt: Date } +enum DataCapability: String, Codable { + case ownershipHistory + case dnsHistory + case extendedSubdomains + case domainPricing +} + enum HistoryDateFilter: String, CaseIterable, Identifiable { case today case last7Days @@ -501,6 +508,48 @@ struct IPGeolocation: Codable { let longitude: Double? } +// MARK: - Ownership Models + +struct DomainOwnership: Codable, Equatable { + let registrar: String? + let createdDate: Date? + let expirationDate: Date? + let status: [String] + let nameservers: [String] + let abuseEmail: String? + + init( + registrar: String? = nil, + createdDate: Date? = nil, + expirationDate: Date? = nil, + status: [String] = [], + nameservers: [String] = [], + abuseEmail: String? = nil + ) { + self.registrar = registrar + self.createdDate = createdDate + self.expirationDate = expirationDate + self.status = status + self.nameservers = nameservers + self.abuseEmail = abuseEmail + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + registrar = try container.decodeIfPresent(String.self, forKey: .registrar) + createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate) + expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) + status = try container.decodeIfPresent([String].self, forKey: .status) ?? [] + nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? [] + abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail) + } +} + +struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable { + var id: String { hostname } + let hostname: String +} + // MARK: - Email Security Models struct EmailSecurityResult: Codable { @@ -627,8 +676,10 @@ struct HistoryEntry: Identifiable, Codable { let ipGeolocation: IPGeolocation? var emailSecurity: EmailSecurityResult? var mtaSts: MTASTSResult? + var ownership: DomainOwnership? var ptrRecord: String? var redirectChain: [RedirectHop] + var subdomains: [DiscoveredSubdomain] var portScanResults: [PortScanResult] var hstsPreloaded: Bool? var availabilityResult: DomainAvailabilityResult? @@ -647,23 +698,26 @@ struct HistoryEntry: Identifiable, Codable { var reachabilityError: String? var ipGeolocationError: String? var emailSecurityError: String? + var ownershipError: String? var ptrError: String? var redirectChainError: String? + var subdomainsError: String? var portScanError: String? init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection], sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, - emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, - redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], + emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil, + ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], + portScanResults: [PortScanResult] = [], hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String, totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil, tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil, changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil, reachabilityError: String? = nil, ipGeolocationError: String? = nil, - emailSecurityError: String? = nil, ptrError: String? = nil, - redirectChainError: String? = nil, portScanError: String? = nil) { + emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil, + redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) { self.domain = domain self.timestamp = timestamp self.trackedDomainID = trackedDomainID @@ -674,8 +728,10 @@ struct HistoryEntry: Identifiable, Codable { self.ipGeolocation = ipGeolocation self.emailSecurity = emailSecurity self.mtaSts = mtaSts ?? emailSecurity?.mtaSts + self.ownership = ownership self.ptrRecord = ptrRecord self.redirectChain = redirectChain + self.subdomains = subdomains self.portScanResults = portScanResults self.hstsPreloaded = hstsPreloaded self.availabilityResult = availabilityResult @@ -694,8 +750,10 @@ struct HistoryEntry: Identifiable, Codable { self.reachabilityError = reachabilityError self.ipGeolocationError = ipGeolocationError self.emailSecurityError = emailSecurityError + self.ownershipError = ownershipError self.ptrError = ptrError self.redirectChainError = redirectChainError + self.subdomainsError = subdomainsError self.portScanError = portScanError } @@ -712,8 +770,10 @@ struct HistoryEntry: Identifiable, Codable { ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts + ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership) ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] + subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? [] portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) @@ -732,8 +792,10 @@ struct HistoryEntry: Identifiable, Codable { reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError) emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError) + ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError) ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError) redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError) + subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError) portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) } } diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift new file mode 100644 index 0000000..c38a140 --- /dev/null +++ b/DomainDig/RDAPService.swift @@ -0,0 +1,288 @@ +import Foundation + +enum RDAPService { + static func registrationStatus(for domain: String) async -> DomainAvailabilityStatus? { + let normalizedDomain = normalize(domain) + guard !normalizedDomain.isEmpty else { return nil } + + switch await cache.response(for: normalizedDomain) { + case let .success(response): + return response.isDomainRecord ? .registered : nil + case .empty: + return nil + case .error: + return nil + } + } + + static func ownership(for domain: String) async -> ServiceResult<DomainOwnership> { + let normalizedDomain = normalize(domain) + guard !normalizedDomain.isEmpty else { + return .empty("Unavailable") + } + + switch await cache.response(for: normalizedDomain) { + case let .success(response): + let ownership = DomainOwnership( + registrar: response.registrarName, + createdDate: response.createdDate, + expirationDate: response.expirationDate, + status: response.status, + nameservers: response.nameservers, + abuseEmail: response.abuseEmail + ) + return .success(ownership) + case let .empty(message): + return .empty(message) + case let .error(message): + return .error(message) + } + } + + private static let cache = RDAPCache() + + private static func normalize(_ domain: String) -> String { + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } +} + +private actor RDAPCache { + private var cachedResponses: [String: ServiceResult<RDAPDomainResponse>] = [:] + private var inFlightTasks: [String: Task<ServiceResult<RDAPDomainResponse>, Never>] = [:] + + func response(for domain: String) async -> ServiceResult<RDAPDomainResponse> { + if let cachedResponse = cachedResponses[domain] { + return cachedResponse + } + + if let inFlightTask = inFlightTasks[domain] { + return await inFlightTask.value + } + + let task = Task<ServiceResult<RDAPDomainResponse>, Never> { + await fetchRDAPResponse(for: domain) + } + inFlightTasks[domain] = task + + let result = await task.value + cachedResponses[domain] = result + inFlightTasks[domain] = nil + return result + } +} + +private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { + guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { + return .error("Unavailable") + } + + do { + var request = URLRequest(url: url, timeoutInterval: 8) + request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return .error(URLError(.badServerResponse).localizedDescription) + } + + switch httpResponse.statusCode { + case 200: + let decoder = JSONDecoder() + let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data) + guard rdapResponse.isDomainRecord else { + return .empty("Unavailable") + } + return .success(rdapResponse) + case 404: + return .empty("Unavailable") + default: + return .error("Unavailable") + } + } catch { + return .error(error.localizedDescription) + } +} + +private struct RDAPDomainResponse: Decodable, Sendable { + let ldhName: String? + let objectClassName: String? + let unicodeName: String? + let handle: String? + let rawStatus: [String]? + let rawNameservers: [RDAPNameserver]? + let events: [RDAPEvent]? + let entities: [RDAPEntity]? + + enum CodingKeys: String, CodingKey { + case ldhName + case objectClassName + case unicodeName + case handle + case rawStatus = "status" + case rawNameservers = "nameservers" + case events + case entities + } + + var isDomainRecord: Bool { + if ldhName?.isEmpty == false { + return true + } + if objectClassName == "domain" { + return true + } + return handle != nil && unicodeName != nil + } + + var registrarName: String? { + entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName + } + + var createdDate: Date? { + eventDate(for: ["registration", "registered"]) + } + + var expirationDate: Date? { + eventDate(for: ["expiration", "expiry", "expired"]) + } + + var abuseEmail: String? { + entities?.first(where: { $0.roles.contains("abuse") })?.email + ?? entities?.first(where: { $0.roles.contains("registrar") })?.abuseEntity?.email + } + + var nameservers: [String] { + let rawValues = rawNameservers?.compactMap { $0.ldhName ?? $0.unicodeName } ?? [] + return deduplicated(rawValues) + } + + var status: [String] { + deduplicated(rawStatus ?? []) + } + + private func eventDate(for actions: [String]) -> Date? { + let normalizedActions = Set(actions) + return events? + .first(where: { normalizedActions.contains($0.eventAction.lowercased()) })? + .parsedDate + } + + private func deduplicated(_ values: [String]) -> [String] { + var seen = Set<String>() + return values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .filter { seen.insert($0.lowercased()).inserted } + } +} + +private struct RDAPNameserver: Decodable, Sendable { + let ldhName: String? + let unicodeName: String? +} + +private struct RDAPEvent: Decodable, Sendable { + let eventAction: String + let eventDate: String + + var parsedDate: Date? { + RDAPDateParser.parse(eventDate) + } +} + +private struct RDAPEntity: Decodable, Sendable { + let roles: [String] + let vcardArray: RDAPVCardArray? + let entities: [RDAPEntity]? + + var bestDisplayName: String? { + vcardArray?.fullName ?? vcardArray?.organization ?? vcardArray?.email + } + + var email: String? { + vcardArray?.email + } + + var abuseEntity: RDAPEntity? { + entities?.first(where: { $0.roles.contains("abuse") }) + } +} + +private struct RDAPVCardArray: Decodable, Sendable { + let values: [[RDAPJSONValue]] + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + _ = try container.decode(String.self) + values = try container.decode([[RDAPJSONValue]].self) + } + + var fullName: String? { + value(for: "fn") + } + + var organization: String? { + value(for: "org") + } + + var email: String? { + value(for: "email") + } + + private func value(for key: String) -> String? { + values.first(where: { $0.first?.stringValue?.lowercased() == key })?.last?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private enum RDAPJSONValue: Decodable, Sendable { + case string(String) + case bool(Bool) + case number(Double) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else { + self = .null + } + } + + var stringValue: String? { + switch self { + case let .string(value): + return value + case let .bool(value): + return value ? "true" : "false" + case let .number(value): + return String(value) + case .null: + return nil + } + } +} + +private enum RDAPDateParser { + private static let iso8601WithFractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + static func parse(_ value: String) -> Date? { + iso8601WithFractional.date(from: value) ?? iso8601.date(from: value) + } +} diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift new file mode 100644 index 0000000..57db25d --- /dev/null +++ b/DomainDig/SubdomainDiscoveryService.swift @@ -0,0 +1,118 @@ +import Foundation + +enum SubdomainDiscoveryService { + static func discover(for domain: String, limit: Int = 25) async -> ServiceResult<[DiscoveredSubdomain]> { + let normalizedDomain = normalize(domain) + guard !normalizedDomain.isEmpty else { + return .empty("No passive subdomains found") + } + + return await cache.subdomains(for: normalizedDomain, limit: limit) + } + + private static let cache = SubdomainDiscoveryCache() + + private static func normalize(_ domain: String) -> String { + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } +} + +private actor SubdomainDiscoveryCache { + private var cachedResults: [String: ServiceResult<[DiscoveredSubdomain]>] = [:] + private var inFlightTasks: [String: Task<ServiceResult<[DiscoveredSubdomain]>, Never>] = [:] + private var lastRequestAt: Date? + + func subdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { + if let cachedResult = cachedResults[domain] { + return cachedResult + } + + if let inFlightTask = inFlightTasks[domain] { + return await inFlightTask.value + } + + let task = Task<ServiceResult<[DiscoveredSubdomain]>, Never> { + await enforceRateLimit() + return await fetchSubdomains(for: domain, limit: limit) + } + inFlightTasks[domain] = task + + let result = await task.value + cachedResults[domain] = result + inFlightTasks[domain] = nil + return result + } + + private func enforceRateLimit() async { + if let lastRequestAt { + let delay = max(0, 0.75 - Date().timeIntervalSince(lastRequestAt)) + if delay > 0 { + try? await Task.sleep(for: .seconds(delay)) + } + } + lastRequestAt = Date() + } + + private func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { + var components = URLComponents(string: "https://crt.sh/")! + components.queryItems = [ + URLQueryItem(name: "q", value: "%.\(domain)"), + URLQueryItem(name: "output", value: "json") + ] + + guard let url = components.url else { + return .error("Subdomain discovery unavailable") + } + + do { + let request = URLRequest(url: url, timeoutInterval: 10) + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + return .error("Subdomain discovery unavailable") + } + + let entries = try JSONDecoder().decode([CRTShEntry].self, from: data) + let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit) + return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains) + } catch { + return .error(error.localizedDescription) + } + } + + private func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] { + var seen = Set<String>() + var results: [DiscoveredSubdomain] = [] + + for entry in entries { + let names = entry.nameValue + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + + for name in names { + let sanitized = name.hasPrefix("*.") ? String(name.dropFirst(2)) : name + guard sanitized != domain, sanitized.hasSuffix(".\(domain)") else { + continue + } + guard seen.insert(sanitized).inserted else { + continue + } + results.append(DiscoveredSubdomain(hostname: sanitized)) + if results.count == limit { + return results + } + } + } + + return results + } +} + +private struct CRTShEntry: Decodable { + let nameValue: String + + enum CodingKeys: String, CodingKey { + case nameValue = "name_value" + } +} |
