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}