summaryrefslogtreecommitdiff
path: root/DomainDig/DomainViewModel.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/DomainViewModel.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/DomainViewModel.swift')
-rw-r--r--DomainDig/DomainViewModel.swift51
1 files changed, 51 insertions, 0 deletions
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 {