From 6e273c2676ce29cef057d117e4427e031886e743 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 17 Jul 2026 10:39:17 -0500 Subject: 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. --- DomainDig/ExternalDataService.swift | 67 ++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) (limited to 'DomainDig/ExternalDataService.swift') 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) + case reputation(ServiceResult) } 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> { + 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( 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 { + 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 { 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?, -- cgit v1.2.3