krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.2: DomainDig/SubdomainDiscoveryService.swift · raw
1import Foundation
2
3enum SubdomainDiscoveryService {
4 static func discover(for domain: String, limit: Int = 25) async -> ServiceResult<[DiscoveredSubdomain]> {
5 let normalizedDomain = normalize(domain)
6 guard !normalizedDomain.isEmpty else {
7 return .empty("No passive subdomains found")
8 }
9
10 return await fetchSubdomains(for: normalizedDomain, limit: limit)
11 }
12
13 private static func normalize(_ domain: String) -> String {
14 domain
15 .trimmingCharacters(in: .whitespacesAndNewlines)
16 .lowercased()
17 }
18
19 private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
20 let startedAt = DomainDebugLog.signpostStart("SubdomainDiscovery.fetch", domain: domain)
21 var components = URLComponents(string: "https://crt.sh/")!
22 components.queryItems = [
23 URLQueryItem(name: "q", value: "%.\(domain)"),
24 URLQueryItem(name: "output", value: "json")
25 ]
26
27 guard let url = components.url else {
28 return .error("Subdomain discovery unavailable")
29 }
30
31 do {
32 let request = URLRequest(url: url, timeoutInterval: 10)
33 DomainDebugLog.debug("SubdomainDiscovery.request url=\(url.absoluteString) timeout=10")
34 let (data, response) = try await URLSession.shared.data(for: request)
35 guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
36 DomainDebugLog.error("SubdomainDiscovery.badResponse domain=\(domain)")
37 return .error("Subdomain discovery unavailable")
38 }
39
40 let entries = try JSONDecoder().decode([CRTShEntry].self, from: data)
41 let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit)
42 DomainDebugLog.signpostEnd(
43 "SubdomainDiscovery.fetch",
44 start: startedAt,
45 domain: domain,
46 extra: "entries=\(entries.count) subdomains=\(subdomains.count)"
47 )
48 return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains)
49 } catch {
50 DomainDebugLog.error("SubdomainDiscovery.error domain=\(domain) error=\(error.localizedDescription)")
51 DomainDebugLog.signpostEnd("SubdomainDiscovery.fetch", start: startedAt, domain: domain, extra: "error")
52 return .error(error.localizedDescription)
53 }
54 }
55
56 private static func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] {
57 var seen = Set<String>()
58 var results: [DiscoveredSubdomain] = []
59
60 for entry in entries {
61 let names = entry.nameValue
62 .split(separator: "\n")
63 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
64
65 for name in names {
66 let sanitized = name.hasPrefix("*.") ? String(name.dropFirst(2)) : name
67 guard sanitized != domain, sanitized.hasSuffix(".\(domain)") else {
68 continue
69 }
70 guard seen.insert(sanitized).inserted else {
71 continue
72 }
73 results.append(DiscoveredSubdomain(hostname: sanitized, source: "crt.sh"))
74 if results.count == limit {
75 return results
76 }
77 }
78 }
79
80 return results
81 }
82}
83
84private struct CRTShEntry: Decodable {
85 let nameValue: String
86
87 enum CodingKeys: String, CodingKey {
88 case nameValue = "name_value"
89 }
90}