Commit 6e273c2676

6e273c2676ce29cef057d117e4427e031886e743

parent: d624802338

Unsigned

cmc <hello@cleberg.net> · 2026-07-17 15:39 UTC

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.

Layout: unified · split

DomainDig/DataAccessService.swift +2
@@ -11,6 +11,8 @@ enum DataAccessService {
11 return FeatureAccessService.hasAccess(to: .extendedSubdomains) 11 return FeatureAccessService.hasAccess(to: .extendedSubdomains)
12 case .domainPricing: 12 case .domainPricing:
13 return FeatureAccessService.hasAccess(to: .domainPricing) 13 return FeatureAccessService.hasAccess(to: .domainPricing)
14 case .reputation:
15 return FeatureAccessService.hasAccess(to: .reputation)
14 } 16 }
15 } 17 }
16} 18}
DomainDig/DomainInsightEngine.swift +16
@@ -303,6 +303,19 @@ enum DomainInsightEngine {
303 } 303 }
304 } 304 }
305 305
306 if let reputation = snapshot.reputation {
307 switch reputation.status {
308 case .listed:
309 score += 25
310 let sourceList = reputation.listedSources.isEmpty ? "" : " (\(reputation.listedSources.joined(separator: ", ")))"
311 factors.append(.init(description: "Domain is flagged by a configured reputation source\(sourceList)", impact: .negative))
312 case .clean:
313 factors.append(.init(description: "Domain is clean against the configured reputation source", impact: .positive))
314 case .unknown:
315 break
316 }
317 }
318
306 let clampedScore = min(max(score, 0), 100) 319 let clampedScore = min(max(score, 0), 100)
307 let level: RiskLevel 320 let level: RiskLevel
308 switch clampedScore { 321 switch clampedScore {
@@ -327,6 +340,9 @@ enum DomainInsightEngine {
327 ) -> [String] { 340 ) -> [String] {
328 var items: [String] = [] 341 var items: [String] = []
329 342
343 if snapshot.reputation?.status == .listed {
344 items.append("Domain is flagged by a configured reputation source")
345 }
330 if let group = subdomainGroups.first(where: { $0.label == "staging" || $0.label == "dev" }) { 346 if let group = subdomainGroups.first(where: { $0.label == "staging" || $0.label == "dev" }) {
331 items.append("Multiple \(group.label) subdomains suggest non-production environments are exposed") 347 items.append("Multiple \(group.label) subdomains suggest non-production environments are exposed")
332 } 348 }
DomainDig/DomainMonitoringService.swift +4
@@ -516,6 +516,7 @@ final class DomainMonitoringService {
516 extendedSubdomains: snapshot.extendedSubdomains, 516 extendedSubdomains: snapshot.extendedSubdomains,
517 dnsHistory: snapshot.dnsHistory, 517 dnsHistory: snapshot.dnsHistory,
518 domainPricing: snapshot.domainPricing, 518 domainPricing: snapshot.domainPricing,
519 reputation: snapshot.reputation,
519 portScanResults: snapshot.portScanResults, 520 portScanResults: snapshot.portScanResults,
520 hstsPreloaded: snapshot.hstsPreloaded, 521 hstsPreloaded: snapshot.hstsPreloaded,
521 availabilityResult: snapshot.availabilityResult, 522 availabilityResult: snapshot.availabilityResult,
@@ -554,6 +555,7 @@ final class DomainMonitoringService {
554 extendedSubdomainsError: snapshot.extendedSubdomainsError, 555 extendedSubdomainsError: snapshot.extendedSubdomainsError,
555 dnsHistoryError: snapshot.dnsHistoryError, 556 dnsHistoryError: snapshot.dnsHistoryError,
556 domainPricingError: snapshot.domainPricingError, 557 domainPricingError: snapshot.domainPricingError,
558 reputationError: snapshot.reputationError,
557 portScanError: snapshot.portScanError 559 portScanError: snapshot.portScanError
558 ) 560 )
559 561
@@ -930,6 +932,8 @@ final class DomainMonitoringService {
930 dnsHistoryError: previousSnapshot.dnsHistoryError, 932 dnsHistoryError: previousSnapshot.dnsHistoryError,
931 domainPricing: previousSnapshot.domainPricing, 933 domainPricing: previousSnapshot.domainPricing,
932 domainPricingError: previousSnapshot.domainPricingError, 934 domainPricingError: previousSnapshot.domainPricingError,
935 reputation: previousSnapshot.reputation,
936 reputationError: previousSnapshot.reputationError,
933 portScanResults: previousSnapshot.portScanResults, 937 portScanResults: previousSnapshot.portScanResults,
934 portScanError: previousSnapshot.portScanError, 938 portScanError: previousSnapshot.portScanError,
935 changeSummary: previousSnapshot.changeSummary, 939 changeSummary: previousSnapshot.changeSummary,
DomainDig/DomainViewModel.swift +51
@@ -232,6 +232,9 @@ final class DomainViewModel {
232 var domainPricing: DomainPricingInsight? 232 var domainPricing: DomainPricingInsight?
233 var domainPricingLoading = false 233 var domainPricingLoading = false
234 var domainPricingError: String? 234 var domainPricingError: String?
235 var reputation: DomainReputationResult?
236 var reputationLoading = false
237 var reputationError: String?
235 var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits() 238 var usageCredits: [UsageCreditFeature: UsageCreditStatus] = DomainViewModel.defaultUsageCredits()
236 239
237 var portScanResults: [PortScanResult] = [] 240 var portScanResults: [PortScanResult] = []
@@ -642,6 +645,8 @@ final class DomainViewModel {
642 dnsHistoryError: dnsHistoryError, 645 dnsHistoryError: dnsHistoryError,
643 domainPricing: domainPricing, 646 domainPricing: domainPricing,
644 domainPricingError: domainPricingError, 647 domainPricingError: domainPricingError,
648 reputation: reputation,
649 reputationError: reputationError,
645 portScanResults: allPortScanResults, 650 portScanResults: allPortScanResults,
646 portScanError: combinedPortScanError, 651 portScanError: combinedPortScanError,
647 changeSummary: currentChangeSummary, 652 changeSummary: currentChangeSummary,
@@ -1883,6 +1888,11 @@ final class DomainViewModel {
1883 await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false) 1888 await refreshDomainPricing(for: snapshot.domain, persistAfterFetch: false)
1884 } 1889 }
1885 1890
1891 if DataAccessService.hasAccess(to: .reputation), reputation == nil {
1892 DomainDebugLog.debug("DomainViewModel.performLookup loadingReputation domain=\(domain)")
1893 await refreshReputation(for: snapshot.domain, persistAfterFetch: false)
1894 }
1895
1886 guard snapshot.statusMessage == nil else { 1896 guard snapshot.statusMessage == nil else {
1887 return history.first(where: { $0.id == snapshot.historyEntryID }) 1897 return history.first(where: { $0.id == snapshot.historyEntryID })
1888 } 1898 }
@@ -2053,6 +2063,8 @@ final class DomainViewModel {
2053 dnsHistoryError: previousSnapshot.dnsHistoryError, 2063 dnsHistoryError: previousSnapshot.dnsHistoryError,
2054 domainPricing: previousSnapshot.domainPricing, 2064 domainPricing: previousSnapshot.domainPricing,
2055 domainPricingError: previousSnapshot.domainPricingError, 2065 domainPricingError: previousSnapshot.domainPricingError,
2066 reputation: previousSnapshot.reputation,
2067 reputationError: previousSnapshot.reputationError,
2056 portScanResults: previousSnapshot.portScanResults, 2068 portScanResults: previousSnapshot.portScanResults,
2057 portScanError: previousSnapshot.portScanError, 2069 portScanError: previousSnapshot.portScanError,
2058 changeSummary: previousSnapshot.changeSummary, 2070 changeSummary: previousSnapshot.changeSummary,
@@ -2474,6 +2486,7 @@ final class DomainViewModel {
2474 extendedSubdomains: snapshot.extendedSubdomains, 2486 extendedSubdomains: snapshot.extendedSubdomains,
2475 dnsHistory: snapshot.dnsHistory, 2487 dnsHistory: snapshot.dnsHistory,
2476 domainPricing: snapshot.domainPricing, 2488 domainPricing: snapshot.domainPricing,
2489 reputation: snapshot.reputation,
2477 portScanResults: snapshot.portScanResults, 2490 portScanResults: snapshot.portScanResults,
2478 hstsPreloaded: snapshot.hstsPreloaded, 2491 hstsPreloaded: snapshot.hstsPreloaded,
2479 availabilityResult: snapshot.availabilityResult, 2492 availabilityResult: snapshot.availabilityResult,
@@ -2516,6 +2529,7 @@ final class DomainViewModel {
2516 extendedSubdomainsError: snapshot.extendedSubdomainsError, 2529 extendedSubdomainsError: snapshot.extendedSubdomainsError,
2517 dnsHistoryError: snapshot.dnsHistoryError, 2530 dnsHistoryError: snapshot.dnsHistoryError,
2518 domainPricingError: snapshot.domainPricingError, 2531 domainPricingError: snapshot.domainPricingError,
2532 reputationError: snapshot.reputationError,
2519 portScanError: snapshot.portScanError 2533 portScanError: snapshot.portScanError
2520 ) 2534 )
2521 2535
@@ -3844,6 +3858,8 @@ final class DomainViewModel {
3844 dnsHistoryError: nil, 3858 dnsHistoryError: nil,
3845 domainPricing: nil, 3859 domainPricing: nil,
3846 domainPricingError: nil, 3860 domainPricingError: nil,
3861 reputation: nil,
3862 reputationError: nil,
3847 portScanResults: [], 3863 portScanResults: [],
3848 portScanError: nil, 3864 portScanError: nil,
3849 changeSummary: trackedDomain.lastChangeSummary, 3865 changeSummary: trackedDomain.lastChangeSummary,
@@ -4005,6 +4021,18 @@ final class DomainViewModel {
4005 rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary)) 4021 rows.append(InfoRowViewData(label: "Auction", value: auctionSignal, tone: .secondary))
4006 } 4022 }
4007 } 4023 }
4024 if let reputation = snapshot.reputation {
4025 let tone: ResultTone
4026 switch reputation.status {
4027 case .clean: tone = .success
4028 case .listed: tone = .failure
4029 case .unknown: tone = .secondary
4030 }
4031 let value = reputation.status == .listed && !reputation.listedSources.isEmpty
4032 ? "\(reputation.status.title) (\(reputation.listedSources.joined(separator: ", ")))"
4033 : reputation.status.title
4034 rows.append(InfoRowViewData(label: "Reputation", value: value, tone: tone))
4035 }
4008 if let certificateStatus = certificateBadgeLabel(from: snapshot) { 4036 if let certificateStatus = certificateBadgeLabel(from: snapshot) {
4009 rows.insert( 4037 rows.insert(
4010 InfoRowViewData( 4038 InfoRowViewData(
@@ -4700,6 +4728,29 @@ final class DomainViewModel {
4700 } 4728 }
4701 } 4729 }
4702 4730
4731 private func refreshReputation(for domain: String, persistAfterFetch: Bool) async {
4732 reputationLoading = true
4733 let outcome = await ExternalDataService.shared.reputation(domain: domain)
4734
4735 switch outcome.value {
4736 case let .success(result):
4737 reputation = result
4738 reputationError = nil
4739 case let .empty(message):
4740 reputation = nil
4741 reputationError = conciseExternalMessage(message, fallback: "Reputation check unavailable")
4742 case let .error(message):
4743 reputation = nil
4744 reputationError = conciseExternalMessage(message, fallback: "Reputation check unavailable")
4745 }
4746
4747 reputationLoading = false
4748
4749 if persistAfterFetch {
4750 _ = saveHistoryEntry(replaceLatest: true)
4751 }
4752 }
4753
4703 private func conciseExternalMessage(_ message: String, fallback: String) -> String { 4754 private func conciseExternalMessage(_ message: String, fallback: String) -> String {
4704 let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) 4755 let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
4705 if trimmed.isEmpty { 4756 if trimmed.isEmpty {
DomainDig/ExternalDataService.swift +66 −1
@@ -8,12 +8,14 @@ actor ExternalDataService {
8 case dnsHistory(String) 8 case dnsHistory(String)
9 case extendedSubdomains(String) 9 case extendedSubdomains(String)
10 case pricing(String) 10 case pricing(String)
11 case reputation(String)
11 } 12 }
12 13
13 private enum RateLimitBucket: Hashable { 14 private enum RateLimitBucket: Hashable {
14 case history 15 case history
15 case subdomains 16 case subdomains
16 case pricing 17 case pricing
18 case reputation
17 19
18 var minimumSpacing: TimeInterval { 20 var minimumSpacing: TimeInterval {
19 switch self { 21 switch self {
@@ -23,6 +25,8 @@ actor ExternalDataService {
23 return 1.5 25 return 1.5
24 case .pricing: 26 case .pricing:
25 return 1.0 27 return 1.0
28 case .reputation:
29 return 1.0
26 } 30 }
27 } 31 }
28 } 32 }
@@ -32,6 +36,7 @@ actor ExternalDataService {
32 case dnsHistory(ServiceResult<[DNSHistoryEvent]>) 36 case dnsHistory(ServiceResult<[DNSHistoryEvent]>)
33 case extendedSubdomains(ServiceResult<[DiscoveredSubdomain]>) 37 case extendedSubdomains(ServiceResult<[DiscoveredSubdomain]>)
34 case pricing(ServiceResult<DomainPricingInsight>) 38 case pricing(ServiceResult<DomainPricingInsight>)
39 case reputation(ServiceResult<DomainReputationResult>)
35 } 40 }
36 41
37 private struct CacheEntry { 42 private struct CacheEntry {
@@ -44,6 +49,7 @@ actor ExternalDataService {
44 let dnsHistoryURL: String? 49 let dnsHistoryURL: String?
45 let extendedSubdomainsURL: String? 50 let extendedSubdomainsURL: String?
46 let pricingURL: String? 51 let pricingURL: String?
52 let reputationURL: String?
47 } 53 }
48 54
49 private let ttl: TimeInterval = 900 55 private let ttl: TimeInterval = 900
@@ -161,6 +167,25 @@ actor ExternalDataService {
161 ) 167 )
162 } 168 }
163 169
170 /// Checks a domain against a configured blocklist/reputation endpoint. With
171 /// no endpoint configured (the default, since DomainDig ships no bundled
172 /// third-party reputation dependency) this resolves to `.empty` and callers
173 /// treat the result as unavailable rather than "clean".
174 func reputation(domain: String) async -> CachedLookupResult<ServiceResult<DomainReputationResult>> {
175 let normalizedDomain = Self.normalize(domain)
176 return await execute(
177 key: .reputation(normalizedDomain),
178 rateLimitBucket: .reputation,
179 extract: { payload in
180 guard case let .reputation(result) = payload else { return nil }
181 return result
182 },
183 operation: { [configuration = configuration()] in
184 .reputation(await self.fetchReputation(domain: normalizedDomain, configuration: configuration))
185 }
186 )
187 }
188
164 private func execute<T>( 189 private func execute<T>(
165 key: RequestKey, 190 key: RequestKey,
166 rateLimitBucket: RateLimitBucket, 191 rateLimitBucket: RateLimitBucket,
@@ -213,7 +238,9 @@ actor ExternalDataService {
213 extendedSubdomainsURL: defaults.string(forKey: "externalData.extendedSubdomainsURL") 238 extendedSubdomainsURL: defaults.string(forKey: "externalData.extendedSubdomainsURL")
214 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalExtendedSubdomainsURL") as? String, 239 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalExtendedSubdomainsURL") as? String,
215 pricingURL: defaults.string(forKey: "externalData.pricingURL") 240 pricingURL: defaults.string(forKey: "externalData.pricingURL")
216 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String 241 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalPricingURL") as? String,
242 reputationURL: defaults.string(forKey: "externalData.reputationURL")
243 ?? Bundle.main.object(forInfoDictionaryKey: "ExternalReputationURL") as? String
217 ) 244 )
218 } 245 }
219 246
@@ -290,6 +317,28 @@ actor ExternalDataService {
290 } 317 }
291 } 318 }
292 319
320 private func fetchReputation(
321 domain: String,
322 configuration: Configuration
323 ) async -> ServiceResult<DomainReputationResult> {
324 guard let template = configuration.reputationURL,
325 let url = Self.url(from: template, domain: domain) else {
326 return .empty("No reputation source configured")
327 }
328
329 switch await requestData(url: url) {
330 case let .success(data):
331 guard let reputation = parseReputation(from: data) else {
332 return .error("Invalid external response")
333 }
334 return .success(reputation)
335 case let .empty(message):
336 return .empty(message)
337 case let .error(message):
338 return .error(message)
339 }
340 }
341
293 private func requestData(url: URL) async -> ServiceResult<Data> { 342 private func requestData(url: URL) async -> ServiceResult<Data> {
294 for attempt in 0..<2 { 343 for attempt in 0..<2 {
295 do { 344 do {
@@ -403,6 +452,22 @@ actor ExternalDataService {
403 ) 452 )
404 } 453 }
405 454
455 private func parseReputation(from data: Data) -> DomainReputationResult? {
456 guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
457 return nil
458 }
459
460 let listedSources = json["listed_sources"] as? [String] ?? []
461 let status: DomainReputationStatus
462 if let statusString = json["status"] as? String, let parsed = DomainReputationStatus(rawValue: statusString) {
463 status = parsed
464 } else {
465 status = listedSources.isEmpty ? .clean : .listed
466 }
467
468 return DomainReputationResult(status: status, listedSources: listedSources, checkedAt: Date())
469 }
470
406 private static func localOwnershipHistory( 471 private static func localOwnershipHistory(
407 domain: String, 472 domain: String,
408 currentOwnership: DomainOwnership?, 473 currentOwnership: DomainOwnership?,
DomainDig/FeatureAccessService.swift +5 −2
@@ -32,6 +32,7 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
32 case dnsHistory 32 case dnsHistory
33 case extendedSubdomains 33 case extendedSubdomains
34 case domainPricing 34 case domainPricing
35 case reputation
35 36
36 var id: String { rawValue } 37 var id: String { rawValue }
37 38
@@ -61,6 +62,8 @@ enum FeatureCapability: String, CaseIterable, Identifiable {
61 return "Extended subdomains" 62 return "Extended subdomains"
62 case .domainPricing: 63 case .domainPricing:
63 return "Domain pricing" 64 return "Domain pricing"
65 case .reputation:
66 return "Domain reputation"
64 } 67 }
65 } 68 }
66} 69}
@@ -157,7 +160,7 @@ enum FeatureAccessService {
157 switch capability { 160 switch capability {
158 case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports: 161 case .workflows, .batchOperations, .automatedMonitoring, .localAlerts, .advancedExports:
159 return "Available in Pro" 162 return "Available in Pro"
160 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: 163 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing, .reputation:
161 return "Available in Pro+" 164 return "Available in Pro+"
162 case .limitedTracking: 165 case .limitedTracking:
163 return "Tracking is limited on Free" 166 return "Tracking is limited on Free"
@@ -219,7 +222,7 @@ enum FeatureAccessService {
219 static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext { 222 static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
220 let title: String 223 let title: String
221 switch capability { 224 switch capability {
222 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing: 225 case .ownershipHistory, .dnsHistory, .extendedSubdomains, .domainPricing, .reputation:
223 title = "Available in Pro+" 226 title = "Available in Pro+"
224 default: 227 default:
225 title = "Available in Pro" 228 title = "Available in Pro"
DomainDig/Models.swift +32 −1
@@ -391,6 +391,7 @@ enum DataCapability: String, Codable {
391 case dnsHistory 391 case dnsHistory
392 case extendedSubdomains 392 case extendedSubdomains
393 case domainPricing 393 case domainPricing
394 case reputation
394} 395}
395 396
396enum UsageCreditFeature: String, Codable, CaseIterable, Identifiable, Sendable { 397enum UsageCreditFeature: String, Codable, CaseIterable, Identifiable, Sendable {
@@ -724,6 +725,29 @@ struct DomainPricingInsight: Codable, Equatable, Sendable {
724 let collectedAt: Date 725 let collectedAt: Date
725} 726}
726 727
728enum DomainReputationStatus: String, Codable, Sendable {
729 case clean
730 case listed
731 case unknown
732
733 var title: String {
734 switch self {
735 case .clean:
736 return "Clean"
737 case .listed:
738 return "Listed"
739 case .unknown:
740 return "Unknown"
741 }
742 }
743}
744
745struct DomainReputationResult: Codable, Equatable, Sendable {
746 let status: DomainReputationStatus
747 let listedSources: [String]
748 let checkedAt: Date
749}
750
727enum HistoryDateFilter: String, CaseIterable, Identifiable { 751enum HistoryDateFilter: String, CaseIterable, Identifiable {
728 case today 752 case today
729 case last7Days 753 case last7Days
@@ -2285,6 +2309,7 @@ struct HistoryEntry: Identifiable, Codable {
2285 var extendedSubdomains: [DiscoveredSubdomain] 2309 var extendedSubdomains: [DiscoveredSubdomain]
2286 var dnsHistory: [DNSHistoryEvent] 2310 var dnsHistory: [DNSHistoryEvent]
2287 var domainPricing: DomainPricingInsight? 2311 var domainPricing: DomainPricingInsight?
2312 var reputation: DomainReputationResult?
2288 var portScanResults: [PortScanResult] 2313 var portScanResults: [PortScanResult]
2289 var hstsPreloaded: Bool? 2314 var hstsPreloaded: Bool?
2290 var availabilityResult: DomainAvailabilityResult? 2315 var availabilityResult: DomainAvailabilityResult?
@@ -2327,6 +2352,7 @@ struct HistoryEntry: Identifiable, Codable {
2327 var extendedSubdomainsError: String? 2352 var extendedSubdomainsError: String?
2328 var dnsHistoryError: String? 2353 var dnsHistoryError: String?
2329 var domainPricingError: String? 2354 var domainPricingError: String?
2355 var reputationError: String?
2330 var portScanError: String? 2356 var portScanError: String?
2331 2357
2332 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection], 2358 init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, note: String? = nil, dnsSections: [DNSSection],
@@ -2344,6 +2370,7 @@ struct HistoryEntry: Identifiable, Codable {
2344 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [], 2370 ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
2345 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [], 2371 extendedSubdomains: [DiscoveredSubdomain] = [], dnsHistory: [DNSHistoryEvent] = [],
2346 domainPricing: DomainPricingInsight? = nil, 2372 domainPricing: DomainPricingInsight? = nil,
2373 reputation: DomainReputationResult? = nil,
2347 portScanResults: [PortScanResult] = [], 2374 portScanResults: [PortScanResult] = [],
2348 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil, 2375 hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
2349 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0", 2376 suggestions: [DomainSuggestionResult] = [], appVersion: String = "2.7.0",
@@ -2362,7 +2389,7 @@ struct HistoryEntry: Identifiable, Codable {
2362 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil, 2389 emailSecurityError: String? = nil, ownershipError: String? = nil, ownershipHistoryError: String? = nil,
2363 ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil, 2390 ptrError: String? = nil, redirectChainError: String? = nil, subdomainsError: String? = nil,
2364 extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil, 2391 extendedSubdomainsError: String? = nil, dnsHistoryError: String? = nil,
2365 domainPricingError: String? = nil, portScanError: String? = nil) { 2392 domainPricingError: String? = nil, reputationError: String? = nil, portScanError: String? = nil) {
2366 self.domain = domain 2393 self.domain = domain
2367 self.timestamp = timestamp 2394 self.timestamp = timestamp
2368 self.trackedDomainID = trackedDomainID 2395 self.trackedDomainID = trackedDomainID
@@ -2390,6 +2417,7 @@ struct HistoryEntry: Identifiable, Codable {
2390 self.extendedSubdomains = extendedSubdomains 2417 self.extendedSubdomains = extendedSubdomains
2391 self.dnsHistory = dnsHistory 2418 self.dnsHistory = dnsHistory
2392 self.domainPricing = domainPricing 2419 self.domainPricing = domainPricing
2420 self.reputation = reputation
2393 self.portScanResults = portScanResults 2421 self.portScanResults = portScanResults
2394 self.hstsPreloaded = hstsPreloaded 2422 self.hstsPreloaded = hstsPreloaded
2395 self.availabilityResult = availabilityResult 2423 self.availabilityResult = availabilityResult
@@ -2432,6 +2460,7 @@ struct HistoryEntry: Identifiable, Codable {
2432 self.extendedSubdomainsError = extendedSubdomainsError 2460 self.extendedSubdomainsError = extendedSubdomainsError
2433 self.dnsHistoryError = dnsHistoryError 2461 self.dnsHistoryError = dnsHistoryError
2434 self.domainPricingError = domainPricingError 2462 self.domainPricingError = domainPricingError
2463 self.reputationError = reputationError
2435 self.portScanError = portScanError 2464 self.portScanError = portScanError
2436 } 2465 }
2437 2466
@@ -2465,6 +2494,7 @@ struct HistoryEntry: Identifiable, Codable {
2465 extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? [] 2494 extendedSubdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .extendedSubdomains) ?? []
2466 dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? [] 2495 dnsHistory = try container.decodeIfPresent([DNSHistoryEvent].self, forKey: .dnsHistory) ?? []
2467 domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing) 2496 domainPricing = try container.decodeIfPresent(DomainPricingInsight.self, forKey: .domainPricing)
2497 reputation = try container.decodeIfPresent(DomainReputationResult.self, forKey: .reputation)
2468 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] 2498 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
2469 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) 2499 hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
2470 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult) 2500 availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -2510,6 +2540,7 @@ struct HistoryEntry: Identifiable, Codable {
2510 extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError) 2540 extendedSubdomainsError = try container.decodeIfPresent(String.self, forKey: .extendedSubdomainsError)
2511 dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError) 2541 dnsHistoryError = try container.decodeIfPresent(String.self, forKey: .dnsHistoryError)
2512 domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError) 2542 domainPricingError = try container.decodeIfPresent(String.self, forKey: .domainPricingError)
2543 reputationError = try container.decodeIfPresent(String.self, forKey: .reputationError)
2513 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) 2544 portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
2514 } 2545 }
2515 2546
DomainInspectionService.swift +2
@@ -369,6 +369,8 @@ struct DomainInspectionService {
369 dnsHistoryError: nil, 369 dnsHistoryError: nil,
370 domainPricing: nil, 370 domainPricing: nil,
371 domainPricingError: nil, 371 domainPricingError: nil,
372 reputation: nil,
373 reputationError: nil,
372 portScanResults: portScanResults.value, 374 portScanResults: portScanResults.value,
373 portScanError: portScanResults.message, 375 portScanError: portScanResults.message,
374 changeSummary: nil, 376 changeSummary: nil,
DomainReportBuilder.swift +2
@@ -38,6 +38,7 @@ struct DomainReport: Codable {
38 let extendedSubdomains: [String] 38 let extendedSubdomains: [String]
39 let dnsHistory: [DNSHistoryEvent] 39 let dnsHistory: [DNSHistoryEvent]
40 let domainPricing: DomainPricingInsight? 40 let domainPricing: DomainPricingInsight?
41 let reputation: DomainReputationResult?
41 let subdomainGroups: [SubdomainGroup] 42 let subdomainGroups: [SubdomainGroup]
42 let riskAssessment: DomainRiskAssessment 43 let riskAssessment: DomainRiskAssessment
43 let insights: [String] 44 let insights: [String]
@@ -360,6 +361,7 @@ struct DomainReportBuilder {
360 extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname), 361 extendedSubdomains: snapshot.extendedSubdomains.map(\.hostname),
361 dnsHistory: snapshot.dnsHistory, 362 dnsHistory: snapshot.dnsHistory,
362 domainPricing: snapshot.domainPricing, 363 domainPricing: snapshot.domainPricing,
364 reputation: snapshot.reputation,
363 subdomainGroups: analysis.subdomainGroups, 365 subdomainGroups: analysis.subdomainGroups,
364 riskAssessment: analysis.riskAssessment, 366 riskAssessment: analysis.riskAssessment,
365 insights: analysis.insights, 367 insights: analysis.insights,
DomainReportExporter.swift +8 −1
@@ -69,7 +69,8 @@ enum DomainReportExporter {
69 "Email: \(report.email.summary)", 69 "Email: \(report.email.summary)",
70 "Subdomains: \(report.subdomains.count)", 70 "Subdomains: \(report.subdomains.count)",
71 "Extended Subdomains: \(report.extendedSubdomains.count)", 71 "Extended Subdomains: \(report.extendedSubdomains.count)",
72 "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")" 72 "External Price: \(report.domainPricing?.estimatedPrice ?? "Unavailable")",
73 "Reputation: \(report.reputation?.status.title ?? "Unavailable")"
73 ] 74 ]
74 } 75 }
75 76
@@ -406,6 +407,8 @@ enum DomainReportExporter {
406 "pricing_resale_signal", 407 "pricing_resale_signal",
407 "pricing_auction_signal", 408 "pricing_auction_signal",
408 "pricing_source", 409 "pricing_source",
410 "reputation_status",
411 "reputation_listed_sources",
409 "open_ports", 412 "open_ports",
410 "reachability_summary", 413 "reachability_summary",
411 "geolocation_summary", 414 "geolocation_summary",
@@ -438,6 +441,8 @@ enum DomainReportExporter {
438 let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ") 441 let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
439 let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ") 442 let ownershipHistory = report.ownershipHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
440 let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ") 443 let dnsHistory = report.dnsHistory.map { "\($0.date.ISO8601Format()) \($0.summary)" }.joined(separator: " | ")
444 let reputationStatus = report.reputation?.status.rawValue ?? ""
445 let reputationListedSources = report.reputation?.listedSources.joined(separator: " | ") ?? ""
441 446
442 return [ 447 return [
443 report.domain, 448 report.domain,
@@ -483,6 +488,8 @@ enum DomainReportExporter {
483 report.domainPricing?.resaleSignal ?? "", 488 report.domainPricing?.resaleSignal ?? "",
484 report.domainPricing?.auctionSignal ?? "", 489 report.domainPricing?.auctionSignal ?? "",
485 report.domainPricing?.source ?? "", 490 report.domainPricing?.source ?? "",
491 reputationStatus,
492 reputationListedSources,
486 openPorts, 493 openPorts,
487 report.network.reachabilitySummary, 494 report.network.reachabilitySummary,
488 report.network.geolocationSummary, 495 report.network.geolocationSummary,
LookupSnapshot.swift +4
@@ -68,6 +68,8 @@ struct LookupSnapshot {
68 let dnsHistoryError: String? 68 let dnsHistoryError: String?
69 let domainPricing: DomainPricingInsight? 69 let domainPricing: DomainPricingInsight?
70 let domainPricingError: String? 70 let domainPricingError: String?
71 let reputation: DomainReputationResult?
72 let reputationError: String?
71 let portScanResults: [PortScanResult] 73 let portScanResults: [PortScanResult]
72 let portScanError: String? 74 let portScanError: String?
73 let changeSummary: DomainChangeSummary? 75 let changeSummary: DomainChangeSummary?
@@ -150,6 +152,8 @@ extension HistoryEntry {
150 dnsHistoryError: dnsHistoryError, 152 dnsHistoryError: dnsHistoryError,
151 domainPricing: domainPricing, 153 domainPricing: domainPricing,
152 domainPricingError: domainPricingError, 154 domainPricingError: domainPricingError,
155 reputation: reputation,
156 reputationError: reputationError,
153 portScanResults: portScanResults, 157 portScanResults: portScanResults,
154 portScanError: portScanError, 158 portScanError: portScanError,
155 changeSummary: changeSummary, 159 changeSummary: changeSummary,