diff options
| author | Christian Cleberg <[email protected]> | 2026-07-17 10:39:17 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-17 10:57:30 -0500 |
| commit | 6e273c2676ce29cef057d117e4427e031886e743 (patch) | |
| tree | c16273cd4b3e022f2322cb9061b8e735542eea55 /DomainDig/ExternalDataService.swift | |
| parent | d62480233819184db2e55741b05375818ebf3881 (diff) | |
| download | domain-dig-6e273c2676ce29cef057d117e4427e031886e743.tar.gz domain-dig-6e273c2676ce29cef057d117e4427e031886e743.tar.bz2 domain-dig-6e273c2676ce29cef057d117e4427e031886e743.zip | |
v4.7.0: Add domain reputation/blocklist data source
- New DomainReputationResult model (status: clean/listed/unknown, listed
sources, checked-at) and a `reputation(domain:)` method on ExternalDataService,
mirroring the existing pluggable-URL enrichment pattern (ownership history,
DNS history, extended subdomains, pricing). With no endpoint configured
(the default; DomainDig ships no bundled third-party reputation dependency)
it resolves to unavailable rather than "clean".
- New .reputation FeatureCapability/DataCapability, gated Pro+ like domainPricing.
- Threaded reputation/reputationError through LookupSnapshot and HistoryEntry
(backward-compatible decode) so results persist with history entries.
- Auto-fetched in performLookup alongside pricing; surfaced as a "Reputation"
info row, folded into DomainInsightEngine's risk score/factors and top-level
insights (a listed domain raises risk score and adds a factor/insight), and
exported in text, CSV, and JSON report output.
- Reputation-driven risk changes ride the existing change-severity pipeline, so
a listed status flip is visible to monitoring the same way any other risk
delta is, without bespoke monitoring wiring.
Diffstat (limited to 'DomainDig/ExternalDataService.swift')
| -rw-r--r-- | DomainDig/ExternalDataService.swift | 67 |
1 files changed, 66 insertions, 1 deletions
diff --git a/DomainDig/ExternalDataService.swift b/DomainDig/ExternalDataService.swift index 65f117e..43cc489 100644 --- a/DomainDig/ExternalDataService.swift +++ b/DomainDig/ExternalDataService.swift @@ -8,12 +8,14 @@ actor ExternalDataService { case dnsHistory(String) case extendedSubdomains(String) case pricing(String) + case reputation(String) } private enum RateLimitBucket: Hashable { case history case subdomains case pricing + case reputation var minimumSpacing: TimeInterval { switch self { @@ -23,6 +25,8 @@ actor ExternalDataService { return 1.5 case .pricing: return 1.0 + case .reputation: + return 1.0 } } } @@ -32,6 +36,7 @@ actor ExternalDataService { case dnsHistory(ServiceResult<[DNSHistoryEvent]>) case extendedSubdomains(ServiceResult<[DiscoveredSubdomain]>) case pricing(ServiceResult<DomainPricingInsight>) + case reputation(ServiceResult<DomainReputationResult>) } private struct CacheEntry { @@ -44,6 +49,7 @@ actor ExternalDataService { let dnsHistoryURL: String? let extendedSubdomainsURL: String? let pricingURL: String? + let reputationURL: String? } private let ttl: TimeInterval = 900 @@ -161,6 +167,25 @@ actor ExternalDataService { ) } + /// Checks a domain against a configured blocklist/reputation endpoint. With + /// no endpoint configured (the default, since DomainDig ships no bundled + /// third-party reputation dependency) this resolves to `.empty` and callers + /// treat the result as unavailable rather than "clean". + func reputation(domain: String) async -> CachedLookupResult<ServiceResult<DomainReputationResult>> { + let normalizedDomain = Self.normalize(domain) + return await execute( + key: .reputation(normalizedDomain), + rateLimitBucket: .reputation, + extract: { payload in + guard case let .reputation(result) = payload else { return nil } + return result + }, + operation: { [configuration = configuration()] in + .reputation(await self.fetchReputation(domain: normalizedDomain, configuration: configuration)) + } + ) + } + private func execute<T>( key: RequestKey, rateLimitBucket: RateLimitBucket, @@ -213,7 +238,9 @@ actor ExternalDataService { extendedSubdomainsURL: defaults.string(forKey: "externalData.extendedSubdomainsURL") ?? Bundle.main.object(forInfoDictionaryKey: "ExternalExtendedSubdomainsURL") as? String, pricingURL: defaults.string(forKey: "externalData.pricingURL") - ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String, + reputationURL: defaults.string(forKey: "externalData.reputationURL") + ?? Bundle.main.object(forInfoDictionaryKey: "ExternalReputationURL") as? String ) } @@ -290,6 +317,28 @@ actor ExternalDataService { } } + private func fetchReputation( + domain: String, + configuration: Configuration + ) async -> ServiceResult<DomainReputationResult> { + guard let template = configuration.reputationURL, + let url = Self.url(from: template, domain: domain) else { + return .empty("No reputation source configured") + } + + switch await requestData(url: url) { + case let .success(data): + guard let reputation = parseReputation(from: data) else { + return .error("Invalid external response") + } + return .success(reputation) + case let .empty(message): + return .empty(message) + case let .error(message): + return .error(message) + } + } + private func requestData(url: URL) async -> ServiceResult<Data> { for attempt in 0..<2 { do { @@ -403,6 +452,22 @@ actor ExternalDataService { ) } + private func parseReputation(from data: Data) -> DomainReputationResult? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + let listedSources = json["listed_sources"] as? [String] ?? [] + let status: DomainReputationStatus + if let statusString = json["status"] as? String, let parsed = DomainReputationStatus(rawValue: statusString) { + status = parsed + } else { + status = listedSources.isEmpty ? .clean : .listed + } + + return DomainReputationResult(status: status, listedSources: listedSources, checkedAt: Date()) + } + private static func localOwnershipHistory( domain: String, currentOwnership: DomainOwnership?, |
