summaryrefslogtreecommitdiff
path: root/DomainDig
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
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')
-rw-r--r--DomainDig/DataAccessService.swift2
-rw-r--r--DomainDig/DomainInsightEngine.swift16
-rw-r--r--DomainDig/DomainMonitoringService.swift4
-rw-r--r--DomainDig/DomainViewModel.swift51
-rw-r--r--DomainDig/ExternalDataService.swift67
-rw-r--r--DomainDig/FeatureAccessService.swift7
-rw-r--r--DomainDig/Models.swift33
7 files changed, 176 insertions, 4 deletions
diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift
index 98e215d..43b664e 100644
--- a/DomainDig/DataAccessService.swift
+++ b/DomainDig/DataAccessService.swift
@@ -11,6 +11,8 @@ enum DataAccessService {
return FeatureAccessService.hasAccess(to: .extendedSubdomains)
case .domainPricing:
return FeatureAccessService.hasAccess(to: .domainPricing)
+ case .reputation:
+ return FeatureAccessService.hasAccess(to: .reputation)
}
}
}
diff --git a/DomainDig/DomainInsightEngine.swift b/DomainDig/DomainInsightEngine.swift
index 2547727..f1de9c0 100644
--- a/DomainDig/DomainInsightEngine.swift
+++ b/DomainDig/DomainInsightEngine.swift
@@ -303,6 +303,19 @@ enum DomainInsightEngine {
}
}
+ if let reputation = snapshot.reputation {
+ switch reputation.status {
+ case .listed:
+ score += 25
+ let sourceList = reputation.listedSources.isEmpty ? "" : " (\(reputation.listedSources.joined(separator: ", ")))"
+ factors.append(.init(description: "Domain is flagged by a configured reputation source\(sourceList)", impact: .negative))
+ case .clean:
+ factors.append(.init(description: "Domain is clean against the configured reputation source", impact: .positive))
+ case .unknown:
+ break
+ }
+ }
+
let clampedScore = min(max(score, 0), 100)
let level: RiskLevel
switch clampedScore {
@@ -327,6 +340,9 @@ enum DomainInsightEngine {
) -> [String] {
var items: [String] = []
+ if snapshot.reputation?.status == .listed {
+ items.append("Domain is flagged by a configured reputation source")
+ }
if let group = subdomainGroups.first(where: { $0.label == "staging" || $0.label == "dev" }) {
items.append("Multiple \(group.label) subdomains suggest non-production environments are exposed")
}
diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
index 3229fb7..6fb5fc9 100644
--- a/DomainDig/DomainMonitoringService.swift
+++ b/DomainDig/DomainMonitoringService.swift
@@ -516,6 +516,7 @@ final class DomainMonitoringService {
extendedSubdomains: snapshot.extendedSubdomains,
dnsHistory: snapshot.dnsHistory,
domainPricing: snapshot.domainPricing,
+ reputation: snapshot.reputation,
portScanResults: snapshot.portScanResults,
hstsPreloaded: snapshot.hstsPreloaded,
availabilityResult: snapshot.availabilityResult,
@@ -554,6 +555,7 @@ final class DomainMonitoringService {
extendedSubdomainsError: snapshot.extendedSubdomainsError,
dnsHistoryError: snapshot.dnsHistoryError,
domainPricingError: snapshot.domainPricingError,
+ reputationError: snapshot.reputationError,
portScanError: snapshot.portScanError
)
@@ -930,6 +932,8 @@ final class DomainMonitoringService {
dnsHistoryError: previousSnapshot.dnsHistoryError,
domainPricing: previousSnapshot.domainPricing,
domainPricingError: previousSnapshot.domainPricingError,
+ reputation: previousSnapshot.reputation,
+ reputationError: previousSnapshot.reputationError,
portScanResults: previousSnapshot.portScanResults,
portScanError: previousSnapshot.portScanError,
changeSummary: previousSnapshot.changeSummary,
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index dcae907..3c92ff6 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -232,6 +232,9 @@ final class DomainViewModel {
var domainPricing: DomainPricingInsight?
var domainPricingLoading = false
var domainPricingError: String?
+ var reputation: DomainReputationResult?
+ var reputationLoading = false
+ var reputationError: String?
var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits()
var portScanResults: [PortScanResult] = []
@@ -642,6 +645,8 @@ final class DomainViewModel {
dnsHistoryError: dnsHistoryError,
domainPricing: domainPricing,
domainPricingError: domainPricingError,
+ reputation: reputation,
+ reputationError: reputationError,
portScanResults: allPortScanResults,
portScanError: combinedPortScanError,
changeSummary: currentChangeSummary,
@@ -1883,6 +1888,11 @@ final class DomainViewModel {
await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
}
+ if DataAccessService.hasAccess(to: .reputation), reputation == nil {
+ DomainDebugLog.debug("DomainViewModel.performLookup loadingReputation domain=\(domain)")
+ await refreshReputation(for: snapshot.domain, persistAfterFetch: false)
+ }
+
guard snapshot.statusMessage == nil else {
return history.first(where: { $0.id == snapshot.historyEntryID })
}
@@ -2053,6 +2063,8 @@ final class DomainViewModel {
dnsHistoryError: previousSnapshot.dnsHistoryError,
domainPricing: previousSnapshot.domainPricing,
domainPricingError: previousSnapshot.domainPricingError,
+ reputation: previousSnapshot.reputation,
+ reputationError: previousSnapshot.reputationError,
portScanResults: previousSnapshot.portScanResults,
portScanError: previousSnapshot.portScanError,
changeSummary: previousSnapshot.changeSummary,
@@ -2474,6 +2486,7 @@ final class DomainViewModel {
extendedSubdomains: snapshot.extendedSubdomains,
dnsHistory: snapshot.dnsHistory,
domainPricing: snapshot.domainPricing,
+ reputation: snapshot.reputation,
portScanResults: snapshot.portScanResults,
hstsPreloaded: snapshot.hstsPreloaded,
availabilityResult: snapshot.availabilityResult,
@@ -2516,6 +2529,7 @@ final class DomainViewModel {
extendedSubdomainsError: snapshot.extendedSubdomainsError,
dnsHistoryError: snapshot.dnsHistoryError,
domainPricingError: snapshot.domainPricingError,
+ reputationError: snapshot.reputationError,
portScanError: snapshot.portScanError
)
@@ -3844,6 +3858,8 @@ final class DomainViewModel {
dnsHistoryError: nil,
domainPricing: nil,
domainPricingError: nil,
+ reputation: nil,
+ reputationError: nil,
portScanResults: [],
portScanError: nil,
changeSummary: trackedDomain.lastChangeSummary,
@@ -4005,6 +4021,18 @@ final class DomainViewModel {
rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary))
}
}
+ if let reputation = snapshot.reputation {
+ let tone: ResultTone
+ switch reputation.status {
+ case .clean: tone = .success
+ case .listed: tone = .failure
+ case .unknown: tone = .secondary
+ }
+ let value = reputation.status == .listed && !reputation.listedSources.isEmpty
+ ? "\(reputation.status.title) (\(reputation.listedSources.joined(separator: ", ")))"
+ : reputation.status.title
+ rows.append(InfoRowViewData(label: "Reputation", value: value, tone: tone))
+ }
if let certificateStatus = certificateBadgeLabel(from: snapshot) {
rows.insert(
InfoRowViewData(
@@ -4700,6 +4728,29 @@ final class DomainViewModel {
}
}
+ private func refreshReputation(for domain: String, persistAfterFetch: Bool) async {
+ reputationLoading = true
+ let outcome = await ExternalDataService.shared.reputation(domain: domain)
+
+ switch outcome.value {
+ case let .success(result):
+ reputation = result
+ reputationError = nil
+ case let .empty(message):
+ reputation = nil
+ reputationError = conciseExternalMessage(message, fallback: "Reputation check unavailable")
+ case let .error(message):
+ reputation = nil
+ reputationError = conciseExternalMessage(message, fallback: "Reputation check unavailable")
+ }
+
+ reputationLoading = false
+
+ if persistAfterFetch {
+ _ = saveHistoryEntry(replaceLatest: true)
+ }
+ }
+
private func conciseExternalMessage(_ message: String, fallback: String) -> String {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
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?,
diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift
index a601b33..1598354 100644
--- a/DomainDig/FeatureAccessService.swift
+++ b/DomainDig/FeatureAccessService.swift
@@ -32,6 +32,7 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
case dnsHistory
case extendedSubdomains
case domainPricing
+ case reputation
var id: String { rawValue }
@@ -61,6 +62,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
return "Extended subdomains"
case .domainPricing:
return "Domain pricing"
+ case .reputation:
+ return "Domain reputation"
}
}
}
@@ -157,7 +160,7 @@ enum FeatureAccessService {
switch capability {
case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports:
return "Available in Pro"
- case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
+ case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing, .reputation:
return "Available in Pro+"
case .limitedTracking:
return "Tracking is limited on Free"
@@ -219,7 +222,7 @@ enum FeatureAccessService {
static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
let title: String
switch capability {
- case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing:
+ case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing, .reputation:
title = "Available in Pro+"
default:
title = "Available in Pro"
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 376d264..d14ff4a 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -391,6 +391,7 @@ enum DataCapability: String, Codable {
case dnsHistory
case extendedSubdomains
case domainPricing
+ case reputation
}
enum UsageCreditFeature: String, Codable, CaseIterable, Identifiable, Sendable {
@@ -724,6 +725,29 @@ struct DomainPricingInsight: Codable, Equatable, Sendable {
let collectedAt: Date
}
+enum DomainReputationStatus: String, Codable, Sendable {
+ case clean
+ case listed
+ case unknown
+
+ var title: String {
+ switch self {
+ case .clean:
+ return "Clean"
+ case .listed:
+ return "Listed"
+ case .unknown:
+ return "Unknown"
+ }
+ }
+}
+
+struct DomainReputationResult: Codable, Equatable, Sendable {
+ let status: DomainReputationStatus
+ let listedSources: [String]
+ let checkedAt: Date
+}
+
enum HistoryDateFilter: String, CaseIterable, Identifiable {
case today
case last7Days
@@ -2285,6 +2309,7 @@ struct HistoryEntry: Identifiable, Codable {
var extendedSubdomains: [DiscoveredSubdomain]
var dnsHistory: [DNSHistoryEvent]
var domainPricing: DomainPricingInsight?
+ var reputation: DomainReputationResult?
var portScanResults: [PortScanResult]
var hstsPreloaded: Bool?
var availabilityResult: DomainAvailabilityResult?
@@ -2327,6 +2352,7 @@ struct HistoryEntry: Identifiable, Codable {
var extendedSubdomainsError: String?
var dnsHistoryError: String?
var domainPricingError: String?
+ var reputationError: String?
var portScanError: String?
init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
@@ -2344,6 +2370,7 @@ struct HistoryEntry: Identifiable, Codable {
ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
domainPricing: DomainPricingInsight? = nil,
+ reputation: DomainReputationResult? = nil,
portScanResults: [PortScanResult] = [],
hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
@@ -2362,7 +2389,7 @@ struct HistoryEntry: Identifiable, Codable {
emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
- domainPricingError: String? = nil, portScanError: String? = nil) {
+ domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) {
self.domain = domain
self.timestamp = timestamp
self.trackedDomainID = trackedDomainID
@@ -2390,6 +2417,7 @@ struct HistoryEntry: Identifiable, Codable {
self.extendedSubdomains = extendedSubdomains
self.dnsHistory = dnsHistory
self.domainPricing = domainPricing
+ self.reputation = reputation
self.portScanResults = portScanResults
self.hstsPreloaded = hstsPreloaded
self.availabilityResult = availabilityResult
@@ -2432,6 +2460,7 @@ struct HistoryEntry: Identifiable, Codable {
self.extendedSubdomainsError = extendedSubdomainsError
self.dnsHistoryError = dnsHistoryError
self.domainPricingError = domainPricingError
+ self.reputationError = reputationError
self.portScanError = portScanError
}
@@ -2465,6 +2494,7 @@ struct HistoryEntry: Identifiable, Codable {
extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
+ reputation = try container.decodeIfPresent(DomainReputationResult.self, forKey: .reputation)
portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -2510,6 +2540,7 @@ struct HistoryEntry: Identifiable, Codable {
extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
+ reputationError = try container.decodeIfPresent(String.self, forKey: .reputationError)
portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
}