summaryrefslogtreecommitdiff
path: root/DomainDig/SubdomainDiscoveryService.swift
blob: 4e25cf03ee40f3135bca5d3e06ff6bbd498719cf (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
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 fetchSubdomains(for: normalizedDomain, limit: limit)
    }

    private static func normalize(_ domain: String) -> String {
        domain
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .lowercased()
    }

    private static 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 static 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"
    }
}