Commit e73d58ee5d
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +119 −4
| @@ -6,25 +6,59 @@ | ||
| 6 | 6 | objectVersion = 77; |
| 7 | 7 | objects = { |
| 8 | 8 | |
| 9 | /* Begin PBXBuildFile section */ | |
| 10 | 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */; }; | |
| 11 | 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; | |
| 12 | 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; | |
| 13 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; | |
| 14 | 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; | |
| 15 | /* End PBXBuildFile section */ | |
| 16 | ||
| 9 | 17 | /* Begin PBXFileReference section */ |
| 10 | 18 | 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 19 | 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; | |
| 20 | 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; }; | |
| 21 | 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; | |
| 22 | 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; }; | |
| 23 | 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; | |
| 24 | 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; | |
| 11 | 25 | /* End PBXFileReference section */ |
| 12 | 26 | |
| 13 | 27 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| 14 | 28 | 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = { |
| 15 | 29 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; |
| 16 | 30 | membershipExceptions = ( |
| 31 | DomainDigCLI.swift, | |
| 17 | 32 | Info.plist, |
| 18 | 33 | ); |
| 19 | 34 | target = 8B7800682F6090E300933221 /* DomainDig */; |
| 20 | 35 | }; |
| 21 | 36 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| 37 | 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = { | |
| 38 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | |
| 39 | membershipExceptions = ( | |
| 40 | Assets.xcassets, | |
| 41 | BatchResultsView.swift, | |
| 42 | BatchSweepSummaryView.swift, | |
| 43 | ContentView.swift, | |
| 44 | DomainDigApp.swift, | |
| 45 | DomainViewModel.swift, | |
| 46 | ExportPresenter.swift, | |
| 47 | HistoryView.swift, | |
| 48 | Info.plist, | |
| 49 | LocalNotificationService.swift, | |
| 50 | SavedDomainsView.swift, | |
| 51 | WatchlistView.swift, | |
| 52 | ); | |
| 53 | target = 8BF124FB2F70000100933221 /* DomainDigCLI */; | |
| 54 | }; | |
| 22 | 55 | |
| 23 | 56 | /* Begin PBXFileSystemSynchronizedRootGroup section */ |
| 24 | 57 | 8B78006B2F6090E300933221 /* DomainDig */ = { |
| 25 | 58 | isa = PBXFileSystemSynchronizedRootGroup; |
| 26 | 59 | exceptions = ( |
| 27 | 60 | 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */, |
| 61 | 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */, | |
| 28 | 62 | ); |
| 29 | 63 | path = DomainDig; |
| 30 | 64 | sourceTree = "<group>"; |
| @@ -39,6 +73,13 @@ | ||
| 39 | 73 | ); |
| 40 | 74 | runOnlyForDeploymentPostprocessing = 0; |
| 41 | 75 | }; |
| 76 | 8BF124FD2F70000100933221 /* Frameworks */ = { | |
| 77 | isa = PBXFrameworksBuildPhase; | |
| 78 | buildActionMask = 2147483647; | |
| 79 | files = ( | |
| 80 | ); | |
| 81 | runOnlyForDeploymentPostprocessing = 0; | |
| 82 | }; | |
| 42 | 83 | /* End PBXFrameworksBuildPhase section */ |
| 43 | 84 | |
| 44 | 85 | /* Begin PBXGroup section */ |
| @@ -47,6 +88,11 @@ | ||
| 47 | 88 | children = ( |
| 48 | 89 | 8B78006B2F6090E300933221 /* DomainDig */, |
| 49 | 90 | 8B78006A2F6090E300933221 /* Products */, |
| 91 | 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */, | |
| 92 | 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */, | |
| 93 | 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */, | |
| 94 | 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */, | |
| 95 | 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */, | |
| 50 | 96 | ); |
| 51 | 97 | sourceTree = "<group>"; |
| 52 | 98 | }; |
| @@ -54,6 +100,7 @@ | ||
| 54 | 100 | isa = PBXGroup; |
| 55 | 101 | children = ( |
| 56 | 102 | 8B7800692F6090E300933221 /* DomainDig.app */, |
| 103 | 8BF124F92F70000100933221 /* domaindig */, | |
| 57 | 104 | ); |
| 58 | 105 | name = Products; |
| 59 | 106 | sourceTree = "<group>"; |
| @@ -83,6 +130,27 @@ | ||
| 83 | 130 | productReference = 8B7800692F6090E300933221 /* DomainDig.app */; |
| 84 | 131 | productType = "com.apple.product-type.application"; |
| 85 | 132 | }; |
| 133 | 8BF124FB2F70000100933221 /* DomainDigCLI */ = { | |
| 134 | isa = PBXNativeTarget; | |
| 135 | buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */; | |
| 136 | buildPhases = ( | |
| 137 | 8BF124FC2F70000100933221 /* Sources */, | |
| 138 | 8BF124FD2F70000100933221 /* Frameworks */, | |
| 139 | ); | |
| 140 | buildRules = ( | |
| 141 | ); | |
| 142 | dependencies = ( | |
| 143 | ); | |
| 144 | fileSystemSynchronizedGroups = ( | |
| 145 | 8B78006B2F6090E300933221 /* DomainDig */, | |
| 146 | ); | |
| 147 | name = DomainDigCLI; | |
| 148 | packageProductDependencies = ( | |
| 149 | ); | |
| 150 | productName = domaindig; | |
| 151 | productReference = 8BF124F92F70000100933221 /* domaindig */; | |
| 152 | productType = "com.apple.product-type.tool"; | |
| 153 | }; | |
| 86 | 154 | /* End PBXNativeTarget section */ |
| 87 | 155 | |
| 88 | 156 | /* Begin PBXProject section */ |
| @@ -113,6 +181,7 @@ | ||
| 113 | 181 | projectRoot = ""; |
| 114 | 182 | targets = ( |
| 115 | 183 | 8B7800682F6090E300933221 /* DomainDig */, |
| 184 | 8BF124FB2F70000100933221 /* DomainDigCLI */, | |
| 116 | 185 | ); |
| 117 | 186 | }; |
| 118 | 187 | /* End PBXProject section */ |
| @@ -132,6 +201,11 @@ | ||
| 132 | 201 | isa = PBXSourcesBuildPhase; |
| 133 | 202 | buildActionMask = 2147483647; |
| 134 | 203 | files = ( |
| 204 | 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, | |
| 205 | 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, | |
| 206 | 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, | |
| 207 | 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, | |
| 208 | 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */, | |
| 135 | 209 | ); |
| 136 | 210 | runOnlyForDeploymentPostprocessing = 0; |
| 137 | 211 | }; |
| @@ -267,7 +341,7 @@ | ||
| 267 | 341 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 268 | 342 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 269 | 343 | CODE_SIGN_STYLE = Automatic; |
| 270 | CURRENT_PROJECT_VERSION = 16; | |
| 344 | CURRENT_PROJECT_VERSION = 17; | |
| 271 | 345 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 272 | 346 | ENABLE_PREVIEWS = YES; |
| 273 | 347 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -284,7 +358,7 @@ | ||
| 284 | 358 | "$(inherited)", |
| 285 | 359 | "@executable_path/Frameworks", |
| 286 | 360 | ); |
| 287 | MARKETING_VERSION = 2.3.0; | |
| 361 | MARKETING_VERSION = 2.4.0; | |
| 288 | 362 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 289 | 363 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 290 | 364 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -303,7 +377,7 @@ | ||
| 303 | 377 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 304 | 378 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 305 | 379 | CODE_SIGN_STYLE = Automatic; |
| 306 | CURRENT_PROJECT_VERSION = 16; | |
| 380 | CURRENT_PROJECT_VERSION = 17; | |
| 307 | 381 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 308 | 382 | ENABLE_PREVIEWS = YES; |
| 309 | 383 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -320,7 +394,7 @@ | ||
| 320 | 394 | "$(inherited)", |
| 321 | 395 | "@executable_path/Frameworks", |
| 322 | 396 | ); |
| 323 | MARKETING_VERSION = 2.3.0; | |
| 397 | MARKETING_VERSION = 2.4.0; | |
| 324 | 398 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 325 | 399 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 326 | 400 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -333,6 +407,47 @@ | ||
| 333 | 407 | }; |
| 334 | 408 | name = Release; |
| 335 | 409 | }; |
| 410 | 8BF125002F70000100933221 /* Debug */ = { | |
| 411 | isa = XCBuildConfiguration; | |
| 412 | buildSettings = { | |
| 413 | CODE_SIGNING_ALLOWED = NO; | |
| 414 | CODE_SIGN_STYLE = Automatic; | |
| 415 | CURRENT_PROJECT_VERSION = 17; | |
| 416 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 417 | ENABLE_TESTABILITY = YES; | |
| 418 | GENERATE_INFOPLIST_FILE = YES; | |
| 419 | MACOSX_DEPLOYMENT_TARGET = 14.0; | |
| 420 | MARKETING_VERSION = 2.4.0; | |
| 421 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI; | |
| 422 | PRODUCT_NAME = domaindig; | |
| 423 | SDKROOT = macosx; | |
| 424 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | |
| 425 | SWIFT_EMIT_LOC_STRINGS = NO; | |
| 426 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | |
| 427 | SWIFT_VERSION = 5.0; | |
| 428 | }; | |
| 429 | name = Debug; | |
| 430 | }; | |
| 431 | 8BF125012F70000100933221 /* Release */ = { | |
| 432 | isa = XCBuildConfiguration; | |
| 433 | buildSettings = { | |
| 434 | CODE_SIGNING_ALLOWED = NO; | |
| 435 | CODE_SIGN_STYLE = Automatic; | |
| 436 | CURRENT_PROJECT_VERSION = 17; | |
| 437 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 438 | GENERATE_INFOPLIST_FILE = YES; | |
| 439 | MACOSX_DEPLOYMENT_TARGET = 14.0; | |
| 440 | MARKETING_VERSION = 2.4.0; | |
| 441 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI; | |
| 442 | PRODUCT_NAME = domaindig; | |
| 443 | SDKROOT = macosx; | |
| 444 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | |
| 445 | SWIFT_EMIT_LOC_STRINGS = NO; | |
| 446 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | |
| 447 | SWIFT_VERSION = 5.0; | |
| 448 | }; | |
| 449 | name = Release; | |
| 450 | }; | |
| 336 | 451 | /* End XCBuildConfiguration section */ |
| 337 | 452 | |
| 338 | 453 | /* Begin XCConfigurationList section */ |
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +5
| @@ -5,6 +5,11 @@ | ||
| 5 | 5 | <key>SchemeUserState</key> |
| 6 | 6 | <dict> |
| 7 | 7 | <key>DomainDig.xcscheme_^#shared#^_</key> |
| 8 | <dict> | |
| 9 | <key>orderHint</key> | |
| 10 | <integer>1</integer> | |
| 11 | </dict> | |
| 12 | <key>DomainDigCLI.xcscheme_^#shared#^_</key> | |
| 8 | 13 | <dict> |
| 9 | 14 | <key>orderHint</key> |
| 10 | 15 | <integer>0</integer> |
DomainDig/ContentView.swift +40 −16
| @@ -304,10 +304,13 @@ struct ContentView: View { | ||
| 304 | 304 | } |
| 305 | 305 | Menu { |
| 306 | 306 | Button("Export TXT") { |
| 307 | shareSingleResults(asCSV: false) | |
| 307 | shareSingleResults(format: .text) | |
| 308 | 308 | } |
| 309 | 309 | Button("Export CSV") { |
| 310 | shareSingleResults(asCSV: true) | |
| 310 | shareSingleResults(format: .csv) | |
| 311 | } | |
| 312 | Button("Export JSON") { | |
| 313 | shareSingleResults(format: .json) | |
| 311 | 314 | } |
| 312 | 315 | } label: { |
| 313 | 316 | Image(systemName: "square.and.arrow.up") |
| @@ -332,10 +335,13 @@ struct ContentView: View { | ||
| 332 | 335 | if !viewModel.currentBatchResultEntries.isEmpty { |
| 333 | 336 | Menu { |
| 334 | 337 | Button("Export Batch TXT") { |
| 335 | shareBatchResults(asCSV: false) | |
| 338 | shareBatchResults(format: .text) | |
| 336 | 339 | } |
| 337 | 340 | Button("Export Batch CSV") { |
| 338 | shareBatchResults(asCSV: true) | |
| 341 | shareBatchResults(format: .csv) | |
| 342 | } | |
| 343 | Button("Export Batch JSON") { | |
| 344 | shareBatchResults(format: .json) | |
| 339 | 345 | } |
| 340 | 346 | } label: { |
| 341 | 347 | Label("Export", systemImage: "square.and.arrow.up") |
| @@ -412,33 +418,51 @@ struct ContentView: View { | ||
| 412 | 418 | return ports |
| 413 | 419 | } |
| 414 | 420 | |
| 415 | private func shareSingleResults(asCSV: Bool) { | |
| 416 | let (filename, contents) = exportPayload( | |
| 421 | private func shareSingleResults(format: DomainExportFormat) { | |
| 422 | let (filename, data) = exportPayload( | |
| 417 | 423 | prefix: "domaindig_single", |
| 424 | format: format, | |
| 418 | 425 | text: viewModel.exportText(), |
| 419 | 426 | csv: viewModel.exportCSV(), |
| 420 | asCSV: asCSV | |
| 427 | json: viewModel.exportJSONData() | |
| 421 | 428 | ) |
| 422 | ExportPresenter.share(filename: filename, contents: contents) | |
| 429 | ExportPresenter.share(filename: filename, data: data) | |
| 423 | 430 | } |
| 424 | 431 | |
| 425 | private func shareBatchResults(asCSV: Bool) { | |
| 426 | let (filename, contents) = exportPayload( | |
| 432 | private func shareBatchResults(format: DomainExportFormat) { | |
| 433 | let (filename, data) = exportPayload( | |
| 427 | 434 | prefix: "domaindig_batch", |
| 435 | format: format, | |
| 428 | 436 | text: viewModel.exportBatchText(), |
| 429 | 437 | csv: viewModel.exportBatchCSV(), |
| 430 | asCSV: asCSV | |
| 438 | json: viewModel.exportBatchJSONData() | |
| 431 | 439 | ) |
| 432 | ExportPresenter.share(filename: filename, contents: contents) | |
| 440 | ExportPresenter.share(filename: filename, data: data) | |
| 433 | 441 | } |
| 434 | 442 | |
| 435 | private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) { | |
| 443 | private func exportPayload( | |
| 444 | prefix: String, | |
| 445 | format: DomainExportFormat, | |
| 446 | text: String, | |
| 447 | csv: String, | |
| 448 | json: Data? | |
| 449 | ) -> (String, Data) { | |
| 436 | 450 | let formatter = DateFormatter() |
| 437 | 451 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 438 | 452 | let timestamp = formatter.string(from: Date()) |
| 439 | let fileExtension = asCSV ? "csv" : "txt" | |
| 440 | let filename = "\(timestamp)_\(prefix).\(fileExtension)" | |
| 441 | return (filename, asCSV ? csv : text) | |
| 453 | let filename = "\(timestamp)_\(prefix).\(format.fileExtension)" | |
| 454 | let data: Data | |
| 455 | ||
| 456 | switch format { | |
| 457 | case .text: | |
| 458 | data = Data(text.utf8) | |
| 459 | case .csv: | |
| 460 | data = Data(csv.utf8) | |
| 461 | case .json: | |
| 462 | data = json ?? Data("[]".utf8) | |
| 463 | } | |
| 464 | ||
| 465 | return (filename, data) | |
| 442 | 466 | } |
| 443 | 467 | } |
| 444 | 468 | |
DomainDig/DomainViewModel.swift +69 −361
| @@ -91,94 +91,6 @@ struct DomainSuggestionViewData: Identifiable { | ||
| 91 | 91 | let tone: ResultTone |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | struct LookupSnapshot { | |
| 95 | let historyEntryID: UUID? | |
| 96 | let domain: String | |
| 97 | let timestamp: Date | |
| 98 | let trackedDomainID: UUID? | |
| 99 | let resolverDisplayName: String | |
| 100 | let resolverURLString: String | |
| 101 | let totalLookupDurationMs: Int? | |
| 102 | let dnsSections: [DNSSection] | |
| 103 | let dnsError: String? | |
| 104 | let availabilityResult: DomainAvailabilityResult? | |
| 105 | let suggestions: [DomainSuggestionResult] | |
| 106 | let sslInfo: SSLCertificateInfo? | |
| 107 | let sslError: String? | |
| 108 | let hstsPreloaded: Bool? | |
| 109 | let httpHeaders: [HTTPHeader] | |
| 110 | let httpSecurityGrade: String? | |
| 111 | let httpStatusCode: Int? | |
| 112 | let httpResponseTimeMs: Int? | |
| 113 | let httpProtocol: String? | |
| 114 | let http3Advertised: Bool | |
| 115 | let httpHeadersError: String? | |
| 116 | let reachabilityResults: [PortReachability] | |
| 117 | let reachabilityError: String? | |
| 118 | let ipGeolocation: IPGeolocation? | |
| 119 | let ipGeolocationError: String? | |
| 120 | let emailSecurity: EmailSecurityResult? | |
| 121 | let emailSecurityError: String? | |
| 122 | let ownership: DomainOwnership? | |
| 123 | let ownershipError: String? | |
| 124 | let ptrRecord: String? | |
| 125 | let ptrError: String? | |
| 126 | let redirectChain: [RedirectHop] | |
| 127 | let redirectChainError: String? | |
| 128 | let subdomains: [DiscoveredSubdomain] | |
| 129 | let subdomainsError: String? | |
| 130 | let portScanResults: [PortScanResult] | |
| 131 | let portScanError: String? | |
| 132 | let changeSummary: DomainChangeSummary? | |
| 133 | let isLive: Bool | |
| 134 | } | |
| 135 | ||
| 136 | extension HistoryEntry { | |
| 137 | var snapshot: LookupSnapshot { | |
| 138 | LookupSnapshot( | |
| 139 | historyEntryID: id, | |
| 140 | domain: domain, | |
| 141 | timestamp: timestamp, | |
| 142 | trackedDomainID: trackedDomainID, | |
| 143 | resolverDisplayName: resolverDisplayName, | |
| 144 | resolverURLString: resolverURLString, | |
| 145 | totalLookupDurationMs: totalLookupDurationMs, | |
| 146 | dnsSections: dnsSections, | |
| 147 | dnsError: nil, | |
| 148 | availabilityResult: availabilityResult, | |
| 149 | suggestions: suggestions, | |
| 150 | sslInfo: sslInfo, | |
| 151 | sslError: sslError, | |
| 152 | hstsPreloaded: hstsPreloaded, | |
| 153 | httpHeaders: httpHeaders, | |
| 154 | httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue, | |
| 155 | httpStatusCode: nil, | |
| 156 | httpResponseTimeMs: nil, | |
| 157 | httpProtocol: nil, | |
| 158 | http3Advertised: false, | |
| 159 | httpHeadersError: httpHeadersError, | |
| 160 | reachabilityResults: reachabilityResults, | |
| 161 | reachabilityError: reachabilityError, | |
| 162 | ipGeolocation: ipGeolocation, | |
| 163 | ipGeolocationError: ipGeolocationError, | |
| 164 | emailSecurity: emailSecurity, | |
| 165 | emailSecurityError: emailSecurityError, | |
| 166 | ownership: ownership, | |
| 167 | ownershipError: ownershipError, | |
| 168 | ptrRecord: ptrRecord, | |
| 169 | ptrError: ptrError, | |
| 170 | redirectChain: redirectChain, | |
| 171 | redirectChainError: redirectChainError, | |
| 172 | subdomains: subdomains, | |
| 173 | subdomainsError: subdomainsError, | |
| 174 | portScanResults: portScanResults, | |
| 175 | portScanError: portScanError, | |
| 176 | changeSummary: changeSummary, | |
| 177 | isLive: false | |
| 178 | ) | |
| 179 | } | |
| 180 | } | |
| 181 | ||
| 182 | 94 | private struct BatchLookupPayload { |
| 183 | 95 | let snapshot: LookupSnapshot |
| 184 | 96 | } |
| @@ -271,6 +183,7 @@ final class DomainViewModel { | ||
| 271 | 183 | private var lookupStartedAt: Date? |
| 272 | 184 | private var activeBatchDomains: [String] = [] |
| 273 | 185 | private var lastBatchStartedAt: Date? |
| 186 | private let reportBuilder = DomainReportBuilder() | |
| 274 | 187 | |
| 275 | 188 | private static let recentSearchesKey = "recentSearches" |
| 276 | 189 | private static let maxRecent = 20 |
| @@ -484,6 +397,18 @@ final class DomainViewModel { | ||
| 484 | 397 | ) |
| 485 | 398 | } |
| 486 | 399 | |
| 400 | var currentReport: DomainReport? { | |
| 401 | guard !searchedDomain.isEmpty else { return nil } | |
| 402 | return reportBuilder.build( | |
| 403 | from: currentSnapshot, | |
| 404 | previousSnapshot: previousSnapshot( | |
| 405 | for: searchedDomain, | |
| 406 | trackedDomainID: currentTrackedDomain?.id, | |
| 407 | replacingLatest: false | |
| 408 | ) | |
| 409 | ) | |
| 410 | } | |
| 411 | ||
| 487 | 412 | var summaryFields: [SummaryFieldViewData] { |
| 488 | 413 | Self.summaryFields(from: currentSnapshot) |
| 489 | 414 | } |
| @@ -768,64 +693,55 @@ final class DomainViewModel { | ||
| 768 | 693 | } |
| 769 | 694 | |
| 770 | 695 | func exportText() -> String { |
| 771 | Self.formatExportText( | |
| 772 | from: currentSnapshot, | |
| 773 | trackedDomain: currentTrackedDomain, | |
| 774 | changeSummary: currentChangeSummary, | |
| 775 | diffSections: currentDiffSections | |
| 776 | ) | |
| 696 | guard let currentReport else { return "No results available." } | |
| 697 | return DomainReportExporter.text(for: currentReport) | |
| 777 | 698 | } |
| 778 | 699 | |
| 779 | 700 | func exportCSV() -> String { |
| 780 | Self.formatCSV(from: [currentSnapshot]) | |
| 701 | guard let currentReport else { return DomainReportExporter.csv(for: []) } | |
| 702 | return DomainReportExporter.csv(for: [currentReport]) | |
| 703 | } | |
| 704 | ||
| 705 | func exportJSONData() -> Data? { | |
| 706 | guard let currentReport else { return nil } | |
| 707 | return try? DomainReportExporter.data(for: currentReport, format: .json) | |
| 781 | 708 | } |
| 782 | 709 | |
| 783 | 710 | func exportBatchText() -> String { |
| 784 | Self.formatBatchExportText( | |
| 785 | title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export", | |
| 786 | entries: currentBatchResultEntries.map { entry in | |
| 787 | ( | |
| 788 | snapshot: entry.snapshot, | |
| 789 | trackedDomain: trackedDomains.first(where: { tracked in | |
| 790 | tracked.id == entry.trackedDomainID || | |
| 791 | tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame | |
| 792 | }), | |
| 793 | changeSummary: entry.changeSummary, | |
| 794 | diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? [] | |
| 795 | ) | |
| 796 | } | |
| 711 | DomainReportExporter.batchText( | |
| 712 | for: currentBatchReports(), | |
| 713 | title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" | |
| 797 | 714 | ) |
| 798 | 715 | } |
| 799 | 716 | |
| 800 | 717 | func exportBatchCSV() -> String { |
| 801 | Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot)) | |
| 718 | DomainReportExporter.csv(for: currentBatchReports()) | |
| 719 | } | |
| 720 | ||
| 721 | func exportBatchJSONData() -> Data? { | |
| 722 | try? DomainReportExporter.data( | |
| 723 | for: currentBatchReports(), | |
| 724 | format: .json, | |
| 725 | title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export" | |
| 726 | ) | |
| 802 | 727 | } |
| 803 | 728 | |
| 804 | 729 | func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String { |
| 805 | Self.formatCSV(from: exportSnapshots(for: domains)) | |
| 730 | DomainReportExporter.csv(for: reports(for: domains)) | |
| 806 | 731 | } |
| 807 | 732 | |
| 808 | 733 | func exportTrackedDomainsText(domains: [TrackedDomain]) -> String { |
| 809 | let latestEntries = latestSnapshots(for: domains) | |
| 810 | return Self.formatBatchExportText( | |
| 811 | title: "Tracked Domains Export", | |
| 812 | entries: domains.map { trackedDomain in | |
| 813 | if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) { | |
| 814 | return ( | |
| 815 | snapshot: entry.snapshot, | |
| 816 | trackedDomain: trackedDomain, | |
| 817 | changeSummary: entry.changeSummary, | |
| 818 | diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? [] | |
| 819 | ) | |
| 820 | } | |
| 734 | DomainReportExporter.batchText( | |
| 735 | for: reports(for: domains), | |
| 736 | title: "Tracked Domains Export" | |
| 737 | ) | |
| 738 | } | |
| 821 | 739 | |
| 822 | return ( | |
| 823 | snapshot: placeholderSnapshot(for: trackedDomain), | |
| 824 | trackedDomain: trackedDomain, | |
| 825 | changeSummary: trackedDomain.lastChangeSummary, | |
| 826 | diffSections: [] | |
| 827 | ) | |
| 828 | } | |
| 740 | func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? { | |
| 741 | try? DomainReportExporter.data( | |
| 742 | for: reports(for: domains), | |
| 743 | format: .json, | |
| 744 | title: "Tracked Domains Export" | |
| 829 | 745 | ) |
| 830 | 746 | } |
| 831 | 747 | |
| @@ -1130,239 +1046,8 @@ final class DomainViewModel { | ||
| 1130 | 1046 | |
| 1131 | 1047 | private static func performBatchLookup(domain: String) async -> BatchLookupPayload? { |
| 1132 | 1048 | guard !Task.isCancelled else { return nil } |
| 1133 | ||
| 1134 | let startedAt = Date() | |
| 1135 | let resolverDisplayName = DNSLookupService.currentResolverDisplayName() | |
| 1136 | let resolverURLString = DNSLookupService.currentResolverURLString() | |
| 1137 | ||
| 1138 | async let dnsResult = DNSLookupService.lookupAll(domain: domain) | |
| 1139 | async let availabilityResult = DomainAvailabilityService.check(domain: domain) | |
| 1140 | async let sslResult = SSLCheckService.check(domain: domain) | |
| 1141 | async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain) | |
| 1142 | async let httpResult = HTTPHeadersService.fetch(domain: domain) | |
| 1143 | async let reachabilityResult = ReachabilityService.checkAll(domain: domain) | |
| 1144 | async let ownershipResult = DomainOwnershipService.lookup(domain: domain) | |
| 1145 | async let redirectResult = RedirectChainService.trace(domain: domain) | |
| 1146 | async let subdomainResult = SubdomainDiscoveryService.discover(for: domain) | |
| 1147 | async let portScanResult = PortScanService.scanAll(domain: domain) | |
| 1148 | ||
| 1149 | let resolvedDNS = await dnsResult | |
| 1150 | let availability = await availabilityResult | |
| 1151 | let resolvedSSL = await sslResult | |
| 1152 | let hsts = await hstsResult | |
| 1153 | let http = await httpResult | |
| 1154 | let reachability = await reachabilityResult | |
| 1155 | let resolvedOwnership = await ownershipResult | |
| 1156 | let redirects = await redirectResult | |
| 1157 | let resolvedSubdomains = await subdomainResult | |
| 1158 | let ports = await portScanResult | |
| 1159 | ||
| 1049 | let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain) | |
| 1160 | 1050 | guard !Task.isCancelled else { return nil } |
| 1161 | ||
| 1162 | let dnsSections: [DNSSection] | |
| 1163 | let dnsError: String? | |
| 1164 | switch resolvedDNS { | |
| 1165 | case let .success(sections): | |
| 1166 | dnsSections = sections | |
| 1167 | dnsError = nil | |
| 1168 | case let .empty(message), let .error(message): | |
| 1169 | dnsSections = [] | |
| 1170 | dnsError = message | |
| 1171 | } | |
| 1172 | ||
| 1173 | let sslInfo: SSLCertificateInfo? | |
| 1174 | let sslError: String? | |
| 1175 | switch resolvedSSL { | |
| 1176 | case let .success(info): | |
| 1177 | sslInfo = info | |
| 1178 | sslError = nil | |
| 1179 | case let .empty(message), let .error(message): | |
| 1180 | sslInfo = nil | |
| 1181 | sslError = message | |
| 1182 | } | |
| 1183 | ||
| 1184 | let httpHeaders: [HTTPHeader] | |
| 1185 | let httpSecurityGrade: String? | |
| 1186 | let httpStatusCode: Int? | |
| 1187 | let httpResponseTimeMs: Int? | |
| 1188 | let httpProtocol: String? | |
| 1189 | let http3Advertised: Bool | |
| 1190 | let httpHeadersError: String? | |
| 1191 | switch http { | |
| 1192 | case let .success(result): | |
| 1193 | httpHeaders = result.headers | |
| 1194 | httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue | |
| 1195 | httpStatusCode = result.statusCode | |
| 1196 | httpResponseTimeMs = result.responseTimeMs | |
| 1197 | httpProtocol = result.httpProtocol | |
| 1198 | http3Advertised = result.http3Advertised | |
| 1199 | httpHeadersError = nil | |
| 1200 | case let .empty(message), let .error(message): | |
| 1201 | httpHeaders = [] | |
| 1202 | httpSecurityGrade = nil | |
| 1203 | httpStatusCode = nil | |
| 1204 | httpResponseTimeMs = nil | |
| 1205 | httpProtocol = nil | |
| 1206 | http3Advertised = false | |
| 1207 | httpHeadersError = message | |
| 1208 | } | |
| 1209 | ||
| 1210 | let reachabilityResults: [PortReachability] | |
| 1211 | let reachabilityError: String? | |
| 1212 | switch reachability { | |
| 1213 | case let .success(results): | |
| 1214 | reachabilityResults = results | |
| 1215 | reachabilityError = nil | |
| 1216 | case let .empty(message), let .error(message): | |
| 1217 | reachabilityResults = [] | |
| 1218 | reachabilityError = message | |
| 1219 | } | |
| 1220 | ||
| 1221 | let redirectChain: [RedirectHop] | |
| 1222 | let redirectChainError: String? | |
| 1223 | switch redirects { | |
| 1224 | case let .success(hops): | |
| 1225 | redirectChain = hops | |
| 1226 | redirectChainError = nil | |
| 1227 | case let .empty(message), let .error(message): | |
| 1228 | redirectChain = [] | |
| 1229 | redirectChainError = message | |
| 1230 | } | |
| 1231 | ||
| 1232 | let portScanResults: [PortScanResult] | |
| 1233 | let portScanError: String? | |
| 1234 | switch ports { | |
| 1235 | case let .success(results): | |
| 1236 | portScanResults = await enrichOpenPortBanners(results, domain: domain) | |
| 1237 | portScanError = nil | |
| 1238 | case let .empty(message), let .error(message): | |
| 1239 | portScanResults = [] | |
| 1240 | portScanError = message | |
| 1241 | } | |
| 1242 | ||
| 1243 | let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] | |
| 1244 | let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value | |
| 1245 | ||
| 1246 | async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords) | |
| 1247 | async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : [] | |
| 1248 | ||
| 1249 | let resolvedEmail = await emailResult | |
| 1250 | let resolvedSuggestions = await suggestions | |
| 1251 | let resolvedPTR: ServiceResult<String>? | |
| 1252 | let resolvedGeo: ServiceResult<IPGeolocation>? | |
| 1253 | if let primaryIP { | |
| 1254 | resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) | |
| 1255 | resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP) | |
| 1256 | } else { | |
| 1257 | resolvedPTR = nil | |
| 1258 | resolvedGeo = nil | |
| 1259 | } | |
| 1260 | ||
| 1261 | guard !Task.isCancelled else { return nil } | |
| 1262 | ||
| 1263 | let emailSecurity: EmailSecurityResult? | |
| 1264 | let emailSecurityError: String? | |
| 1265 | switch resolvedEmail { | |
| 1266 | case let .success(result): | |
| 1267 | emailSecurity = result | |
| 1268 | emailSecurityError = nil | |
| 1269 | case let .empty(message), let .error(message): | |
| 1270 | emailSecurity = nil | |
| 1271 | emailSecurityError = message | |
| 1272 | } | |
| 1273 | ||
| 1274 | let ownership: DomainOwnership? | |
| 1275 | let ownershipError: String? | |
| 1276 | switch resolvedOwnership { | |
| 1277 | case let .success(result): | |
| 1278 | ownership = result | |
| 1279 | ownershipError = nil | |
| 1280 | case let .empty(message), let .error(message): | |
| 1281 | ownership = nil | |
| 1282 | ownershipError = message | |
| 1283 | } | |
| 1284 | ||
| 1285 | let ptrRecord: String? | |
| 1286 | let ptrError: String? | |
| 1287 | switch resolvedPTR { | |
| 1288 | case let .success(record): | |
| 1289 | ptrRecord = record | |
| 1290 | ptrError = nil | |
| 1291 | case let .empty(message), let .error(message): | |
| 1292 | ptrRecord = nil | |
| 1293 | ptrError = message | |
| 1294 | case .none: | |
| 1295 | ptrRecord = nil | |
| 1296 | ptrError = "No A record available" | |
| 1297 | } | |
| 1298 | ||
| 1299 | let ipGeolocation: IPGeolocation? | |
| 1300 | let ipGeolocationError: String? | |
| 1301 | switch resolvedGeo { | |
| 1302 | case let .success(result): | |
| 1303 | ipGeolocation = result | |
| 1304 | ipGeolocationError = nil | |
| 1305 | case let .empty(message), let .error(message): | |
| 1306 | ipGeolocation = nil | |
| 1307 | ipGeolocationError = message | |
| 1308 | case .none: | |
| 1309 | ipGeolocation = nil | |
| 1310 | ipGeolocationError = "No A record available" | |
| 1311 | } | |
| 1312 | ||
| 1313 | let subdomains: [DiscoveredSubdomain] | |
| 1314 | let subdomainsError: String? | |
| 1315 | switch resolvedSubdomains { | |
| 1316 | case let .success(result): | |
| 1317 | subdomains = result | |
| 1318 | subdomainsError = nil | |
| 1319 | case let .empty(message), let .error(message): | |
| 1320 | subdomains = [] | |
| 1321 | subdomainsError = message | |
| 1322 | } | |
| 1323 | ||
| 1324 | let snapshot = LookupSnapshot( | |
| 1325 | historyEntryID: nil, | |
| 1326 | domain: domain, | |
| 1327 | timestamp: Date(), | |
| 1328 | trackedDomainID: nil, | |
| 1329 | resolverDisplayName: resolverDisplayName, | |
| 1330 | resolverURLString: resolverURLString, | |
| 1331 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), | |
| 1332 | dnsSections: dnsSections, | |
| 1333 | dnsError: dnsError, | |
| 1334 | availabilityResult: availability, | |
| 1335 | suggestions: resolvedSuggestions, | |
| 1336 | sslInfo: sslInfo, | |
| 1337 | sslError: sslError, | |
| 1338 | hstsPreloaded: hsts, | |
| 1339 | httpHeaders: httpHeaders, | |
| 1340 | httpSecurityGrade: httpSecurityGrade, | |
| 1341 | httpStatusCode: httpStatusCode, | |
| 1342 | httpResponseTimeMs: httpResponseTimeMs, | |
| 1343 | httpProtocol: httpProtocol, | |
| 1344 | http3Advertised: http3Advertised, | |
| 1345 | httpHeadersError: httpHeadersError, | |
| 1346 | reachabilityResults: reachabilityResults, | |
| 1347 | reachabilityError: reachabilityError, | |
| 1348 | ipGeolocation: ipGeolocation, | |
| 1349 | ipGeolocationError: ipGeolocationError, | |
| 1350 | emailSecurity: emailSecurity, | |
| 1351 | emailSecurityError: emailSecurityError, | |
| 1352 | ownership: ownership, | |
| 1353 | ownershipError: ownershipError, | |
| 1354 | ptrRecord: ptrRecord, | |
| 1355 | ptrError: ptrError, | |
| 1356 | redirectChain: redirectChain, | |
| 1357 | redirectChainError: redirectChainError, | |
| 1358 | subdomains: subdomains, | |
| 1359 | subdomainsError: subdomainsError, | |
| 1360 | portScanResults: portScanResults, | |
| 1361 | portScanError: portScanError, | |
| 1362 | changeSummary: nil, | |
| 1363 | isLive: false | |
| 1364 | ) | |
| 1365 | ||
| 1366 | 1051 | return BatchLookupPayload(snapshot: snapshot) |
| 1367 | 1052 | } |
| 1368 | 1053 | |
| @@ -1967,6 +1652,29 @@ final class DomainViewModel { | ||
| 1967 | 1652 | } |
| 1968 | 1653 | } |
| 1969 | 1654 | |
| 1655 | private func currentBatchReports() -> [DomainReport] { | |
| 1656 | currentBatchResultEntries.map(report(for:)) | |
| 1657 | } | |
| 1658 | ||
| 1659 | private func reports(for domains: [TrackedDomain]) -> [DomainReport] { | |
| 1660 | let latestEntries = latestSnapshots(for: domains) | |
| 1661 | ||
| 1662 | return domains.map { trackedDomain in | |
| 1663 | if let entry = latestEntries.first(where: { | |
| 1664 | $0.trackedDomainID == trackedDomain.id || | |
| 1665 | $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame | |
| 1666 | }) { | |
| 1667 | return report(for: entry) | |
| 1668 | } | |
| 1669 | ||
| 1670 | return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain)) | |
| 1671 | } | |
| 1672 | } | |
| 1673 | ||
| 1674 | private func report(for entry: HistoryEntry) -> DomainReport { | |
| 1675 | reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry)) | |
| 1676 | } | |
| 1677 | ||
| 1970 | 1678 | private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { |
| 1971 | 1679 | LookupSnapshot( |
| 1972 | 1680 | historyEntryID: trackedDomain.lastSnapshotID, |
DomainDig/ExportPresenter.swift +5 −1
| @@ -3,10 +3,14 @@ import UIKit | ||
| 3 | 3 | |
| 4 | 4 | enum ExportPresenter { |
| 5 | 5 | static func share(filename: String, contents: String) { |
| 6 | share(filename: filename, data: Data(contents.utf8)) | |
| 7 | } | |
| 8 | ||
| 9 | static func share(filename: String, data: Data) { | |
| 6 | 10 | let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) |
| 7 | 11 | |
| 8 | 12 | do { |
| 9 | try contents.write(to: url, atomically: true, encoding: .utf8) | |
| 13 | try data.write(to: url, options: .atomic) | |
| 10 | 14 | } catch { |
| 11 | 15 | return |
| 12 | 16 | } |
DomainDig/WatchlistView.swift +20 −9
| @@ -143,11 +143,15 @@ struct WatchlistView: View { | ||
| 143 | 143 | .disabled(viewModel.batchLookupRunning) |
| 144 | 144 | |
| 145 | 145 | Button("Export TXT") { |
| 146 | shareTrackedDomains(asCSV: false) | |
| 146 | shareTrackedDomains(format: .text) | |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | 149 | Button("Export CSV") { |
| 150 | shareTrackedDomains(asCSV: true) | |
| 150 | shareTrackedDomains(format: .csv) | |
| 151 | } | |
| 152 | ||
| 153 | Button("Export JSON") { | |
| 154 | shareTrackedDomains(format: .json) | |
| 151 | 155 | } |
| 152 | 156 | } label: { |
| 153 | 157 | Image(systemName: "line.3.horizontal.decrease.circle") |
| @@ -178,16 +182,23 @@ struct WatchlistView: View { | ||
| 178 | 182 | domains.forEach(viewModel.deleteTrackedDomain) |
| 179 | 183 | } |
| 180 | 184 | |
| 181 | private func shareTrackedDomains(asCSV: Bool) { | |
| 185 | private func shareTrackedDomains(format: DomainExportFormat) { | |
| 182 | 186 | let formatter = DateFormatter() |
| 183 | 187 | formatter.dateFormat = "yyyyMMdd_HHmmss" |
| 184 | 188 | let timestamp = formatter.string(from: Date()) |
| 185 | let fileExtension = asCSV ? "csv" : "txt" | |
| 186 | let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)" | |
| 187 | let contents = asCSV | |
| 188 | ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains) | |
| 189 | : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains) | |
| 190 | ExportPresenter.share(filename: filename, contents: contents) | |
| 189 | let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)" | |
| 190 | let data: Data | |
| 191 | ||
| 192 | switch format { | |
| 193 | case .text: | |
| 194 | data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8) | |
| 195 | case .csv: | |
| 196 | data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8) | |
| 197 | case .json: | |
| 198 | data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8) | |
| 199 | } | |
| 200 | ||
| 201 | ExportPresenter.share(filename: filename, data: data) | |
| 191 | 202 | } |
| 192 | 203 | } |
| 193 | 204 | |
DomainDigCLI.swift added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | @main | |
| 4 | struct DomainDigCLI { | |
| 5 | static func main() async { | |
| 6 | let arguments = Array(CommandLine.arguments.dropFirst()) | |
| 7 | ||
| 8 | guard let command = CommandLine.arguments.first else { | |
| 9 | fputs("usage: domaindig <domain> [--json]\n", stderr) | |
| 10 | Foundation.exit(1) | |
| 11 | } | |
| 12 | _ = command | |
| 13 | ||
| 14 | let wantsJSON = arguments.contains("--json") || arguments.contains("-j") | |
| 15 | let domains = arguments.filter { !$0.hasPrefix("-") } | |
| 16 | ||
| 17 | guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { | |
| 18 | fputs("usage: domaindig <domain> [--json]\n", stderr) | |
| 19 | Foundation.exit(1) | |
| 20 | } | |
| 21 | ||
| 22 | let inspectionService = DomainInspectionService() | |
| 23 | let report = await inspectionService.inspect(domain: domain) | |
| 24 | ||
| 25 | do { | |
| 26 | let data = try DomainReportExporter.data( | |
| 27 | for: report, | |
| 28 | format: wantsJSON ? .json : .text | |
| 29 | ) | |
| 30 | FileHandle.standardOutput.write(data) | |
| 31 | if data.last != 0x0A { | |
| 32 | FileHandle.standardOutput.write(Data([0x0A])) | |
| 33 | } | |
| 34 | } catch { | |
| 35 | fputs("domaindig: \(error.localizedDescription)\n", stderr) | |
| 36 | Foundation.exit(1) | |
| 37 | } | |
| 38 | } | |
| 39 | } | |
DomainInspectionService.swift added +225
| @@ -0,0 +1,225 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | struct DomainInspectionService { | |
| 4 | private let reportBuilder = DomainReportBuilder() | |
| 5 | ||
| 6 | func inspect(domain: String) async -> DomainReport { | |
| 7 | let snapshot = await inspectSnapshot(domain: domain) | |
| 8 | return reportBuilder.build(from: snapshot) | |
| 9 | } | |
| 10 | ||
| 11 | func inspectSnapshot(domain: String) async -> LookupSnapshot { | |
| 12 | let normalizedDomain = normalize(domain) | |
| 13 | let startedAt = Date() | |
| 14 | let resolverDisplayName = DNSLookupService.currentResolverDisplayName() | |
| 15 | let resolverURLString = DNSLookupService.currentResolverURLString() | |
| 16 | ||
| 17 | async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain) | |
| 18 | async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain) | |
| 19 | async let sslResult = SSLCheckService.check(domain: normalizedDomain) | |
| 20 | async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain) | |
| 21 | async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain) | |
| 22 | async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain) | |
| 23 | async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain) | |
| 24 | async let redirectResult = RedirectChainService.trace(domain: normalizedDomain) | |
| 25 | async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain) | |
| 26 | async let portScanResult = PortScanService.scanAll(domain: normalizedDomain) | |
| 27 | ||
| 28 | let resolvedDNS = await dnsResult | |
| 29 | let availability = await availabilityResult | |
| 30 | let resolvedSSL = await sslResult | |
| 31 | let hsts = await hstsResult | |
| 32 | let http = await httpResult | |
| 33 | let reachability = await reachabilityResult | |
| 34 | let resolvedOwnership = await ownershipResult | |
| 35 | let redirects = await redirectResult | |
| 36 | let resolvedSubdomains = await subdomainResult | |
| 37 | let ports = await portScanResult | |
| 38 | ||
| 39 | let dnsSections = mapServiceResult(resolvedDNS, emptyValue: []) | |
| 40 | let sslInfo = mapOptionalValueServiceResult(resolvedSSL) | |
| 41 | let httpHeadersResult = mapHTTPResult(http) | |
| 42 | let reachabilityResultValue = mapServiceResult(reachability, emptyValue: []) | |
| 43 | let redirectChain = mapServiceResult(redirects, emptyValue: []) | |
| 44 | let ownership = mapOptionalValueServiceResult(resolvedOwnership) | |
| 45 | let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: []) | |
| 46 | let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain) | |
| 47 | ||
| 48 | let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] | |
| 49 | let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value | |
| 50 | ||
| 51 | async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords) | |
| 52 | async let suggestions = availability.status == .registered | |
| 53 | ? DomainAvailabilityService.suggestions(for: normalizedDomain) | |
| 54 | : [] | |
| 55 | ||
| 56 | let resolvedEmail = await emailResult | |
| 57 | let resolvedSuggestions = await suggestions | |
| 58 | ||
| 59 | let ptrResult: ServiceResult<String>? | |
| 60 | let geoResult: ServiceResult<IPGeolocation>? | |
| 61 | if let primaryIP { | |
| 62 | ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) | |
| 63 | geoResult = await IPGeolocationService.lookup(ip: primaryIP) | |
| 64 | } else { | |
| 65 | ptrResult = nil | |
| 66 | geoResult = nil | |
| 67 | } | |
| 68 | ||
| 69 | let emailSecurity = mapOptionalValueServiceResult(resolvedEmail) | |
| 70 | let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available") | |
| 71 | let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available") | |
| 72 | ||
| 73 | return LookupSnapshot( | |
| 74 | historyEntryID: nil, | |
| 75 | domain: availability.domain, | |
| 76 | timestamp: Date(), | |
| 77 | trackedDomainID: nil, | |
| 78 | resolverDisplayName: resolverDisplayName, | |
| 79 | resolverURLString: resolverURLString, | |
| 80 | totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), | |
| 81 | dnsSections: dnsSections.value, | |
| 82 | dnsError: dnsSections.message, | |
| 83 | availabilityResult: availability, | |
| 84 | suggestions: resolvedSuggestions, | |
| 85 | sslInfo: sslInfo.value, | |
| 86 | sslError: sslInfo.message, | |
| 87 | hstsPreloaded: hsts, | |
| 88 | httpHeaders: httpHeadersResult.headers, | |
| 89 | httpSecurityGrade: httpHeadersResult.securityGrade, | |
| 90 | httpStatusCode: httpHeadersResult.statusCode, | |
| 91 | httpResponseTimeMs: httpHeadersResult.responseTimeMs, | |
| 92 | httpProtocol: httpHeadersResult.httpProtocol, | |
| 93 | http3Advertised: httpHeadersResult.http3Advertised, | |
| 94 | httpHeadersError: httpHeadersResult.error, | |
| 95 | reachabilityResults: reachabilityResultValue.value, | |
| 96 | reachabilityError: reachabilityResultValue.message, | |
| 97 | ipGeolocation: geolocation.value, | |
| 98 | ipGeolocationError: geolocation.message, | |
| 99 | emailSecurity: emailSecurity.value, | |
| 100 | emailSecurityError: emailSecurity.message, | |
| 101 | ownership: ownership.value, | |
| 102 | ownershipError: ownership.message, | |
| 103 | ptrRecord: ptrRecord.value, | |
| 104 | ptrError: ptrRecord.message, | |
| 105 | redirectChain: redirectChain.value, | |
| 106 | redirectChainError: redirectChain.message, | |
| 107 | subdomains: subdomains.value, | |
| 108 | subdomainsError: subdomains.message, | |
| 109 | portScanResults: portScanResults.value, | |
| 110 | portScanError: portScanResults.message, | |
| 111 | changeSummary: nil, | |
| 112 | isLive: false | |
| 113 | ) | |
| 114 | } | |
| 115 | ||
| 116 | private func normalize(_ domain: String) -> String { | |
| 117 | domain | |
| 118 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 119 | .replacingOccurrences(of: "https://", with: "") | |
| 120 | .replacingOccurrences(of: "http://", with: "") | |
| 121 | .components(separatedBy: "/").first? | |
| 122 | .lowercased() ?? domain.lowercased() | |
| 123 | } | |
| 124 | ||
| 125 | private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { | |
| 126 | switch result { | |
| 127 | case let .success(value): | |
| 128 | return (value, nil) | |
| 129 | case let .empty(message), let .error(message): | |
| 130 | return (emptyValue, message) | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) { | |
| 135 | switch result { | |
| 136 | case let .success(value): | |
| 137 | return (value, nil) | |
| 138 | case let .empty(message), let .error(message): | |
| 139 | return (nil, message) | |
| 140 | } | |
| 141 | } | |
| 142 | ||
| 143 | private func mapOptionalServiceResult<Value>( | |
| 144 | _ result: ServiceResult<Value>?, | |
| 145 | missingMessage: String | |
| 146 | ) -> (value: Value?, message: String?) { | |
| 147 | guard let result else { | |
| 148 | return (nil, missingMessage) | |
| 149 | } | |
| 150 | ||
| 151 | switch result { | |
| 152 | case let .success(value): | |
| 153 | return (value, nil) | |
| 154 | case let .empty(message), let .error(message): | |
| 155 | return (nil, message) | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 159 | private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) { | |
| 160 | switch result { | |
| 161 | case let .success(results): | |
| 162 | return (await enrichOpenPortBanners(in: results, domain: domain), nil) | |
| 163 | case let .empty(message), let .error(message): | |
| 164 | return ([], message) | |
| 165 | } | |
| 166 | } | |
| 167 | ||
| 168 | private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> ( | |
| 169 | headers: [HTTPHeader], | |
| 170 | securityGrade: String?, | |
| 171 | statusCode: Int?, | |
| 172 | responseTimeMs: Int?, | |
| 173 | httpProtocol: String?, | |
| 174 | http3Advertised: Bool, | |
| 175 | error: String? | |
| 176 | ) { | |
| 177 | switch result { | |
| 178 | case let .success(value): | |
| 179 | return ( | |
| 180 | headers: value.headers, | |
| 181 | securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue, | |
| 182 | statusCode: value.statusCode, | |
| 183 | responseTimeMs: value.responseTimeMs, | |
| 184 | httpProtocol: value.httpProtocol, | |
| 185 | http3Advertised: value.http3Advertised, | |
| 186 | error: nil | |
| 187 | ) | |
| 188 | case let .empty(message), let .error(message): | |
| 189 | return ( | |
| 190 | headers: [], | |
| 191 | securityGrade: nil, | |
| 192 | statusCode: nil, | |
| 193 | responseTimeMs: nil, | |
| 194 | httpProtocol: nil, | |
| 195 | http3Advertised: false, | |
| 196 | error: message | |
| 197 | ) | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] { | |
| 202 | let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in | |
| 203 | for result in results where result.open { | |
| 204 | group.addTask { | |
| 205 | let banner = await PortScanService.grabBanner(host: domain, port: result.port) | |
| 206 | return (result.port, banner) | |
| 207 | } | |
| 208 | } | |
| 209 | ||
| 210 | var collected: [UInt16: String] = [:] | |
| 211 | for await (port, banner) in group { | |
| 212 | if let banner { | |
| 213 | collected[port] = banner | |
| 214 | } | |
| 215 | } | |
| 216 | return collected | |
| 217 | } | |
| 218 | ||
| 219 | return results.map { result in | |
| 220 | var updated = result | |
| 221 | updated.banner = banners[result.port] | |
| 222 | return updated | |
| 223 | } | |
| 224 | } | |
| 225 | } | |
DomainReportBuilder.swift added +190
| @@ -0,0 +1,190 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | struct DomainReport: Codable { | |
| 4 | let domain: String | |
| 5 | let timestamp: Date | |
| 6 | let availability: DomainAvailabilityStatus | |
| 7 | let ownership: DomainOwnership? | |
| 8 | let dns: DNSResultSummary | |
| 9 | let web: WebResultSummary | |
| 10 | let email: EmailSecuritySummary | |
| 11 | let network: NetworkSummary | |
| 12 | let subdomains: [String] | |
| 13 | let changeSummary: DomainChangeSummary? | |
| 14 | } | |
| 15 | ||
| 16 | struct DNSResultSummary: Codable { | |
| 17 | let resolverDisplayName: String | |
| 18 | let resolverURLString: String | |
| 19 | let lookupDurationMs: Int? | |
| 20 | let recordSections: [DNSSection] | |
| 21 | let primaryIP: String? | |
| 22 | let ptrRecord: String? | |
| 23 | let dnssecSigned: Bool? | |
| 24 | let error: String? | |
| 25 | let ptrError: String? | |
| 26 | } | |
| 27 | ||
| 28 | struct WebResultSummary: Codable { | |
| 29 | let tls: SSLCertificateInfo? | |
| 30 | let tlsStatus: String | |
| 31 | let certificateWarningLevel: CertificateWarningLevel | |
| 32 | let hstsPreloaded: Bool? | |
| 33 | let headers: [HTTPHeader] | |
| 34 | let headerCount: Int | |
| 35 | let securityGrade: String? | |
| 36 | let statusCode: Int? | |
| 37 | let responseTimeMs: Int? | |
| 38 | let httpProtocol: String? | |
| 39 | let http3Advertised: Bool | |
| 40 | let redirectChain: [RedirectHop] | |
| 41 | let finalURL: String? | |
| 42 | let tlsError: String? | |
| 43 | let headersError: String? | |
| 44 | let redirectError: String? | |
| 45 | } | |
| 46 | ||
| 47 | struct EmailSecuritySummary: Codable { | |
| 48 | let records: EmailSecurityResult? | |
| 49 | let summary: String | |
| 50 | let error: String? | |
| 51 | } | |
| 52 | ||
| 53 | struct NetworkSummary: Codable { | |
| 54 | let primaryIP: String? | |
| 55 | let reachability: [PortReachability] | |
| 56 | let reachabilitySummary: String | |
| 57 | let reachabilityError: String? | |
| 58 | let geolocation: IPGeolocation? | |
| 59 | let geolocationSummary: String | |
| 60 | let geolocationError: String? | |
| 61 | let portScan: [PortScanResult] | |
| 62 | let openPorts: [UInt16] | |
| 63 | let portScanError: String? | |
| 64 | } | |
| 65 | ||
| 66 | struct DomainReportBuilder { | |
| 67 | func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { | |
| 68 | let primaryIP = primaryIPAddress(from: snapshot) | |
| 69 | ||
| 70 | return DomainReport( | |
| 71 | domain: snapshot.domain, | |
| 72 | timestamp: snapshot.timestamp, | |
| 73 | availability: snapshot.availabilityResult?.status ?? .unknown, | |
| 74 | ownership: snapshot.ownership, | |
| 75 | dns: DNSResultSummary( | |
| 76 | resolverDisplayName: snapshot.resolverDisplayName, | |
| 77 | resolverURLString: snapshot.resolverURLString, | |
| 78 | lookupDurationMs: snapshot.totalLookupDurationMs, | |
| 79 | recordSections: snapshot.dnsSections, | |
| 80 | primaryIP: primaryIP, | |
| 81 | ptrRecord: snapshot.ptrRecord, | |
| 82 | dnssecSigned: dnssecSigned(from: snapshot), | |
| 83 | error: snapshot.dnsError, | |
| 84 | ptrError: snapshot.ptrError | |
| 85 | ), | |
| 86 | web: WebResultSummary( | |
| 87 | tls: snapshot.sslInfo, | |
| 88 | tlsStatus: tlsStatus(from: snapshot), | |
| 89 | certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), | |
| 90 | hstsPreloaded: snapshot.hstsPreloaded, | |
| 91 | headers: snapshot.httpHeaders, | |
| 92 | headerCount: snapshot.httpHeaders.count, | |
| 93 | securityGrade: snapshot.httpSecurityGrade, | |
| 94 | statusCode: snapshot.httpStatusCode, | |
| 95 | responseTimeMs: snapshot.httpResponseTimeMs, | |
| 96 | httpProtocol: snapshot.httpProtocol, | |
| 97 | http3Advertised: snapshot.http3Advertised, | |
| 98 | redirectChain: snapshot.redirectChain, | |
| 99 | finalURL: snapshot.redirectChain.last?.url, | |
| 100 | tlsError: snapshot.sslError, | |
| 101 | headersError: snapshot.httpHeadersError, | |
| 102 | redirectError: snapshot.redirectChainError | |
| 103 | ), | |
| 104 | email: EmailSecuritySummary( | |
| 105 | records: snapshot.emailSecurity, | |
| 106 | summary: emailSummary(from: snapshot), | |
| 107 | error: snapshot.emailSecurityError | |
| 108 | ), | |
| 109 | network: NetworkSummary( | |
| 110 | primaryIP: primaryIP, | |
| 111 | reachability: snapshot.reachabilityResults, | |
| 112 | reachabilitySummary: reachabilitySummary(from: snapshot), | |
| 113 | reachabilityError: snapshot.reachabilityError, | |
| 114 | geolocation: snapshot.ipGeolocation, | |
| 115 | geolocationSummary: geolocationSummary(from: snapshot), | |
| 116 | geolocationError: snapshot.ipGeolocationError, | |
| 117 | portScan: snapshot.portScanResults, | |
| 118 | openPorts: snapshot.portScanResults.filter(\.open).map(\.port), | |
| 119 | portScanError: snapshot.portScanError | |
| 120 | ), | |
| 121 | subdomains: snapshot.subdomains.map(\.hostname), | |
| 122 | changeSummary: snapshot.changeSummary ?? previousSnapshot.map { | |
| 123 | DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp) | |
| 124 | } | |
| 125 | ) | |
| 126 | } | |
| 127 | ||
| 128 | func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { | |
| 129 | build(from: entry.snapshot, previousSnapshot: previousSnapshot) | |
| 130 | } | |
| 131 | ||
| 132 | private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { | |
| 133 | snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value | |
| 134 | } | |
| 135 | ||
| 136 | private func dnssecSigned(from snapshot: LookupSnapshot) -> Bool? { | |
| 137 | snapshot.dnsSections.compactMap(\.dnssecSigned).first | |
| 138 | } | |
| 139 | ||
| 140 | private func tlsStatus(from snapshot: LookupSnapshot) -> String { | |
| 141 | if snapshot.sslInfo != nil { | |
| 142 | return "valid" | |
| 143 | } | |
| 144 | if let sslError = snapshot.sslError { | |
| 145 | return sslError.localizedCaseInsensitiveContains("certificate") ? "invalid" : "failed" | |
| 146 | } | |
| 147 | return "unavailable" | |
| 148 | } | |
| 149 | ||
| 150 | private func emailSummary(from snapshot: LookupSnapshot) -> String { | |
| 151 | guard let emailSecurity = snapshot.emailSecurity else { | |
| 152 | return snapshot.emailSecurityError ?? "Unavailable" | |
| 153 | } | |
| 154 | ||
| 155 | return [ | |
| 156 | "SPF \(emailSecurity.spf.found ? "Yes" : "No")", | |
| 157 | "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")", | |
| 158 | "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")", | |
| 159 | "BIMI \(emailSecurity.bimi.found ? "Yes" : "No")", | |
| 160 | "MTA-STS \(emailSecurity.mtaSts?.txtFound == true ? "Yes" : "No")" | |
| 161 | ].joined(separator: " / ") | |
| 162 | } | |
| 163 | ||
| 164 | private func reachabilitySummary(from snapshot: LookupSnapshot) -> String { | |
| 165 | guard !snapshot.reachabilityResults.isEmpty else { | |
| 166 | return snapshot.reachabilityError ?? "Unavailable" | |
| 167 | } | |
| 168 | ||
| 169 | return snapshot.reachabilityResults | |
| 170 | .sorted { $0.port < $1.port } | |
| 171 | .map { "\($0.port):\($0.reachable ? "open" : "closed")" } | |
| 172 | .joined(separator: ", ") | |
| 173 | } | |
| 174 | ||
| 175 | private func geolocationSummary(from snapshot: LookupSnapshot) -> String { | |
| 176 | guard let geolocation = snapshot.ipGeolocation else { | |
| 177 | return snapshot.ipGeolocationError ?? "Unavailable" | |
| 178 | } | |
| 179 | ||
| 180 | let parts = [geolocation.city, geolocation.region, geolocation.country_name] | |
| 181 | .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } | |
| 182 | .filter { !$0.isEmpty } | |
| 183 | ||
| 184 | if !parts.isEmpty { | |
| 185 | return parts.joined(separator: ", ") | |
| 186 | } | |
| 187 | ||
| 188 | return geolocation.ip | |
| 189 | } | |
| 190 | } | |
DomainReportExporter.swift added +367
| @@ -0,0 +1,367 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | enum DomainExportFormat: String { | |
| 4 | case text = "txt" | |
| 5 | case csv = "csv" | |
| 6 | case json = "json" | |
| 7 | ||
| 8 | var fileExtension: String { rawValue } | |
| 9 | } | |
| 10 | ||
| 11 | enum DomainReportExporter { | |
| 12 | static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data { | |
| 13 | switch format { | |
| 14 | case .text: | |
| 15 | return Data(text(for: report).utf8) | |
| 16 | case .csv: | |
| 17 | return Data(csv(for: [report]).utf8) | |
| 18 | case .json: | |
| 19 | return try jsonEncoder.encode(report) | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data { | |
| 24 | switch format { | |
| 25 | case .text: | |
| 26 | return Data(batchText(for: reports, title: title).utf8) | |
| 27 | case .csv: | |
| 28 | return Data(csv(for: reports).utf8) | |
| 29 | case .json: | |
| 30 | return try jsonEncoder.encode(reports) | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | static func text(for report: DomainReport) -> String { | |
| 35 | var lines = [ | |
| 36 | "DomainDig Report", | |
| 37 | "Domain: \(report.domain)", | |
| 38 | "Timestamp: \(textDateFormatter.string(from: report.timestamp))", | |
| 39 | "Availability: \(availabilityLabel(report.availability))" | |
| 40 | ] | |
| 41 | ||
| 42 | appendSection("Summary", to: &lines) { | |
| 43 | [ | |
| 44 | "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", | |
| 45 | "TLS Status: \(report.web.tlsStatus)", | |
| 46 | "HTTP: \(httpSummary(for: report))", | |
| 47 | "Email: \(report.email.summary)", | |
| 48 | "Subdomains: \(report.subdomains.count)" | |
| 49 | ] | |
| 50 | } | |
| 51 | ||
| 52 | appendSection("Ownership", to: &lines) { | |
| 53 | var ownershipLines = [ | |
| 54 | "Registrar: \(report.ownership?.registrar ?? "Unavailable")", | |
| 55 | "Created: \(ownershipDateLabel(report.ownership?.createdDate))", | |
| 56 | "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))", | |
| 57 | "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")", | |
| 58 | "Status: \(joined(report.ownership?.status) ?? "Unavailable")", | |
| 59 | "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")" | |
| 60 | ] | |
| 61 | if let error = report.dns.error, report.ownership == nil { | |
| 62 | ownershipLines.append("Error: \(error)") | |
| 63 | } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil { | |
| 64 | _ = error | |
| 65 | } | |
| 66 | return ownershipLines | |
| 67 | } | |
| 68 | ||
| 69 | appendSection("DNS", to: &lines) { | |
| 70 | var dnsLines = [ | |
| 71 | "Resolver: \(report.dns.resolverDisplayName)", | |
| 72 | "Resolver URL: \(report.dns.resolverURLString)", | |
| 73 | "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", | |
| 74 | "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", | |
| 75 | "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")", | |
| 76 | "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))" | |
| 77 | ] | |
| 78 | if let error = report.dns.error { | |
| 79 | dnsLines.append("Error: \(error)") | |
| 80 | } | |
| 81 | if report.dns.recordSections.isEmpty { | |
| 82 | dnsLines.append("Records: None") | |
| 83 | } else { | |
| 84 | dnsLines.append("Records:") | |
| 85 | for section in report.dns.recordSections { | |
| 86 | let values = (section.records + section.wildcardRecords).map(\.value) | |
| 87 | let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ") | |
| 88 | dnsLines.append(" \(section.recordType.rawValue): \(renderedValues)") | |
| 89 | } | |
| 90 | } | |
| 91 | return dnsLines | |
| 92 | } | |
| 93 | ||
| 94 | appendSection("Web", to: &lines) { | |
| 95 | var webLines = [ | |
| 96 | "TLS Status: \(report.web.tlsStatus)", | |
| 97 | "Certificate Warning: \(report.web.certificateWarningLevel.title)", | |
| 98 | "Security Grade: \(report.web.securityGrade ?? "Unavailable")", | |
| 99 | "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")", | |
| 100 | "Protocol: \(report.web.httpProtocol ?? "Unavailable")", | |
| 101 | "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")", | |
| 102 | "Final URL: \(report.web.finalURL ?? "Unavailable")", | |
| 103 | "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))", | |
| 104 | "Header Count: \(report.web.headerCount)" | |
| 105 | ] | |
| 106 | if let tlsError = report.web.tlsError { | |
| 107 | webLines.append("TLS Error: \(tlsError)") | |
| 108 | } | |
| 109 | if let headersError = report.web.headersError { | |
| 110 | webLines.append("Headers Error: \(headersError)") | |
| 111 | } | |
| 112 | if let redirectError = report.web.redirectError { | |
| 113 | webLines.append("Redirect Error: \(redirectError)") | |
| 114 | } | |
| 115 | if !report.web.headers.isEmpty { | |
| 116 | webLines.append("Headers:") | |
| 117 | for header in report.web.headers { | |
| 118 | webLines.append(" \(header.name): \(header.value)") | |
| 119 | } | |
| 120 | } | |
| 121 | if !report.web.redirectChain.isEmpty { | |
| 122 | webLines.append("Redirect Chain:") | |
| 123 | for hop in report.web.redirectChain { | |
| 124 | webLines.append(" \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")") | |
| 125 | } | |
| 126 | } | |
| 127 | return webLines | |
| 128 | } | |
| 129 | ||
| 130 | appendSection("Email", to: &lines) { | |
| 131 | var emailLines = [report.email.summary] | |
| 132 | if let records = report.email.records { | |
| 133 | emailLines.append("SPF: \(recordLabel(records.spf))") | |
| 134 | emailLines.append("DMARC: \(recordLabel(records.dmarc))") | |
| 135 | emailLines.append("DKIM: \(recordLabel(records.dkim))") | |
| 136 | emailLines.append("BIMI: \(recordLabel(records.bimi))") | |
| 137 | emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")") | |
| 138 | } | |
| 139 | if let error = report.email.error { | |
| 140 | emailLines.append("Error: \(error)") | |
| 141 | } | |
| 142 | return emailLines | |
| 143 | } | |
| 144 | ||
| 145 | appendSection("Network", to: &lines) { | |
| 146 | var networkLines = [ | |
| 147 | "Reachability: \(report.network.reachabilitySummary)", | |
| 148 | "Geolocation: \(report.network.geolocationSummary)", | |
| 149 | "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")" | |
| 150 | ] | |
| 151 | if let error = report.network.reachabilityError { | |
| 152 | networkLines.append("Reachability Error: \(error)") | |
| 153 | } | |
| 154 | if let error = report.network.geolocationError { | |
| 155 | networkLines.append("Geolocation Error: \(error)") | |
| 156 | } | |
| 157 | if let error = report.network.portScanError { | |
| 158 | networkLines.append("Port Scan Error: \(error)") | |
| 159 | } | |
| 160 | if !report.network.portScan.isEmpty { | |
| 161 | networkLines.append("Port Scan:") | |
| 162 | for result in report.network.portScan { | |
| 163 | networkLines.append( | |
| 164 | " \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")" | |
| 165 | ) | |
| 166 | } | |
| 167 | } | |
| 168 | return networkLines | |
| 169 | } | |
| 170 | ||
| 171 | appendSection("Subdomains", to: &lines) { | |
| 172 | if report.subdomains.isEmpty { | |
| 173 | return ["None"] | |
| 174 | } | |
| 175 | return report.subdomains.map { "- \($0)" } | |
| 176 | } | |
| 177 | ||
| 178 | appendSection("Changes", to: &lines) { | |
| 179 | guard let changeSummary = report.changeSummary else { | |
| 180 | return ["No comparison available"] | |
| 181 | } | |
| 182 | ||
| 183 | return [ | |
| 184 | "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")", | |
| 185 | "Severity: \(changeSummary.severity.title)", | |
| 186 | "Summary: \(changeSummary.message)", | |
| 187 | "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))" | |
| 188 | ] | |
| 189 | } | |
| 190 | ||
| 191 | return lines.joined(separator: "\n") | |
| 192 | } | |
| 193 | ||
| 194 | static func batchText(for reports: [DomainReport], title: String) -> String { | |
| 195 | guard !reports.isEmpty else { | |
| 196 | return "\(title)\nNo results available." | |
| 197 | } | |
| 198 | ||
| 199 | var lines = [title, String(repeating: "=", count: title.count), ""] | |
| 200 | for (index, report) in reports.enumerated() { | |
| 201 | if index > 0 { | |
| 202 | lines.append("") | |
| 203 | lines.append(String(repeating: "=", count: 48)) | |
| 204 | lines.append("") | |
| 205 | } | |
| 206 | lines.append(text(for: report)) | |
| 207 | } | |
| 208 | return lines.joined(separator: "\n") | |
| 209 | } | |
| 210 | ||
| 211 | static func csv(for reports: [DomainReport]) -> String { | |
| 212 | let headers = [ | |
| 213 | "domain", | |
| 214 | "timestamp", | |
| 215 | "availability", | |
| 216 | "registrar", | |
| 217 | "ownership_expires", | |
| 218 | "nameservers", | |
| 219 | "primary_ip", | |
| 220 | "ptr_record", | |
| 221 | "dnssec_signed", | |
| 222 | "tls_status", | |
| 223 | "certificate_warning_level", | |
| 224 | "hsts_preloaded", | |
| 225 | "http_status", | |
| 226 | "http_security_grade", | |
| 227 | "final_url", | |
| 228 | "email_summary", | |
| 229 | "subdomain_count", | |
| 230 | "subdomains", | |
| 231 | "open_ports", | |
| 232 | "reachability_summary", | |
| 233 | "geolocation_summary", | |
| 234 | "change_summary" | |
| 235 | ] | |
| 236 | ||
| 237 | let rows = reports.map { report in | |
| 238 | let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "" | |
| 239 | let nameservers = joined(report.ownership?.nameservers) ?? "" | |
| 240 | let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? "" | |
| 241 | let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? "" | |
| 242 | let httpStatus = report.web.statusCode.map(String.init) ?? "" | |
| 243 | let subdomainCount = String(report.subdomains.count) | |
| 244 | let subdomains = report.subdomains.joined(separator: " | ") | |
| 245 | let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ") | |
| 246 | ||
| 247 | return [ | |
| 248 | report.domain, | |
| 249 | csvDateFormatter.string(from: report.timestamp), | |
| 250 | availabilityLabel(report.availability), | |
| 251 | report.ownership?.registrar ?? "", | |
| 252 | expirationDate, | |
| 253 | nameservers, | |
| 254 | report.dns.primaryIP ?? "", | |
| 255 | report.dns.ptrRecord ?? "", | |
| 256 | dnssecSigned, | |
| 257 | report.web.tlsStatus, | |
| 258 | report.web.certificateWarningLevel.rawValue, | |
| 259 | hstsPreloaded, | |
| 260 | httpStatus, | |
| 261 | report.web.securityGrade ?? "", | |
| 262 | report.web.finalURL ?? "", | |
| 263 | report.email.summary, | |
| 264 | subdomainCount, | |
| 265 | subdomains, | |
| 266 | openPorts, | |
| 267 | report.network.reachabilitySummary, | |
| 268 | report.network.geolocationSummary, | |
| 269 | report.changeSummary?.message ?? "" | |
| 270 | ] | |
| 271 | } | |
| 272 | ||
| 273 | return ([headers] + rows) | |
| 274 | .map { row in row.map(csvEscaped).joined(separator: ",") } | |
| 275 | .joined(separator: "\n") | |
| 276 | } | |
| 277 | ||
| 278 | private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) { | |
| 279 | lines.append("") | |
| 280 | lines.append(title) | |
| 281 | lines.append(String(repeating: "-", count: title.count)) | |
| 282 | lines.append(contentsOf: body()) | |
| 283 | } | |
| 284 | ||
| 285 | private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String { | |
| 286 | switch status { | |
| 287 | case .available: | |
| 288 | return "Available" | |
| 289 | case .registered: | |
| 290 | return "Registered" | |
| 291 | case .unknown: | |
| 292 | return "Unknown" | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | private static func joined(_ values: [String]?) -> String? { | |
| 297 | guard let values, !values.isEmpty else { return nil } | |
| 298 | return values.joined(separator: " | ") | |
| 299 | } | |
| 300 | ||
| 301 | private static func dnssecLabel(_ value: Bool?) -> String { | |
| 302 | switch value { | |
| 303 | case true: | |
| 304 | return "Signed" | |
| 305 | case false: | |
| 306 | return "Unsigned" | |
| 307 | case nil: | |
| 308 | return "Unavailable" | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | private static func ownershipDateLabel(_ date: Date?) -> String { | |
| 313 | guard let date else { return "Unavailable" } | |
| 314 | return textDateFormatter.string(from: date) | |
| 315 | } | |
| 316 | ||
| 317 | private static func durationLabel(_ durationMs: Int?) -> String { | |
| 318 | durationMs.map { "\($0) ms" } ?? "Unavailable" | |
| 319 | } | |
| 320 | ||
| 321 | private static func httpSummary(for report: DomainReport) -> String { | |
| 322 | let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 } | |
| 323 | return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ") | |
| 324 | } | |
| 325 | ||
| 326 | private static func booleanLabel(_ value: Bool?) -> String { | |
| 327 | guard let value else { return "Unavailable" } | |
| 328 | return value ? "Yes" : "No" | |
| 329 | } | |
| 330 | ||
| 331 | private static func recordLabel(_ record: EmailSecurityRecord) -> String { | |
| 332 | if record.found { | |
| 333 | return record.value ?? "Present" | |
| 334 | } | |
| 335 | return "Unavailable" | |
| 336 | } | |
| 337 | ||
| 338 | private static let textDateFormatter: DateFormatter = { | |
| 339 | let formatter = DateFormatter() | |
| 340 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" | |
| 341 | return formatter | |
| 342 | }() | |
| 343 | ||
| 344 | private static let csvDateFormatter: ISO8601DateFormatter = { | |
| 345 | let formatter = ISO8601DateFormatter() | |
| 346 | formatter.formatOptions = [.withInternetDateTime] | |
| 347 | return formatter | |
| 348 | }() | |
| 349 | ||
| 350 | private static let jsonEncoder: JSONEncoder = { | |
| 351 | let encoder = JSONEncoder() | |
| 352 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] | |
| 353 | encoder.dateEncodingStrategy = .iso8601 | |
| 354 | return encoder | |
| 355 | }() | |
| 356 | ||
| 357 | nonisolated private static func csvEscaped(_ value: String) -> String { | |
| 358 | let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") | |
| 359 | return "\"\(escaped)\"" | |
| 360 | } | |
| 361 | } | |
| 362 | ||
| 363 | private extension String { | |
| 364 | var nilIfEmpty: String? { | |
| 365 | isEmpty ? nil : self | |
| 366 | } | |
| 367 | } | |
LookupSnapshot.swift added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | struct LookupSnapshot { | |
| 4 | let historyEntryID: UUID? | |
| 5 | let domain: String | |
| 6 | let timestamp: Date | |
| 7 | let trackedDomainID: UUID? | |
| 8 | let resolverDisplayName: String | |
| 9 | let resolverURLString: String | |
| 10 | let totalLookupDurationMs: Int? | |
| 11 | let dnsSections: [DNSSection] | |
| 12 | let dnsError: String? | |
| 13 | let availabilityResult: DomainAvailabilityResult? | |
| 14 | let suggestions: [DomainSuggestionResult] | |
| 15 | let sslInfo: SSLCertificateInfo? | |
| 16 | let sslError: String? | |
| 17 | let hstsPreloaded: Bool? | |
| 18 | let httpHeaders: [HTTPHeader] | |
| 19 | let httpSecurityGrade: String? | |
| 20 | let httpStatusCode: Int? | |
| 21 | let httpResponseTimeMs: Int? | |
| 22 | let httpProtocol: String? | |
| 23 | let http3Advertised: Bool | |
| 24 | let httpHeadersError: String? | |
| 25 | let reachabilityResults: [PortReachability] | |
| 26 | let reachabilityError: String? | |
| 27 | let ipGeolocation: IPGeolocation? | |
| 28 | let ipGeolocationError: String? | |
| 29 | let emailSecurity: EmailSecurityResult? | |
| 30 | let emailSecurityError: String? | |
| 31 | let ownership: DomainOwnership? | |
| 32 | let ownershipError: String? | |
| 33 | let ptrRecord: String? | |
| 34 | let ptrError: String? | |
| 35 | let redirectChain: [RedirectHop] | |
| 36 | let redirectChainError: String? | |
| 37 | let subdomains: [DiscoveredSubdomain] | |
| 38 | let subdomainsError: String? | |
| 39 | let portScanResults: [PortScanResult] | |
| 40 | let portScanError: String? | |
| 41 | let changeSummary: DomainChangeSummary? | |
| 42 | let isLive: Bool | |
| 43 | } | |
| 44 | ||
| 45 | extension HistoryEntry { | |
| 46 | var snapshot: LookupSnapshot { | |
| 47 | LookupSnapshot( | |
| 48 | historyEntryID: id, | |
| 49 | domain: domain, | |
| 50 | timestamp: timestamp, | |
| 51 | trackedDomainID: trackedDomainID, | |
| 52 | resolverDisplayName: resolverDisplayName, | |
| 53 | resolverURLString: resolverURLString, | |
| 54 | totalLookupDurationMs: totalLookupDurationMs, | |
| 55 | dnsSections: dnsSections, | |
| 56 | dnsError: nil, | |
| 57 | availabilityResult: availabilityResult, | |
| 58 | suggestions: suggestions, | |
| 59 | sslInfo: sslInfo, | |
| 60 | sslError: sslError, | |
| 61 | hstsPreloaded: hstsPreloaded, | |
| 62 | httpHeaders: httpHeaders, | |
| 63 | httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue, | |
| 64 | httpStatusCode: nil, | |
| 65 | httpResponseTimeMs: nil, | |
| 66 | httpProtocol: nil, | |
| 67 | http3Advertised: false, | |
| 68 | httpHeadersError: httpHeadersError, | |
| 69 | reachabilityResults: reachabilityResults, | |
| 70 | reachabilityError: reachabilityError, | |
| 71 | ipGeolocation: ipGeolocation, | |
| 72 | ipGeolocationError: ipGeolocationError, | |
| 73 | emailSecurity: emailSecurity, | |
| 74 | emailSecurityError: emailSecurityError, | |
| 75 | ownership: ownership, | |
| 76 | ownershipError: ownershipError, | |
| 77 | ptrRecord: ptrRecord, | |
| 78 | ptrError: ptrError, | |
| 79 | redirectChain: redirectChain, | |
| 80 | redirectChainError: redirectChainError, | |
| 81 | subdomains: subdomains, | |
| 82 | subdomainsError: subdomainsError, | |
| 83 | portScanResults: portScanResults, | |
| 84 | portScanError: portScanError, | |
| 85 | changeSummary: changeSummary, | |
| 86 | isLive: false | |
| 87 | ) | |
| 88 | } | |
| 89 | } | |