summaryrefslogtreecommitdiff
path: root/DomainDig/SubdomainDiscoveryService.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-21 22:08:09 -0500
committerChristian Cleberg <[email protected]>2026-04-21 22:08:09 -0500
commit22ecca12b4fe0e0850401754c674632f322d919d (patch)
tree52845c3020c2471d607e7a1ad9681de83cbcbb85 /DomainDig/SubdomainDiscoveryService.swift
parent1f58092dcb67cded75a850db364f3b46d86181f3 (diff)
downloaddomain-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/SubdomainDiscoveryService.swift')
-rw-r--r--DomainDig/SubdomainDiscoveryService.swift118
1 files changed, 118 insertions, 0 deletions
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"
+ }
+}