summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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
-rw-r--r--DomainInspectionService.swift2
-rw-r--r--DomainReportBuilder.swift2
-rw-r--r--DomainReportExporter.swift9
-rw-r--r--LookupSnapshot.swift4
11 files changed, 192 insertions, 5 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)
}
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift
index 0562195..3cdb21c 100644
--- a/DomainInspectionService.swift
+++ b/DomainInspectionService.swift
@@ -369,6 +369,8 @@ struct DomainInspectionService {
dnsHistoryError: nil,
domainPricing: nil,
domainPricingError: nil,
+ reputation: nil,
+ reputationError: nil,
portScanResults: portScanResults.value,
portScanError: portScanResults.message,
changeSummary: nil,
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index 50c46db..4068c0a 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -38,6 +38,7 @@ struct DomainReport: Codable {
let extendedSubdomains: [String]
let dnsHistory: [DNSHistoryEvent]
let domainPricing: DomainPricingInsight?
+ let reputation: DomainReputationResult?
let subdomainGroups: [SubdomainGroup]
let riskAssessment: DomainRiskAssessment
let insights: [String]
@@ -360,6 +361,7 @@ struct DomainReportBuilder {
extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname),
dnsHistory: snapshot.dnsHistory,
domainPricing: snapshot.domainPricing,
+ reputation: snapshot.reputation,
subdomainGroups: analysis.subdomainGroups,
riskAssessment: analysis.riskAssessment,
insights: analysis.insights,
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
index 55b7372..05ecff9 100644
--- a/DomainReportExporter.swift
+++ b/DomainReportExporter.swift
@@ -69,7 +69,8 @@ enum DomainReportExporter {
"Email: \(report.email.summary)",
"Subdomains: \(report.subdomains.count)",
"Extended Subdomains: \(report.extendedSubdomains.count)",
- "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")"
+ "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")",
+ "Reputation: \(report.reputation?.status.title ?? "Unavailable")"
]
}
@@ -406,6 +407,8 @@ enum DomainReportExporter {
"pricing_resale_signal",
"pricing_auction_signal",
"pricing_source",
+ "reputation_status",
+ "reputation_listed_sources",
"open_ports",
"reachability_summary",
"geolocation_summary",
@@ -438,6 +441,8 @@ enum DomainReportExporter {
let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
+ let reputationStatus = report.reputation?.status.rawValue ?? ""
+ let reputationListedSources = report.reputation?.listedSources.joined(separator: " | ") ?? ""
return [
report.domain,
@@ -483,6 +488,8 @@ enum DomainReportExporter {
report.domainPricing?.resaleSignal ?? "",
report.domainPricing?.auctionSignal ?? "",
report.domainPricing?.source ?? "",
+ reputationStatus,
+ reputationListedSources,
openPorts,
report.network.reachabilitySummary,
report.network.geolocationSummary,
diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift
index f9400f5..e0ccbbd 100644
--- a/LookupSnapshot.swift
+++ b/LookupSnapshot.swift
@@ -68,6 +68,8 @@ struct LookupSnapshot {
let dnsHistoryError: String?
let domainPricing: DomainPricingInsight?
let domainPricingError: String?
+ let reputation: DomainReputationResult?
+ let reputationError: String?
let portScanResults: [PortScanResult]
let portScanError: String?
let changeSummary: DomainChangeSummary?
@@ -150,6 +152,8 @@ extension HistoryEntry {
dnsHistoryError: dnsHistoryError,
domainPricing: domainPricing,
domainPricingError: domainPricingError,
+ reputation: reputation,
+ reputationError: reputationError,
portScanResults: portScanResults,
portScanError: portScanError,
changeSummary: changeSummary,