summaryrefslogtreecommitdiff
path: root/DomainReportExporter.swift
diff options
context:
space:
mode:
Diffstat (limited to 'DomainReportExporter.swift')
-rw-r--r--DomainReportExporter.swift367
1 files changed, 367 insertions, 0 deletions
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
+ }
+}