diff options
Diffstat (limited to 'DomainReportBuilder.swift')
| -rw-r--r-- | DomainReportBuilder.swift | 190 |
1 files changed, 190 insertions, 0 deletions
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 + } +} |
