summaryrefslogtreecommitdiff
path: root/DomainDig/ExternalDataService.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-17 10:39:17 -0500
committerChristian Cleberg <[email protected]>2026-07-17 10:57:30 -0500
commit6e273c2676ce29cef057d117e4427e031886e743 (patch)
treec16273cd4b3e022f2322cb9061b8e735542eea55 /DomainDig/ExternalDataService.swift
parentd62480233819184db2e55741b05375818ebf3881 (diff)
downloaddomain-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.swift67
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?,