diff options
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 123 | ||||
| -rw-r--r-- | DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist | 5 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 56 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 430 | ||||
| -rw-r--r-- | DomainDig/ExportPresenter.swift | 6 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 29 | ||||
| -rw-r--r-- | DomainDigCLI.swift | 39 | ||||
| -rw-r--r-- | DomainInspectionService.swift | 225 | ||||
| -rw-r--r-- | DomainReportBuilder.swift | 190 | ||||
| -rw-r--r-- | DomainReportExporter.swift | 367 | ||||
| -rw-r--r-- | LookupSnapshot.swift | 89 |
11 files changed, 1168 insertions, 391 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 6ce65e4..40dadc9 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -6,25 +6,59 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */; }; + 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; + 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; + 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; + 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; + 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; }; + 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; + 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; }; + 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; + 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + DomainDigCLI.swift, Info.plist, ); target = 8B7800682F6090E300933221 /* DomainDig */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + BatchResultsView.swift, + BatchSweepSummaryView.swift, + ContentView.swift, + DomainDigApp.swift, + DomainViewModel.swift, + ExportPresenter.swift, + HistoryView.swift, + Info.plist, + LocalNotificationService.swift, + SavedDomainsView.swift, + WatchlistView.swift, + ); + target = 8BF124FB2F70000100933221 /* DomainDigCLI */; + }; /* Begin PBXFileSystemSynchronizedRootGroup section */ 8B78006B2F6090E300933221 /* DomainDig */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */, + 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */, ); path = DomainDig; sourceTree = "<group>"; @@ -39,6 +73,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BF124FD2F70000100933221 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -47,6 +88,11 @@ children = ( 8B78006B2F6090E300933221 /* DomainDig */, 8B78006A2F6090E300933221 /* Products */, + 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */, + 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, + 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */, + 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */, + 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */, ); sourceTree = "<group>"; }; @@ -54,6 +100,7 @@ isa = PBXGroup; children = ( 8B7800692F6090E300933221 /* DomainDig.app */, + 8BF124F92F70000100933221 /* domaindig */, ); name = Products; sourceTree = "<group>"; @@ -83,6 +130,27 @@ productReference = 8B7800692F6090E300933221 /* DomainDig.app */; productType = "com.apple.product-type.application"; }; + 8BF124FB2F70000100933221 /* DomainDigCLI */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */; + buildPhases = ( + 8BF124FC2F70000100933221 /* Sources */, + 8BF124FD2F70000100933221 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 8B78006B2F6090E300933221 /* DomainDig */, + ); + name = DomainDigCLI; + packageProductDependencies = ( + ); + productName = domaindig; + productReference = 8BF124F92F70000100933221 /* domaindig */; + productType = "com.apple.product-type.tool"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -113,6 +181,7 @@ projectRoot = ""; targets = ( 8B7800682F6090E300933221 /* DomainDig */, + 8BF124FB2F70000100933221 /* DomainDigCLI */, ); }; /* End PBXProject section */ @@ -132,6 +201,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, + 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, + 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, + 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, + 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -267,7 +341,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -284,7 +358,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.0; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -303,7 +377,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -320,7 +394,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.3.0; + MARKETING_VERSION = 2.4.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -333,6 +407,47 @@ }; name = Release; }; + 8BF125002F70000100933221 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 17; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + ENABLE_TESTABILITY = YES; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 2.4.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI; + PRODUCT_NAME = domaindig; + SDKROOT = macosx; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 8BF125012F70000100933221 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 17; + DEVELOPMENT_TEAM = ZCNAX3VL9D; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 2.4.0; + PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI; + PRODUCT_NAME = domaindig; + SDKROOT = macosx; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist index bd2f39c..06d0111 100644 --- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,6 +7,11 @@ <key>DomainDig.xcscheme_^#shared#^_</key> <dict> <key>orderHint</key> + <integer>1</integer> + </dict> + <key>DomainDigCLI.xcscheme_^#shared#^_</key> + <dict> + <key>orderHint</key> <integer>0</integer> </dict> </dict> diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 261b7ca..c5fc1fa 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -304,10 +304,13 @@ struct ContentView: View { } Menu { Button("Export TXT") { - shareSingleResults(asCSV: false) + shareSingleResults(format: .text) } Button("Export CSV") { - shareSingleResults(asCSV: true) + shareSingleResults(format: .csv) + } + Button("Export JSON") { + shareSingleResults(format: .json) } } label: { Image(systemName: "square.and.arrow.up") @@ -332,10 +335,13 @@ struct ContentView: View { if !viewModel.currentBatchResultEntries.isEmpty { Menu { Button("Export Batch TXT") { - shareBatchResults(asCSV: false) + shareBatchResults(format: .text) } Button("Export Batch CSV") { - shareBatchResults(asCSV: true) + shareBatchResults(format: .csv) + } + Button("Export Batch JSON") { + shareBatchResults(format: .json) } } label: { Label("Export", systemImage: "square.and.arrow.up") @@ -412,33 +418,51 @@ struct ContentView: View { return ports } - private func shareSingleResults(asCSV: Bool) { - let (filename, contents) = exportPayload( + private func shareSingleResults(format: DomainExportFormat) { + let (filename, data) = exportPayload( prefix: "domaindig_single", + format: format, text: viewModel.exportText(), csv: viewModel.exportCSV(), - asCSV: asCSV + json: viewModel.exportJSONData() ) - ExportPresenter.share(filename: filename, contents: contents) + ExportPresenter.share(filename: filename, data: data) } - private func shareBatchResults(asCSV: Bool) { - let (filename, contents) = exportPayload( + private func shareBatchResults(format: DomainExportFormat) { + let (filename, data) = exportPayload( prefix: "domaindig_batch", + format: format, text: viewModel.exportBatchText(), csv: viewModel.exportBatchCSV(), - asCSV: asCSV + json: viewModel.exportBatchJSONData() ) - ExportPresenter.share(filename: filename, contents: contents) + ExportPresenter.share(filename: filename, data: data) } - private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) { + private func exportPayload( + prefix: String, + format: DomainExportFormat, + text: String, + csv: String, + json: Data? + ) -> (String, Data) { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" let timestamp = formatter.string(from: Date()) - let fileExtension = asCSV ? "csv" : "txt" - let filename = "\(timestamp)_\(prefix).\(fileExtension)" - return (filename, asCSV ? csv : text) + let filename = "\(timestamp)_\(prefix).\(format.fileExtension)" + let data: Data + + switch format { + case .text: + data = Data(text.utf8) + case .csv: + data = Data(csv.utf8) + case .json: + data = json ?? Data("[]".utf8) + } + + return (filename, data) } } diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index e895ce6..71209dc 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -91,94 +91,6 @@ struct DomainSuggestionViewData: Identifiable { let tone: ResultTone } -struct LookupSnapshot { - let historyEntryID: UUID? - let domain: String - let timestamp: Date - let trackedDomainID: UUID? - let resolverDisplayName: String - let resolverURLString: String - let totalLookupDurationMs: Int? - let dnsSections: [DNSSection] - let dnsError: String? - let availabilityResult: DomainAvailabilityResult? - let suggestions: [DomainSuggestionResult] - let sslInfo: SSLCertificateInfo? - let sslError: String? - let hstsPreloaded: Bool? - let httpHeaders: [HTTPHeader] - let httpSecurityGrade: String? - let httpStatusCode: Int? - let httpResponseTimeMs: Int? - let httpProtocol: String? - let http3Advertised: Bool - let httpHeadersError: String? - let reachabilityResults: [PortReachability] - let reachabilityError: String? - let ipGeolocation: IPGeolocation? - let ipGeolocationError: String? - let emailSecurity: EmailSecurityResult? - let emailSecurityError: String? - let ownership: DomainOwnership? - let ownershipError: String? - let ptrRecord: String? - let ptrError: String? - let redirectChain: [RedirectHop] - let redirectChainError: String? - let subdomains: [DiscoveredSubdomain] - let subdomainsError: String? - let portScanResults: [PortScanResult] - let portScanError: String? - let changeSummary: DomainChangeSummary? - let isLive: Bool -} - -extension HistoryEntry { - var snapshot: LookupSnapshot { - LookupSnapshot( - historyEntryID: id, - domain: domain, - timestamp: timestamp, - trackedDomainID: trackedDomainID, - resolverDisplayName: resolverDisplayName, - resolverURLString: resolverURLString, - totalLookupDurationMs: totalLookupDurationMs, - dnsSections: dnsSections, - dnsError: nil, - availabilityResult: availabilityResult, - suggestions: suggestions, - sslInfo: sslInfo, - sslError: sslError, - hstsPreloaded: hstsPreloaded, - httpHeaders: httpHeaders, - httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue, - httpStatusCode: nil, - httpResponseTimeMs: nil, - httpProtocol: nil, - http3Advertised: false, - httpHeadersError: httpHeadersError, - reachabilityResults: reachabilityResults, - reachabilityError: reachabilityError, - ipGeolocation: ipGeolocation, - ipGeolocationError: ipGeolocationError, - emailSecurity: emailSecurity, - emailSecurityError: emailSecurityError, - ownership: ownership, - ownershipError: ownershipError, - ptrRecord: ptrRecord, - ptrError: ptrError, - redirectChain: redirectChain, - redirectChainError: redirectChainError, - subdomains: subdomains, - subdomainsError: subdomainsError, - portScanResults: portScanResults, - portScanError: portScanError, - changeSummary: changeSummary, - isLive: false - ) - } -} - private struct BatchLookupPayload { let snapshot: LookupSnapshot } @@ -271,6 +183,7 @@ final class DomainViewModel { private var lookupStartedAt: Date? private var activeBatchDomains: [String] = [] private var lastBatchStartedAt: Date? + private let reportBuilder = DomainReportBuilder() private static let recentSearchesKey = "recentSearches" private static let maxRecent = 20 @@ -484,6 +397,18 @@ final class DomainViewModel { ) } + var currentReport: DomainReport? { + guard !searchedDomain.isEmpty else { return nil } + return reportBuilder.build( + from: currentSnapshot, + previousSnapshot: previousSnapshot( + for: searchedDomain, + trackedDomainID: currentTrackedDomain?.id, + replacingLatest: false + ) + ) + } + var summaryFields: [SummaryFieldViewData] { Self.summaryFields(from: currentSnapshot) } @@ -768,64 +693,55 @@ final class DomainViewModel { } func exportText() -> String { - Self.formatExportText( - from: currentSnapshot, - trackedDomain: currentTrackedDomain, - changeSummary: currentChangeSummary, - diffSections: currentDiffSections - ) + guard let currentReport else { return "No results available." } + return DomainReportExporter.text(for: currentReport) } func exportCSV() -> String { - Self.formatCSV(from: [currentSnapshot]) + guard let currentReport else { return DomainReportExporter.csv(for: []) } + return DomainReportExporter.csv(for: [currentReport]) + } + + func exportJSONData() -> Data? { + guard let currentReport else { return nil } + return try? DomainReportExporter.data(for: currentReport, format: .json) } func exportBatchText() -> String { - Self.formatBatchExportText( - title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export", - entries: currentBatchResultEntries.map { entry in - ( - snapshot: entry.snapshot, - trackedDomain: trackedDomains.first(where: { tracked in - tracked.id == entry.trackedDomainID || - tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame - }), - changeSummary: entry.changeSummary, - diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? [] - ) - } + DomainReportExporter.batchText( + for: currentBatchReports(), + title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" ) } func exportBatchCSV() -> String { - Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot)) + DomainReportExporter.csv(for: currentBatchReports()) + } + + func exportBatchJSONData() -> Data? { + try? DomainReportExporter.data( + for: currentBatchReports(), + format: .json, + title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" + ) } func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String { - Self.formatCSV(from: exportSnapshots(for: domains)) + DomainReportExporter.csv(for: reports(for: domains)) } func exportTrackedDomainsText(domains: [TrackedDomain]) -> String { - let latestEntries = latestSnapshots(for: domains) - return Self.formatBatchExportText( - title: "Tracked Domains Export", - entries: domains.map { trackedDomain in - if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) { - return ( - snapshot: entry.snapshot, - trackedDomain: trackedDomain, - changeSummary: entry.changeSummary, - diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? [] - ) - } + DomainReportExporter.batchText( + for: reports(for: domains), + title: "Tracked Domains Export" + ) + } - return ( - snapshot: placeholderSnapshot(for: trackedDomain), - trackedDomain: trackedDomain, - changeSummary: trackedDomain.lastChangeSummary, - diffSections: [] - ) - } + func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? { + try? DomainReportExporter.data( + for: reports(for: domains), + format: .json, + title: "Tracked Domains Export" ) } @@ -1130,239 +1046,8 @@ final class DomainViewModel { private static func performBatchLookup(domain: String) async -> BatchLookupPayload? { guard !Task.isCancelled else { return nil } - - let startedAt = Date() - let resolverDisplayName = DNSLookupService.currentResolverDisplayName() - let resolverURLString = DNSLookupService.currentResolverURLString() - - async let dnsResult = DNSLookupService.lookupAll(domain: domain) - async let availabilityResult = DomainAvailabilityService.check(domain: domain) - async let sslResult = SSLCheckService.check(domain: domain) - async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain) - async let httpResult = HTTPHeadersService.fetch(domain: domain) - async let reachabilityResult = ReachabilityService.checkAll(domain: domain) - async let ownershipResult = DomainOwnershipService.lookup(domain: domain) - async let redirectResult = RedirectChainService.trace(domain: domain) - async let subdomainResult = SubdomainDiscoveryService.discover(for: domain) - async let portScanResult = PortScanService.scanAll(domain: domain) - - let resolvedDNS = await dnsResult - let availability = await availabilityResult - let resolvedSSL = await sslResult - let hsts = await hstsResult - let http = await httpResult - let reachability = await reachabilityResult - let resolvedOwnership = await ownershipResult - let redirects = await redirectResult - let resolvedSubdomains = await subdomainResult - let ports = await portScanResult - + let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain) guard !Task.isCancelled else { return nil } - - let dnsSections: [DNSSection] - let dnsError: String? - switch resolvedDNS { - case let .success(sections): - dnsSections = sections - dnsError = nil - case let .empty(message), let .error(message): - dnsSections = [] - dnsError = message - } - - let sslInfo: SSLCertificateInfo? - let sslError: String? - switch resolvedSSL { - case let .success(info): - sslInfo = info - sslError = nil - case let .empty(message), let .error(message): - sslInfo = nil - sslError = message - } - - let httpHeaders: [HTTPHeader] - let httpSecurityGrade: String? - let httpStatusCode: Int? - let httpResponseTimeMs: Int? - let httpProtocol: String? - let http3Advertised: Bool - let httpHeadersError: String? - switch http { - case let .success(result): - httpHeaders = result.headers - httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue - httpStatusCode = result.statusCode - httpResponseTimeMs = result.responseTimeMs - httpProtocol = result.httpProtocol - http3Advertised = result.http3Advertised - httpHeadersError = nil - case let .empty(message), let .error(message): - httpHeaders = [] - httpSecurityGrade = nil - httpStatusCode = nil - httpResponseTimeMs = nil - httpProtocol = nil - http3Advertised = false - httpHeadersError = message - } - - let reachabilityResults: [PortReachability] - let reachabilityError: String? - switch reachability { - case let .success(results): - reachabilityResults = results - reachabilityError = nil - case let .empty(message), let .error(message): - reachabilityResults = [] - reachabilityError = message - } - - let redirectChain: [RedirectHop] - let redirectChainError: String? - switch redirects { - case let .success(hops): - redirectChain = hops - redirectChainError = nil - case let .empty(message), let .error(message): - redirectChain = [] - redirectChainError = message - } - - let portScanResults: [PortScanResult] - let portScanError: String? - switch ports { - case let .success(results): - portScanResults = await enrichOpenPortBanners(results, domain: domain) - portScanError = nil - case let .empty(message), let .error(message): - portScanResults = [] - portScanError = message - } - - let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] - let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value - - async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords) - async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : [] - - let resolvedEmail = await emailResult - let resolvedSuggestions = await suggestions - let resolvedPTR: ServiceResult<String>? - let resolvedGeo: ServiceResult<IPGeolocation>? - if let primaryIP { - resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) - resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP) - } else { - resolvedPTR = nil - resolvedGeo = nil - } - - guard !Task.isCancelled else { return nil } - - let emailSecurity: EmailSecurityResult? - let emailSecurityError: String? - switch resolvedEmail { - case let .success(result): - emailSecurity = result - emailSecurityError = nil - case let .empty(message), let .error(message): - emailSecurity = nil - emailSecurityError = message - } - - let ownership: DomainOwnership? - let ownershipError: String? - switch resolvedOwnership { - case let .success(result): - ownership = result - ownershipError = nil - case let .empty(message), let .error(message): - ownership = nil - ownershipError = message - } - - let ptrRecord: String? - let ptrError: String? - switch resolvedPTR { - case let .success(record): - ptrRecord = record - ptrError = nil - case let .empty(message), let .error(message): - ptrRecord = nil - ptrError = message - case .none: - ptrRecord = nil - ptrError = "No A record available" - } - - let ipGeolocation: IPGeolocation? - let ipGeolocationError: String? - switch resolvedGeo { - case let .success(result): - ipGeolocation = result - ipGeolocationError = nil - case let .empty(message), let .error(message): - ipGeolocation = nil - ipGeolocationError = message - case .none: - ipGeolocation = nil - ipGeolocationError = "No A record available" - } - - let subdomains: [DiscoveredSubdomain] - let subdomainsError: String? - switch resolvedSubdomains { - case let .success(result): - subdomains = result - subdomainsError = nil - case let .empty(message), let .error(message): - subdomains = [] - subdomainsError = message - } - - let snapshot = LookupSnapshot( - historyEntryID: nil, - domain: domain, - timestamp: Date(), - trackedDomainID: nil, - resolverDisplayName: resolverDisplayName, - resolverURLString: resolverURLString, - totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), - dnsSections: dnsSections, - dnsError: dnsError, - availabilityResult: availability, - suggestions: resolvedSuggestions, - sslInfo: sslInfo, - sslError: sslError, - hstsPreloaded: hsts, - httpHeaders: httpHeaders, - httpSecurityGrade: httpSecurityGrade, - httpStatusCode: httpStatusCode, - httpResponseTimeMs: httpResponseTimeMs, - httpProtocol: httpProtocol, - http3Advertised: http3Advertised, - httpHeadersError: httpHeadersError, - reachabilityResults: reachabilityResults, - reachabilityError: reachabilityError, - ipGeolocation: ipGeolocation, - ipGeolocationError: ipGeolocationError, - emailSecurity: emailSecurity, - emailSecurityError: emailSecurityError, - ownership: ownership, - ownershipError: ownershipError, - ptrRecord: ptrRecord, - ptrError: ptrError, - redirectChain: redirectChain, - redirectChainError: redirectChainError, - subdomains: subdomains, - subdomainsError: subdomainsError, - portScanResults: portScanResults, - portScanError: portScanError, - changeSummary: nil, - isLive: false - ) - return BatchLookupPayload(snapshot: snapshot) } @@ -1967,6 +1652,29 @@ final class DomainViewModel { } } + private func currentBatchReports() -> [DomainReport] { + currentBatchResultEntries.map(report(for:)) + } + + private func reports(for domains: [TrackedDomain]) -> [DomainReport] { + let latestEntries = latestSnapshots(for: domains) + + return domains.map { trackedDomain in + if let entry = latestEntries.first(where: { + $0.trackedDomainID == trackedDomain.id || + $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame + }) { + return report(for: entry) + } + + return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain)) + } + } + + private func report(for entry: HistoryEntry) -> DomainReport { + reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry)) + } + private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { LookupSnapshot( historyEntryID: trackedDomain.lastSnapshotID, diff --git a/DomainDig/ExportPresenter.swift b/DomainDig/ExportPresenter.swift index 82ec7ae..2dbd1fc 100644 --- a/DomainDig/ExportPresenter.swift +++ b/DomainDig/ExportPresenter.swift @@ -3,10 +3,14 @@ import UIKit enum ExportPresenter { static func share(filename: String, contents: String) { + share(filename: filename, data: Data(contents.utf8)) + } + + static func share(filename: String, data: Data) { let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) do { - try contents.write(to: url, atomically: true, encoding: .utf8) + try data.write(to: url, options: .atomic) } catch { return } diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index 1f03a0d..7e42b53 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -143,11 +143,15 @@ struct WatchlistView: View { .disabled(viewModel.batchLookupRunning) Button("Export TXT") { - shareTrackedDomains(asCSV: false) + shareTrackedDomains(format: .text) } Button("Export CSV") { - shareTrackedDomains(asCSV: true) + shareTrackedDomains(format: .csv) + } + + Button("Export JSON") { + shareTrackedDomains(format: .json) } } label: { Image(systemName: "line.3.horizontal.decrease.circle") @@ -178,16 +182,23 @@ struct WatchlistView: View { domains.forEach(viewModel.deleteTrackedDomain) } - private func shareTrackedDomains(asCSV: Bool) { + private func shareTrackedDomains(format: DomainExportFormat) { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmmss" let timestamp = formatter.string(from: Date()) - let fileExtension = asCSV ? "csv" : "txt" - let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)" - let contents = asCSV - ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains) - : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains) - ExportPresenter.share(filename: filename, contents: contents) + let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)" + let data: Data + + switch format { + case .text: + data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8) + case .csv: + data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8) + case .json: + data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8) + } + + ExportPresenter.share(filename: filename, data: data) } } diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift new file mode 100644 index 0000000..73f7028 --- /dev/null +++ b/DomainDigCLI.swift @@ -0,0 +1,39 @@ +import Foundation + +@main +struct DomainDigCLI { + static func main() async { + let arguments = Array(CommandLine.arguments.dropFirst()) + + guard let command = CommandLine.arguments.first else { + fputs("usage: domaindig <domain> [--json]\n", stderr) + Foundation.exit(1) + } + _ = command + + let wantsJSON = arguments.contains("--json") || arguments.contains("-j") + let domains = arguments.filter { !$0.hasPrefix("-") } + + guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + fputs("usage: domaindig <domain> [--json]\n", stderr) + Foundation.exit(1) + } + + let inspectionService = DomainInspectionService() + let report = await inspectionService.inspect(domain: domain) + + do { + let data = try DomainReportExporter.data( + for: report, + format: wantsJSON ? .json : .text + ) + FileHandle.standardOutput.write(data) + if data.last != 0x0A { + FileHandle.standardOutput.write(Data([0x0A])) + } + } catch { + fputs("domaindig: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } +} diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift new file mode 100644 index 0000000..3f11290 --- /dev/null +++ b/DomainInspectionService.swift @@ -0,0 +1,225 @@ +import Foundation + +struct DomainInspectionService { + private let reportBuilder = DomainReportBuilder() + + func inspect(domain: String) async -> DomainReport { + let snapshot = await inspectSnapshot(domain: domain) + return reportBuilder.build(from: snapshot) + } + + func inspectSnapshot(domain: String) async -> LookupSnapshot { + let normalizedDomain = normalize(domain) + let startedAt = Date() + let resolverDisplayName = DNSLookupService.currentResolverDisplayName() + let resolverURLString = DNSLookupService.currentResolverURLString() + + async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain) + async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain) + async let sslResult = SSLCheckService.check(domain: normalizedDomain) + async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain) + async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain) + async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain) + async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain) + async let redirectResult = RedirectChainService.trace(domain: normalizedDomain) + async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain) + async let portScanResult = PortScanService.scanAll(domain: normalizedDomain) + + let resolvedDNS = await dnsResult + let availability = await availabilityResult + let resolvedSSL = await sslResult + let hsts = await hstsResult + let http = await httpResult + let reachability = await reachabilityResult + let resolvedOwnership = await ownershipResult + let redirects = await redirectResult + let resolvedSubdomains = await subdomainResult + let ports = await portScanResult + + let dnsSections = mapServiceResult(resolvedDNS, emptyValue: []) + let sslInfo = mapOptionalValueServiceResult(resolvedSSL) + let httpHeadersResult = mapHTTPResult(http) + let reachabilityResultValue = mapServiceResult(reachability, emptyValue: []) + let redirectChain = mapServiceResult(redirects, emptyValue: []) + let ownership = mapOptionalValueServiceResult(resolvedOwnership) + let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: []) + let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain) + + let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] + let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value + + async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords) + async let suggestions = availability.status == .registered + ? DomainAvailabilityService.suggestions(for: normalizedDomain) + : [] + + let resolvedEmail = await emailResult + let resolvedSuggestions = await suggestions + + let ptrResult: ServiceResult<String>? + let geoResult: ServiceResult<IPGeolocation>? + if let primaryIP { + ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) + geoResult = await IPGeolocationService.lookup(ip: primaryIP) + } else { + ptrResult = nil + geoResult = nil + } + + let emailSecurity = mapOptionalValueServiceResult(resolvedEmail) + let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available") + let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available") + + return LookupSnapshot( + historyEntryID: nil, + domain: availability.domain, + timestamp: Date(), + trackedDomainID: nil, + resolverDisplayName: resolverDisplayName, + resolverURLString: resolverURLString, + totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), + dnsSections: dnsSections.value, + dnsError: dnsSections.message, + availabilityResult: availability, + suggestions: resolvedSuggestions, + sslInfo: sslInfo.value, + sslError: sslInfo.message, + hstsPreloaded: hsts, + httpHeaders: httpHeadersResult.headers, + httpSecurityGrade: httpHeadersResult.securityGrade, + httpStatusCode: httpHeadersResult.statusCode, + httpResponseTimeMs: httpHeadersResult.responseTimeMs, + httpProtocol: httpHeadersResult.httpProtocol, + http3Advertised: httpHeadersResult.http3Advertised, + httpHeadersError: httpHeadersResult.error, + reachabilityResults: reachabilityResultValue.value, + reachabilityError: reachabilityResultValue.message, + ipGeolocation: geolocation.value, + ipGeolocationError: geolocation.message, + emailSecurity: emailSecurity.value, + emailSecurityError: emailSecurity.message, + ownership: ownership.value, + ownershipError: ownership.message, + ptrRecord: ptrRecord.value, + ptrError: ptrRecord.message, + redirectChain: redirectChain.value, + redirectChainError: redirectChain.message, + subdomains: subdomains.value, + subdomainsError: subdomains.message, + portScanResults: portScanResults.value, + portScanError: portScanResults.message, + changeSummary: nil, + isLive: false + ) + } + + private func normalize(_ domain: String) -> String { + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/").first? + .lowercased() ?? domain.lowercased() + } + + private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { + switch result { + case let .success(value): + return (value, nil) + case let .empty(message), let .error(message): + return (emptyValue, message) + } + } + + private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) { + switch result { + case let .success(value): + return (value, nil) + case let .empty(message), let .error(message): + return (nil, message) + } + } + + private func mapOptionalServiceResult<Value>( + _ result: ServiceResult<Value>?, + missingMessage: String + ) -> (value: Value?, message: String?) { + guard let result else { + return (nil, missingMessage) + } + + switch result { + case let .success(value): + return (value, nil) + case let .empty(message), let .error(message): + return (nil, message) + } + } + + private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) { + switch result { + case let .success(results): + return (await enrichOpenPortBanners(in: results, domain: domain), nil) + case let .empty(message), let .error(message): + return ([], message) + } + } + + private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> ( + headers: [HTTPHeader], + securityGrade: String?, + statusCode: Int?, + responseTimeMs: Int?, + httpProtocol: String?, + http3Advertised: Bool, + error: String? + ) { + switch result { + case let .success(value): + return ( + headers: value.headers, + securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue, + statusCode: value.statusCode, + responseTimeMs: value.responseTimeMs, + httpProtocol: value.httpProtocol, + http3Advertised: value.http3Advertised, + error: nil + ) + case let .empty(message), let .error(message): + return ( + headers: [], + securityGrade: nil, + statusCode: nil, + responseTimeMs: nil, + httpProtocol: nil, + http3Advertised: false, + error: message + ) + } + } + + private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] { + let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in + for result in results where result.open { + group.addTask { + let banner = await PortScanService.grabBanner(host: domain, port: result.port) + return (result.port, banner) + } + } + + var collected: [UInt16: String] = [:] + for await (port, banner) in group { + if let banner { + collected[port] = banner + } + } + return collected + } + + return results.map { result in + var updated = result + updated.banner = banners[result.port] + return updated + } + } +} diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift new file mode 100644 index 0000000..8cb5c82 --- /dev/null +++ b/DomainReportBuilder.swift @@ -0,0 +1,190 @@ +import Foundation + +struct DomainReport: Codable { + let domain: String + let timestamp: Date + let availability: DomainAvailabilityStatus + let ownership: DomainOwnership? + let dns: DNSResultSummary + let web: WebResultSummary + let email: EmailSecuritySummary + let network: NetworkSummary + let subdomains: [String] + let changeSummary: DomainChangeSummary? +} + +struct DNSResultSummary: Codable { + let resolverDisplayName: String + let resolverURLString: String + let lookupDurationMs: Int? + let recordSections: [DNSSection] + let primaryIP: String? + let ptrRecord: String? + let dnssecSigned: Bool? + let error: String? + let ptrError: String? +} + +struct WebResultSummary: Codable { + let tls: SSLCertificateInfo? + let tlsStatus: String + let certificateWarningLevel: CertificateWarningLevel + let hstsPreloaded: Bool? + let headers: [HTTPHeader] + let headerCount: Int + let securityGrade: String? + let statusCode: Int? + let responseTimeMs: Int? + let httpProtocol: String? + let http3Advertised: Bool + let redirectChain: [RedirectHop] + let finalURL: String? + let tlsError: String? + let headersError: String? + let redirectError: String? +} + +struct EmailSecuritySummary: Codable { + let records: EmailSecurityResult? + let summary: String + let error: String? +} + +struct NetworkSummary: Codable { + let primaryIP: String? + let reachability: [PortReachability] + let reachabilitySummary: String + let reachabilityError: String? + let geolocation: IPGeolocation? + let geolocationSummary: String + let geolocationError: String? + let portScan: [PortScanResult] + let openPorts: [UInt16] + let portScanError: String? +} + +struct DomainReportBuilder { + func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { + let primaryIP = primaryIPAddress(from: snapshot) + + return DomainReport( + domain: snapshot.domain, + timestamp: snapshot.timestamp, + availability: snapshot.availabilityResult?.status ?? .unknown, + ownership: snapshot.ownership, + dns: DNSResultSummary( + resolverDisplayName: snapshot.resolverDisplayName, + resolverURLString: snapshot.resolverURLString, + lookupDurationMs: snapshot.totalLookupDurationMs, + recordSections: snapshot.dnsSections, + primaryIP: primaryIP, + ptrRecord: snapshot.ptrRecord, + dnssecSigned: dnssecSigned(from: snapshot), + error: snapshot.dnsError, + ptrError: snapshot.ptrError + ), + web: WebResultSummary( + tls: snapshot.sslInfo, + tlsStatus: tlsStatus(from: snapshot), + certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), + hstsPreloaded: snapshot.hstsPreloaded, + headers: snapshot.httpHeaders, + headerCount: snapshot.httpHeaders.count, + securityGrade: snapshot.httpSecurityGrade, + statusCode: snapshot.httpStatusCode, + responseTimeMs: snapshot.httpResponseTimeMs, + httpProtocol: snapshot.httpProtocol, + http3Advertised: snapshot.http3Advertised, + redirectChain: snapshot.redirectChain, + finalURL: snapshot.redirectChain.last?.url, + tlsError: snapshot.sslError, + headersError: snapshot.httpHeadersError, + redirectError: snapshot.redirectChainError + ), + email: EmailSecuritySummary( + records: snapshot.emailSecurity, + summary: emailSummary(from: snapshot), + error: snapshot.emailSecurityError + ), + network: NetworkSummary( + primaryIP: primaryIP, + reachability: snapshot.reachabilityResults, + reachabilitySummary: reachabilitySummary(from: snapshot), + reachabilityError: snapshot.reachabilityError, + geolocation: snapshot.ipGeolocation, + geolocationSummary: geolocationSummary(from: snapshot), + geolocationError: snapshot.ipGeolocationError, + portScan: snapshot.portScanResults, + openPorts: snapshot.portScanResults.filter(\.open).map(\.port), + portScanError: snapshot.portScanError + ), + subdomains: snapshot.subdomains.map(\.hostname), + changeSummary: snapshot.changeSummary ?? previousSnapshot.map { + DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp) + } + ) + } + + func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { + build(from: entry.snapshot, previousSnapshot: previousSnapshot) + } + + private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { + snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value + } + + private func dnssecSigned(from snapshot: LookupSnapshot) -> Bool? { + snapshot.dnsSections.compactMap(\.dnssecSigned).first + } + + private func tlsStatus(from snapshot: LookupSnapshot) -> String { + if snapshot.sslInfo != nil { + return "valid" + } + if let sslError = snapshot.sslError { + return sslError.localizedCaseInsensitiveContains("certificate") ? "invalid" : "failed" + } + return "unavailable" + } + + private func emailSummary(from snapshot: LookupSnapshot) -> String { + guard let emailSecurity = snapshot.emailSecurity else { + return snapshot.emailSecurityError ?? "Unavailable" + } + + return [ + "SPF \(emailSecurity.spf.found ? "Yes" : "No")", + "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")", + "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")", + "BIMI \(emailSecurity.bimi.found ? "Yes" : "No")", + "MTA-STS \(emailSecurity.mtaSts?.txtFound == true ? "Yes" : "No")" + ].joined(separator: " / ") + } + + private func reachabilitySummary(from snapshot: LookupSnapshot) -> String { + guard !snapshot.reachabilityResults.isEmpty else { + return snapshot.reachabilityError ?? "Unavailable" + } + + return snapshot.reachabilityResults + .sorted { $0.port < $1.port } + .map { "\($0.port):\($0.reachable ? "open" : "closed")" } + .joined(separator: ", ") + } + + private func geolocationSummary(from snapshot: LookupSnapshot) -> String { + guard let geolocation = snapshot.ipGeolocation else { + return snapshot.ipGeolocationError ?? "Unavailable" + } + + let parts = [geolocation.city, geolocation.region, geolocation.country_name] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + if !parts.isEmpty { + return parts.joined(separator: ", ") + } + + return geolocation.ip + } +} diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift new file mode 100644 index 0000000..19271fa --- /dev/null +++ b/DomainReportExporter.swift @@ -0,0 +1,367 @@ +import Foundation + +enum DomainExportFormat: String { + case text = "txt" + case csv = "csv" + case json = "json" + + var fileExtension: String { rawValue } +} + +enum DomainReportExporter { + static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data { + switch format { + case .text: + return Data(text(for: report).utf8) + case .csv: + return Data(csv(for: [report]).utf8) + case .json: + return try jsonEncoder.encode(report) + } + } + + static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data { + switch format { + case .text: + return Data(batchText(for: reports, title: title).utf8) + case .csv: + return Data(csv(for: reports).utf8) + case .json: + return try jsonEncoder.encode(reports) + } + } + + static func text(for report: DomainReport) -> String { + var lines = [ + "DomainDig Report", + "Domain: \(report.domain)", + "Timestamp: \(textDateFormatter.string(from: report.timestamp))", + "Availability: \(availabilityLabel(report.availability))" + ] + + appendSection("Summary", to: &lines) { + [ + "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", + "TLS Status: \(report.web.tlsStatus)", + "HTTP: \(httpSummary(for: report))", + "Email: \(report.email.summary)", + "Subdomains: \(report.subdomains.count)" + ] + } + + appendSection("Ownership", to: &lines) { + var ownershipLines = [ + "Registrar: \(report.ownership?.registrar ?? "Unavailable")", + "Created: \(ownershipDateLabel(report.ownership?.createdDate))", + "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", + "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", + "Status: \(joined(report.ownership?.status) ?? "Unavailable")", + "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" + ] + if let error = report.dns.error, report.ownership == nil { + ownershipLines.append("Error: \(error)") + } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil { + _ = error + } + return ownershipLines + } + + appendSection("DNS", to: &lines) { + var dnsLines = [ + "Resolver: \(report.dns.resolverDisplayName)", + "Resolver URL: \(report.dns.resolverURLString)", + "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", + "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", + "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")", + "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))" + ] + if let error = report.dns.error { + dnsLines.append("Error: \(error)") + } + if report.dns.recordSections.isEmpty { + dnsLines.append("Records: None") + } else { + dnsLines.append("Records:") + for section in report.dns.recordSections { + let values = (section.records + section.wildcardRecords).map(\.value) + let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ") + dnsLines.append(" \(section.recordType.rawValue): \(renderedValues)") + } + } + return dnsLines + } + + appendSection("Web", to: &lines) { + var webLines = [ + "TLS Status: \(report.web.tlsStatus)", + "Certificate Warning: \(report.web.certificateWarningLevel.title)", + "Security Grade: \(report.web.securityGrade ?? "Unavailable")", + "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")", + "Protocol: \(report.web.httpProtocol ?? "Unavailable")", + "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")", + "Final URL: \(report.web.finalURL ?? "Unavailable")", + "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))", + "Header Count: \(report.web.headerCount)" + ] + if let tlsError = report.web.tlsError { + webLines.append("TLS Error: \(tlsError)") + } + if let headersError = report.web.headersError { + webLines.append("Headers Error: \(headersError)") + } + if let redirectError = report.web.redirectError { + webLines.append("Redirect Error: \(redirectError)") + } + if !report.web.headers.isEmpty { + webLines.append("Headers:") + for header in report.web.headers { + webLines.append(" \(header.name): \(header.value)") + } + } + if !report.web.redirectChain.isEmpty { + webLines.append("Redirect Chain:") + for hop in report.web.redirectChain { + webLines.append(" \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")") + } + } + return webLines + } + + appendSection("Email", to: &lines) { + var emailLines = [report.email.summary] + if let records = report.email.records { + emailLines.append("SPF: \(recordLabel(records.spf))") + emailLines.append("DMARC: \(recordLabel(records.dmarc))") + emailLines.append("DKIM: \(recordLabel(records.dkim))") + emailLines.append("BIMI: \(recordLabel(records.bimi))") + emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")") + } + if let error = report.email.error { + emailLines.append("Error: \(error)") + } + return emailLines + } + + appendSection("Network", to: &lines) { + var networkLines = [ + "Reachability: \(report.network.reachabilitySummary)", + "Geolocation: \(report.network.geolocationSummary)", + "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")" + ] + if let error = report.network.reachabilityError { + networkLines.append("Reachability Error: \(error)") + } + if let error = report.network.geolocationError { + networkLines.append("Geolocation Error: \(error)") + } + if let error = report.network.portScanError { + networkLines.append("Port Scan Error: \(error)") + } + if !report.network.portScan.isEmpty { + networkLines.append("Port Scan:") + for result in report.network.portScan { + networkLines.append( + " \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")" + ) + } + } + return networkLines + } + + appendSection("Subdomains", to: &lines) { + if report.subdomains.isEmpty { + return ["None"] + } + return report.subdomains.map { "- \($0)" } + } + + appendSection("Changes", to: &lines) { + guard let changeSummary = report.changeSummary else { + return ["No comparison available"] + } + + return [ + "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")", + "Severity: \(changeSummary.severity.title)", + "Summary: \(changeSummary.message)", + "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))" + ] + } + + return lines.joined(separator: "\n") + } + + static func batchText(for reports: [DomainReport], title: String) -> String { + guard !reports.isEmpty else { + return "\(title)\nNo results available." + } + + var lines = [title, String(repeating: "=", count: title.count), ""] + for (index, report) in reports.enumerated() { + if index > 0 { + lines.append("") + lines.append(String(repeating: "=", count: 48)) + lines.append("") + } + lines.append(text(for: report)) + } + return lines.joined(separator: "\n") + } + + static func csv(for reports: [DomainReport]) -> String { + let headers = [ + "domain", + "timestamp", + "availability", + "registrar", + "ownership_expires", + "nameservers", + "primary_ip", + "ptr_record", + "dnssec_signed", + "tls_status", + "certificate_warning_level", + "hsts_preloaded", + "http_status", + "http_security_grade", + "final_url", + "email_summary", + "subdomain_count", + "subdomains", + "open_ports", + "reachability_summary", + "geolocation_summary", + "change_summary" + ] + + let rows = reports.map { report in + let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "" + let nameservers = joined(report.ownership?.nameservers) ?? "" + let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? "" + let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? "" + let httpStatus = report.web.statusCode.map(String.init) ?? "" + let subdomainCount = String(report.subdomains.count) + let subdomains = report.subdomains.joined(separator: " | ") + let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ") + + return [ + report.domain, + csvDateFormatter.string(from: report.timestamp), + availabilityLabel(report.availability), + report.ownership?.registrar ?? "", + expirationDate, + nameservers, + report.dns.primaryIP ?? "", + report.dns.ptrRecord ?? "", + dnssecSigned, + report.web.tlsStatus, + report.web.certificateWarningLevel.rawValue, + hstsPreloaded, + httpStatus, + report.web.securityGrade ?? "", + report.web.finalURL ?? "", + report.email.summary, + subdomainCount, + subdomains, + openPorts, + report.network.reachabilitySummary, + report.network.geolocationSummary, + report.changeSummary?.message ?? "" + ] + } + + return ([headers] + rows) + .map { row in row.map(csvEscaped).joined(separator: ",") } + .joined(separator: "\n") + } + + private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) { + lines.append("") + lines.append(title) + lines.append(String(repeating: "-", count: title.count)) + lines.append(contentsOf: body()) + } + + private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String { + switch status { + case .available: + return "Available" + case .registered: + return "Registered" + case .unknown: + return "Unknown" + } + } + + private static func joined(_ values: [String]?) -> String? { + guard let values, !values.isEmpty else { return nil } + return values.joined(separator: " | ") + } + + private static func dnssecLabel(_ value: Bool?) -> String { + switch value { + case true: + return "Signed" + case false: + return "Unsigned" + case nil: + return "Unavailable" + } + } + + private static func ownershipDateLabel(_ date: Date?) -> String { + guard let date else { return "Unavailable" } + return textDateFormatter.string(from: date) + } + + private static func durationLabel(_ durationMs: Int?) -> String { + durationMs.map { "\($0) ms" } ?? "Unavailable" + } + + private static func httpSummary(for report: DomainReport) -> String { + let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 } + return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ") + } + + private static func booleanLabel(_ value: Bool?) -> String { + guard let value else { return "Unavailable" } + return value ? "Yes" : "No" + } + + private static func recordLabel(_ record: EmailSecurityRecord) -> String { + if record.found { + return record.value ?? "Present" + } + return "Unavailable" + } + + private static let textDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() + + private static let csvDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + private static let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + nonisolated private static func csvEscaped(_ value: String) -> String { + let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift new file mode 100644 index 0000000..3a35eff --- /dev/null +++ b/LookupSnapshot.swift @@ -0,0 +1,89 @@ +import Foundation + +struct LookupSnapshot { + let historyEntryID: UUID? + let domain: String + let timestamp: Date + let trackedDomainID: UUID? + let resolverDisplayName: String + let resolverURLString: String + let totalLookupDurationMs: Int? + let dnsSections: [DNSSection] + let dnsError: String? + let availabilityResult: DomainAvailabilityResult? + let suggestions: [DomainSuggestionResult] + let sslInfo: SSLCertificateInfo? + let sslError: String? + let hstsPreloaded: Bool? + let httpHeaders: [HTTPHeader] + let httpSecurityGrade: String? + let httpStatusCode: Int? + let httpResponseTimeMs: Int? + let httpProtocol: String? + let http3Advertised: Bool + let httpHeadersError: String? + let reachabilityResults: [PortReachability] + let reachabilityError: String? + let ipGeolocation: IPGeolocation? + let ipGeolocationError: String? + let emailSecurity: EmailSecurityResult? + let emailSecurityError: String? + let ownership: DomainOwnership? + let ownershipError: String? + let ptrRecord: String? + let ptrError: String? + let redirectChain: [RedirectHop] + let redirectChainError: String? + let subdomains: [DiscoveredSubdomain] + let subdomainsError: String? + let portScanResults: [PortScanResult] + let portScanError: String? + let changeSummary: DomainChangeSummary? + let isLive: Bool +} + +extension HistoryEntry { + var snapshot: LookupSnapshot { + LookupSnapshot( + historyEntryID: id, + domain: domain, + timestamp: timestamp, + trackedDomainID: trackedDomainID, + resolverDisplayName: resolverDisplayName, + resolverURLString: resolverURLString, + totalLookupDurationMs: totalLookupDurationMs, + dnsSections: dnsSections, + dnsError: nil, + availabilityResult: availabilityResult, + suggestions: suggestions, + sslInfo: sslInfo, + sslError: sslError, + hstsPreloaded: hstsPreloaded, + httpHeaders: httpHeaders, + httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue, + httpStatusCode: nil, + httpResponseTimeMs: nil, + httpProtocol: nil, + http3Advertised: false, + httpHeadersError: httpHeadersError, + reachabilityResults: reachabilityResults, + reachabilityError: reachabilityError, + ipGeolocation: ipGeolocation, + ipGeolocationError: ipGeolocationError, + emailSecurity: emailSecurity, + emailSecurityError: emailSecurityError, + ownership: ownership, + ownershipError: ownershipError, + ptrRecord: ptrRecord, + ptrError: ptrError, + redirectChain: redirectChain, + redirectChainError: redirectChainError, + subdomains: subdomains, + subdomainsError: subdomainsError, + portScanResults: portScanResults, + portScanError: portScanError, + changeSummary: changeSummary, + isLive: false + ) + } +} |
