Commit 8465ef359f
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +4 −4
| @@ -265,7 +265,7 @@ | ||
| 265 | 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 266 | 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 267 | 267 | CODE_SIGN_STYLE = Automatic; |
| 268 | CURRENT_PROJECT_VERSION = 6; | |
| 268 | CURRENT_PROJECT_VERSION = 7; | |
| 269 | 269 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 270 | 270 | ENABLE_PREVIEWS = YES; |
| 271 | 271 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -282,7 +282,7 @@ | ||
| 282 | 282 | "$(inherited)", |
| 283 | 283 | "@executable_path/Frameworks", |
| 284 | 284 | ); |
| 285 | MARKETING_VERSION = 1.4.0; | |
| 285 | MARKETING_VERSION = 1.5.0; | |
| 286 | 286 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 287 | 287 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 288 | 288 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -301,7 +301,7 @@ | ||
| 301 | 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 302 | 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 303 | 303 | CODE_SIGN_STYLE = Automatic; |
| 304 | CURRENT_PROJECT_VERSION = 6; | |
| 304 | CURRENT_PROJECT_VERSION = 7; | |
| 305 | 305 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 306 | 306 | ENABLE_PREVIEWS = YES; |
| 307 | 307 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -318,7 +318,7 @@ | ||
| 318 | 318 | "$(inherited)", |
| 319 | 319 | "@executable_path/Frameworks", |
| 320 | 320 | ); |
| 321 | MARKETING_VERSION = 1.4.0; | |
| 321 | MARKETING_VERSION = 1.5.0; | |
| 322 | 322 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 323 | 323 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 324 | 324 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +32 −1
| @@ -381,6 +381,8 @@ struct ContentView: View { | ||
| 381 | 381 | emailSecurityRow("SPF", record: email.spf) |
| 382 | 382 | emailSecurityRow("DMARC", record: email.dmarc) |
| 383 | 383 | emailSecurityRow("DKIM", record: email.dkim) |
| 384 | emailSecurityRow("MTA-STS", mtaSts: email.mtaSts) | |
| 385 | emailSecurityRow("BIMI", record: email.bimi) | |
| 384 | 386 | } |
| 385 | 387 | } |
| 386 | 388 | } |
| @@ -394,7 +396,7 @@ struct ContentView: View { | ||
| 394 | 396 | Text(label) |
| 395 | 397 | .font(.system(.caption, design: .monospaced)) |
| 396 | 398 | .fontWeight(.semibold) |
| 397 | .frame(width: 52, alignment: .leading) | |
| 399 | .frame(width: 72, alignment: .leading) | |
| 398 | 400 | Text(record.found ? "✓" : "✗") |
| 399 | 401 | .font(.system(.caption, design: .monospaced)) |
| 400 | 402 | .foregroundStyle(record.found ? .green : .red) |
| @@ -411,6 +413,11 @@ struct ContentView: View { | ||
| 411 | 413 | expandedEmailField = isExpanded ? nil : label |
| 412 | 414 | } |
| 413 | 415 | } |
| 416 | if let selector = record.matchedSelector { | |
| 417 | Text("(selector: \(selector))") | |
| 418 | .font(.system(.caption2, design: .monospaced)) | |
| 419 | .foregroundStyle(.secondary) | |
| 420 | } | |
| 414 | 421 | } else { |
| 415 | 422 | Text("No record found") |
| 416 | 423 | .font(.system(.caption2, design: .monospaced)) |
| @@ -420,6 +427,30 @@ struct ContentView: View { | ||
| 420 | 427 | } |
| 421 | 428 | } |
| 422 | 429 | |
| 430 | private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View { | |
| 431 | VStack(alignment: .leading, spacing: 2) { | |
| 432 | HStack(spacing: 8) { | |
| 433 | Text(label) | |
| 434 | .font(.system(.caption, design: .monospaced)) | |
| 435 | .fontWeight(.semibold) | |
| 436 | .frame(width: 72, alignment: .leading) | |
| 437 | Text(mtaSts?.txtFound == true ? "✓" : "✗") | |
| 438 | .font(.system(.caption, design: .monospaced)) | |
| 439 | .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) | |
| 440 | if let policyMode = mtaSts?.policyMode { | |
| 441 | Text(policyMode) | |
| 442 | .font(.system(.caption2, design: .monospaced)) | |
| 443 | .foregroundStyle(.primary) | |
| 444 | .textSelection(.enabled) | |
| 445 | } else { | |
| 446 | Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") | |
| 447 | .font(.system(.caption2, design: .monospaced)) | |
| 448 | .foregroundStyle(.secondary) | |
| 449 | } | |
| 450 | } | |
| 451 | } | |
| 452 | } | |
| 453 | ||
| 423 | 454 | // MARK: - SSL Results |
| 424 | 455 | |
| 425 | 456 | private var sslResultsSection: some View { |
DomainDig/DomainViewModel.swift +17 −1
| @@ -115,6 +115,7 @@ final class DomainViewModel { | ||
| 115 | 115 | reachabilityResults: reachabilityResults, |
| 116 | 116 | ipGeolocation: ipGeolocation, |
| 117 | 117 | emailSecurity: emailSecurity, |
| 118 | mtaSts: emailSecurity?.mtaSts, | |
| 118 | 119 | ptrRecord: ptrRecord, |
| 119 | 120 | redirectChain: redirectChain, |
| 120 | 121 | portScanResults: portScanResults, |
| @@ -482,7 +483,22 @@ final class DomainViewModel { | ||
| 482 | 483 | lines.append("--------------") |
| 483 | 484 | lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")") |
| 484 | 485 | lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")") |
| 485 | lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(email.dkim.value ?? "No record found")") | |
| 486 | let dkimValue = if let selector = email.dkim.matchedSelector, | |
| 487 | let value = email.dkim.value { | |
| 488 | "\(value) (selector: \(selector))" | |
| 489 | } else { | |
| 490 | email.dkim.value ?? "No record found" | |
| 491 | } | |
| 492 | lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)") | |
| 493 | let mtaDescription = if let mode = email.mtaSts?.policyMode { | |
| 494 | "mode: \(mode)" | |
| 495 | } else if email.mtaSts?.txtFound == true { | |
| 496 | "Policy unavailable" | |
| 497 | } else { | |
| 498 | "No record found" | |
| 499 | } | |
| 500 | lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)") | |
| 501 | lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")") | |
| 486 | 502 | } |
| 487 | 503 | |
| 488 | 504 | // SSL |
DomainDig/EmailSecurityService.swift +88 −15
| @@ -1,6 +1,11 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | 3 | struct EmailSecurityService { |
| 4 | private static let dkimSelectors = [ | |
| 5 | "default", "google", "mail", "selector1", "selector2", "k1", | |
| 6 | "smtp", "dkim", "zoho", "mailchimp" | |
| 7 | ] | |
| 8 | ||
| 4 | 9 | /// Analyze email security records. SPF is parsed from existing TXT records; |
| 5 | 10 | /// DMARC and DKIM require additional DoH queries. |
| 6 | 11 | static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult { |
| @@ -8,12 +13,19 @@ struct EmailSecurityService { | ||
| 8 | 13 | let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") }) |
| 9 | 14 | let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value) |
| 10 | 15 | |
| 11 | // DMARC and DKIM queries in parallel | |
| 16 | // DMARC, DKIM, BIMI, and MTA-STS queries in parallel. | |
| 12 | 17 | async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)") |
| 13 | 18 | async let dkimResult = queryDKIM(domain: domain) |
| 19 | async let bimiResult = queryMatchingTXT( | |
| 20 | subdomain: "default._bimi.\(domain)", | |
| 21 | prefix: "v=BIMI1" | |
| 22 | ) | |
| 23 | async let mtaStsResult = queryMTASTS(domain: domain) | |
| 14 | 24 | |
| 15 | 25 | let dmarcValue = await dmarcResult |
| 16 | 26 | let dkimValue = await dkimResult |
| 27 | let bimiValue = await bimiResult | |
| 28 | let mtaSts = await mtaStsResult | |
| 17 | 29 | |
| 18 | 30 | let dmarc = EmailSecurityRecord( |
| 19 | 31 | found: dmarcValue != nil, |
| @@ -21,10 +33,21 @@ struct EmailSecurityService { | ||
| 21 | 33 | ) |
| 22 | 34 | let dkim = EmailSecurityRecord( |
| 23 | 35 | found: dkimValue != nil, |
| 24 | value: dkimValue | |
| 36 | value: dkimValue?.value, | |
| 37 | matchedSelector: dkimValue?.selector | |
| 38 | ) | |
| 39 | let bimi = EmailSecurityRecord( | |
| 40 | found: bimiValue != nil, | |
| 41 | value: bimiValue | |
| 25 | 42 | ) |
| 26 | 43 | |
| 27 | return EmailSecurityResult(spf: spf, dmarc: dmarc, dkim: dkim) | |
| 44 | return EmailSecurityResult( | |
| 45 | spf: spf, | |
| 46 | dmarc: dmarc, | |
| 47 | dkim: dkim, | |
| 48 | bimi: bimi, | |
| 49 | mtaSts: mtaSts | |
| 50 | ) | |
| 28 | 51 | } |
| 29 | 52 | |
| 30 | 53 | /// Query a TXT record for the given subdomain via DoH. |
| @@ -37,25 +60,75 @@ struct EmailSecurityService { | ||
| 37 | 60 | } |
| 38 | 61 | } |
| 39 | 62 | |
| 40 | /// Try common DKIM selectors and return the first found. | |
| 41 | private static func queryDKIM(domain: String) async -> String? { | |
| 42 | let selectors = ["default", "google", "mail"] | |
| 43 | return await withTaskGroup(of: (Int, String?).self, returning: String?.self) { group in | |
| 44 | for (index, selector) in selectors.enumerated() { | |
| 63 | private static func queryMatchingTXT(subdomain: String, prefix: String) async -> String? { | |
| 64 | do { | |
| 65 | let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT) | |
| 66 | return records.first(where: { $0.value.hasPrefix(prefix) })?.value | |
| 67 | } catch { | |
| 68 | return nil | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | /// Try common DKIM selectors concurrently and return the first valid result. | |
| 73 | private static func queryDKIM(domain: String) async -> (selector: String, value: String)? { | |
| 74 | await withTaskGroup(of: (selector: String, value: String?).self) { group in | |
| 75 | for selector in dkimSelectors { | |
| 45 | 76 | group.addTask { |
| 46 | 77 | let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)") |
| 47 | return (index, value) | |
| 78 | return (selector, value) | |
| 48 | 79 | } |
| 49 | 80 | } |
| 50 | 81 | |
| 51 | var results: [(Int, String?)] = [] | |
| 52 | 82 | for await result in group { |
| 53 | results.append(result) | |
| 83 | if let value = result.value, !value.isEmpty { | |
| 84 | group.cancelAll() | |
| 85 | return (result.selector, value) | |
| 86 | } | |
| 54 | 87 | } |
| 55 | // Return the first (by selector order) that has a value | |
| 56 | return results | |
| 57 | .sorted { $0.0 < $1.0 } | |
| 58 | .first(where: { $0.1 != nil })?.1 | |
| 88 | ||
| 89 | return nil | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | private static func queryMTASTS(domain: String) async -> MTASTSResult? { | |
| 94 | let txtValue = await queryMatchingTXT(subdomain: "_mta-sts.\(domain)", prefix: "v=STSv1") | |
| 95 | guard txtValue != nil else { | |
| 96 | return nil | |
| 59 | 97 | } |
| 98 | ||
| 99 | return MTASTSResult( | |
| 100 | txtFound: true, | |
| 101 | policyMode: await fetchMTASTSPolicyMode(domain: domain) | |
| 102 | ) | |
| 103 | } | |
| 104 | ||
| 105 | private static func fetchMTASTSPolicyMode(domain: String) async -> String? { | |
| 106 | guard let url = URL(string: "https://mta-sts.\(domain)/.well-known/mta-sts.txt") else { | |
| 107 | return nil | |
| 108 | } | |
| 109 | ||
| 110 | var request = URLRequest(url: url) | |
| 111 | request.timeoutInterval = 5 | |
| 112 | ||
| 113 | do { | |
| 114 | let (data, _) = try await URLSession.shared.data(for: request) | |
| 115 | let policy = String(decoding: data, as: UTF8.self) | |
| 116 | ||
| 117 | for line in policy.split(whereSeparator: \.isNewline) { | |
| 118 | let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 119 | guard trimmedLine.lowercased().hasPrefix("mode:") else { | |
| 120 | continue | |
| 121 | } | |
| 122 | ||
| 123 | let mode = trimmedLine.dropFirst("mode:".count) | |
| 124 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 125 | .lowercased() | |
| 126 | return ["enforce", "testing", "none"].contains(mode) ? mode : nil | |
| 127 | } | |
| 128 | } catch { | |
| 129 | return nil | |
| 130 | } | |
| 131 | ||
| 132 | return nil | |
| 60 | 133 | } |
| 61 | 134 | } |
DomainDig/HistoryView.swift +32 −1
| @@ -254,6 +254,8 @@ struct HistoryDetailView: View { | ||
| 254 | 254 | historyEmailRow("SPF", record: email.spf) |
| 255 | 255 | historyEmailRow("DMARC", record: email.dmarc) |
| 256 | 256 | historyEmailRow("DKIM", record: email.dkim) |
| 257 | historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts) | |
| 258 | historyEmailRow("BIMI", record: email.bimi) | |
| 257 | 259 | } |
| 258 | 260 | } |
| 259 | 261 | } |
| @@ -267,7 +269,7 @@ struct HistoryDetailView: View { | ||
| 267 | 269 | Text(label) |
| 268 | 270 | .font(.system(.caption, design: .monospaced)) |
| 269 | 271 | .fontWeight(.semibold) |
| 270 | .frame(width: 52, alignment: .leading) | |
| 272 | .frame(width: 72, alignment: .leading) | |
| 271 | 273 | Text(record.found ? "✓" : "✗") |
| 272 | 274 | .font(.system(.caption, design: .monospaced)) |
| 273 | 275 | .foregroundStyle(record.found ? .green : .red) |
| @@ -284,6 +286,11 @@ struct HistoryDetailView: View { | ||
| 284 | 286 | expandedEmailField = isExpanded ? nil : label |
| 285 | 287 | } |
| 286 | 288 | } |
| 289 | if let selector = record.matchedSelector { | |
| 290 | Text("(selector: \(selector))") | |
| 291 | .font(.system(.caption2, design: .monospaced)) | |
| 292 | .foregroundStyle(.secondary) | |
| 293 | } | |
| 287 | 294 | } else { |
| 288 | 295 | Text("No record found") |
| 289 | 296 | .font(.system(.caption2, design: .monospaced)) |
| @@ -293,6 +300,30 @@ struct HistoryDetailView: View { | ||
| 293 | 300 | } |
| 294 | 301 | } |
| 295 | 302 | |
| 303 | private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View { | |
| 304 | VStack(alignment: .leading, spacing: 2) { | |
| 305 | HStack(spacing: 8) { | |
| 306 | Text(label) | |
| 307 | .font(.system(.caption, design: .monospaced)) | |
| 308 | .fontWeight(.semibold) | |
| 309 | .frame(width: 72, alignment: .leading) | |
| 310 | Text(mtaSts?.txtFound == true ? "✓" : "✗") | |
| 311 | .font(.system(.caption, design: .monospaced)) | |
| 312 | .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) | |
| 313 | if let policyMode = mtaSts?.policyMode { | |
| 314 | Text(policyMode) | |
| 315 | .font(.system(.caption2, design: .monospaced)) | |
| 316 | .foregroundStyle(.primary) | |
| 317 | .textSelection(.enabled) | |
| 318 | } else { | |
| 319 | Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") | |
| 320 | .font(.system(.caption2, design: .monospaced)) | |
| 321 | .foregroundStyle(.secondary) | |
| 322 | } | |
| 323 | } | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 296 | 327 | // MARK: - SSL |
| 297 | 328 | |
| 298 | 329 | private var sslSection: some View { |
DomainDig/Models.swift +49 −1
| @@ -159,11 +159,56 @@ struct EmailSecurityResult: Codable { | ||
| 159 | 159 | let spf: EmailSecurityRecord |
| 160 | 160 | let dmarc: EmailSecurityRecord |
| 161 | 161 | let dkim: EmailSecurityRecord |
| 162 | let bimi: EmailSecurityRecord | |
| 163 | let mtaSts: MTASTSResult? | |
| 164 | ||
| 165 | init( | |
| 166 | spf: EmailSecurityRecord, | |
| 167 | dmarc: EmailSecurityRecord, | |
| 168 | dkim: EmailSecurityRecord, | |
| 169 | bimi: EmailSecurityRecord = EmailSecurityRecord(found: false, value: nil), | |
| 170 | mtaSts: MTASTSResult? = nil | |
| 171 | ) { | |
| 172 | self.spf = spf | |
| 173 | self.dmarc = dmarc | |
| 174 | self.dkim = dkim | |
| 175 | self.bimi = bimi | |
| 176 | self.mtaSts = mtaSts | |
| 177 | } | |
| 178 | ||
| 179 | init(from decoder: Decoder) throws { | |
| 180 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 181 | spf = try container.decode(EmailSecurityRecord.self, forKey: .spf) | |
| 182 | dmarc = try container.decode(EmailSecurityRecord.self, forKey: .dmarc) | |
| 183 | dkim = try container.decode(EmailSecurityRecord.self, forKey: .dkim) | |
| 184 | bimi = try container.decodeIfPresent(EmailSecurityRecord.self, forKey: .bimi) | |
| 185 | ?? EmailSecurityRecord(found: false, value: nil) | |
| 186 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) | |
| 187 | } | |
| 162 | 188 | } |
| 163 | 189 | |
| 164 | 190 | struct EmailSecurityRecord: Codable { |
| 165 | 191 | let found: Bool |
| 166 | 192 | let value: String? |
| 193 | let matchedSelector: String? | |
| 194 | ||
| 195 | init(found: Bool, value: String?, matchedSelector: String? = nil) { | |
| 196 | self.found = found | |
| 197 | self.value = value | |
| 198 | self.matchedSelector = matchedSelector | |
| 199 | } | |
| 200 | ||
| 201 | init(from decoder: Decoder) throws { | |
| 202 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 203 | found = try container.decode(Bool.self, forKey: .found) | |
| 204 | value = try container.decodeIfPresent(String.self, forKey: .value) | |
| 205 | matchedSelector = try container.decodeIfPresent(String.self, forKey: .matchedSelector) | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | struct MTASTSResult: Codable { | |
| 210 | let txtFound: Bool | |
| 211 | let policyMode: String? | |
| 167 | 212 | } |
| 168 | 213 | |
| 169 | 214 | // MARK: - Redirect Chain Models |
| @@ -197,6 +242,7 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 197 | 242 | let reachabilityResults: [PortReachability] |
| 198 | 243 | let ipGeolocation: IPGeolocation? |
| 199 | 244 | var emailSecurity: EmailSecurityResult? |
| 245 | var mtaSts: MTASTSResult? | |
| 200 | 246 | var ptrRecord: String? |
| 201 | 247 | var redirectChain: [RedirectHop] |
| 202 | 248 | var portScanResults: [PortScanResult] |
| @@ -205,7 +251,7 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 205 | 251 | init(domain: String, timestamp: Date, dnsSections: [DNSSection], |
| 206 | 252 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], |
| 207 | 253 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 208 | emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil, | |
| 254 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, | |
| 209 | 255 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], |
| 210 | 256 | hstsPreloaded: Bool? = nil) { |
| 211 | 257 | self.domain = domain |
| @@ -216,6 +262,7 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 216 | 262 | self.reachabilityResults = reachabilityResults |
| 217 | 263 | self.ipGeolocation = ipGeolocation |
| 218 | 264 | self.emailSecurity = emailSecurity |
| 265 | self.mtaSts = mtaSts ?? emailSecurity?.mtaSts | |
| 219 | 266 | self.ptrRecord = ptrRecord |
| 220 | 267 | self.redirectChain = redirectChain |
| 221 | 268 | self.portScanResults = portScanResults |
| @@ -233,6 +280,7 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 233 | 280 | reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults) |
| 234 | 281 | ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) |
| 235 | 282 | emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) |
| 283 | mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts | |
| 236 | 284 | ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) |
| 237 | 285 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] |
| 238 | 286 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] |