krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v5.0.3: DomainDig/DomainInsightEngine.swift · raw
1import Foundation
2
3enum RiskLevel: String, Codable {
4 case low
5 case medium
6 case high
7
8 var title: String { rawValue.capitalized }
9}
10
11enum RiskImpact: String, Codable {
12 case positive
13 case neutral
14 case negative
15}
16
17struct RiskFactor: Codable, Equatable {
18 let description: String
19 let impact: RiskImpact
20}
21
22struct DomainRiskAssessment: Codable, Equatable {
23 let score: Int
24 let level: RiskLevel
25 let factors: [RiskFactor]
26}
27
28enum ChangeImpactClassification: String, Codable {
29 case informational
30 case warning
31 case critical
32
33 var title: String { rawValue.capitalized }
34}
35
36enum EmailSecurityGrade: String, Codable {
37 case a = "A"
38 case b = "B"
39 case c = "C"
40 case f = "F"
41}
42
43struct EmailSecurityAssessment: Codable, Equatable {
44 let grade: EmailSecurityGrade
45 let reasons: [String]
46}
47
48enum TLSGrade: String, Codable {
49 case a = "A"
50 case b = "B"
51 case c = "C"
52 case f = "F"
53}
54
55struct TLSSummaryAssessment: Codable, Equatable {
56 let grade: TLSGrade
57 let highlights: [String]
58}
59
60struct DNSPatternSummary: Codable, Equatable {
61 let providers: [String]
62 let wildcardDetected: Bool
63 let patterns: [String]
64}
65
66struct SubdomainGroup: Codable, Equatable, Identifiable {
67 let label: String
68 let subdomains: [String]
69
70 var id: String { label }
71}
72
73struct WorkflowInsight: Codable, Equatable, Identifiable {
74 let description: String
75 let domainsInvolved: [String]
76
77 var id: String {
78 ([description] + domainsInvolved.sorted()).joined(separator: "|")
79 }
80}
81
82struct DomainAnalysisBundle {
83 let riskAssessment: DomainRiskAssessment
84 let insights: [String]
85 let dnsPatterns: DNSPatternSummary
86 let emailAssessment: EmailSecurityAssessment?
87 let tlsAssessment: TLSSummaryAssessment
88 let subdomainGroups: [SubdomainGroup]
89}
90
91enum DomainInsightEngine {
92 static func analyze(snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainAnalysisBundle {
93 let dnsPatterns = dnsPatterns(for: snapshot)
94 let emailAssessment = emailAssessment(for: snapshot.emailSecurity)
95 let tlsAssessment = tlsAssessment(for: snapshot)
96 let subdomainGroups = groupedSubdomains(from: snapshot.subdomains.map(\.hostname))
97 let riskAssessment = riskAssessment(
98 for: snapshot,
99 previousSnapshot: previousSnapshot,
100 dnsPatterns: dnsPatterns,
101 emailAssessment: emailAssessment,
102 tlsAssessment: tlsAssessment,
103 subdomainGroups: subdomainGroups
104 )
105 let insights = insights(
106 for: snapshot,
107 previousSnapshot: previousSnapshot,
108 dnsPatterns: dnsPatterns,
109 emailAssessment: emailAssessment,
110 tlsAssessment: tlsAssessment,
111 subdomainGroups: subdomainGroups
112 )
113
114 return DomainAnalysisBundle(
115 riskAssessment: riskAssessment,
116 insights: insights,
117 dnsPatterns: dnsPatterns,
118 emailAssessment: emailAssessment,
119 tlsAssessment: tlsAssessment,
120 subdomainGroups: subdomainGroups
121 )
122 }
123
124 static func workflowInsights(for reports: [DomainReport]) -> [WorkflowInsight] {
125 var insights: [WorkflowInsight] = []
126
127 appendSharedInsights(
128 title: "Shared IP address observed",
129 groups: groupedDomains(for: reports, keyPath: \.dns.primaryIP),
130 into: &insights
131 )
132 appendSharedInsights(
133 title: "Shared nameserver set detected",
134 groups: groupedDomains(for: reports) {
135 let value = $0.ownership?.nameservers.sorted().joined(separator: "|")
136 return value?.nilIfEmpty
137 },
138 into: &insights
139 )
140 appendSharedInsights(
141 title: "Shared registrar detected",
142 groups: groupedDomains(for: reports) { $0.ownership?.registrar?.nilIfEmpty },
143 into: &insights
144 )
145 appendSharedInsights(
146 title: "Shared TLS issuer detected",
147 groups: groupedDomains(for: reports) { $0.web.tls?.issuer.nilIfEmpty },
148 into: &insights
149 )
150
151 return insights
152 }
153
154 static func impactClassification(
155 severity: ChangeSeverity,
156 riskDelta: Int,
157 changedSections: [String]
158 ) -> ChangeImpactClassification {
159 if severity == .high || riskDelta >= 20 || changedSections.contains(where: {
160 $0.localizedCaseInsensitiveContains("availability")
161 || $0.localizedCaseInsensitiveContains("certificate expires")
162 }) {
163 return .critical
164 }
165 if severity == .medium || riskDelta >= 8 || !changedSections.isEmpty {
166 return .warning
167 }
168 return .informational
169 }
170
171 private static func riskAssessment(
172 for snapshot: LookupSnapshot,
173 previousSnapshot: LookupSnapshot?,
174 dnsPatterns: DNSPatternSummary,
175 emailAssessment: EmailSecurityAssessment?,
176 tlsAssessment: TLSSummaryAssessment,
177 subdomainGroups: [SubdomainGroup]
178 ) -> DomainRiskAssessment {
179 var score = 18
180 var factors: [RiskFactor] = []
181
182 switch snapshot.availabilityResult?.status ?? .unknown {
183 case .available:
184 score -= 12
185 factors.append(.init(description: "Domain appears available rather than actively deployed", impact: .positive))
186 case .unknown:
187 score += 8
188 factors.append(.init(description: "Ownership and availability could not be confirmed", impact: .negative))
189 case .registered:
190 if !snapshot.dnsSections.isEmpty || snapshot.sslInfo != nil || !snapshot.redirectChain.isEmpty {
191 score += 6
192 factors.append(.init(description: "Registered domain exposes active infrastructure", impact: .negative))
193 } else {
194 factors.append(.init(description: "Registered domain with limited active surface detected", impact: .neutral))
195 }
196 }
197
198 let recordTypes = snapshot.dnsSections.filter { !$0.records.isEmpty || !$0.wildcardRecords.isEmpty }.count
199 if recordTypes >= 5 {
200 score += 8
201 factors.append(.init(description: "DNS configuration is broad across multiple record types", impact: .negative))
202 }
203 if dnsPatterns.wildcardDetected {
204 score += 12
205 factors.append(.init(description: "Wildcard DNS is enabled", impact: .negative))
206 }
207 if let firstPattern = dnsPatterns.patterns.first {
208 factors.append(.init(description: firstPattern, impact: .neutral))
209 }
210
211 switch tlsAssessment.grade {
212 case .a:
213 score -= 8
214 factors.append(.init(description: "TLS configuration looks current and stable", impact: .positive))
215 case .b:
216 score -= 3
217 factors.append(.init(description: "TLS is valid with minor concerns", impact: .positive))
218 case .c:
219 score += 10
220 factors.append(.init(description: "TLS configuration has visible weaknesses", impact: .negative))
221 case .f:
222 score += 22
223 factors.append(.init(description: "TLS is missing, invalid, or near failure", impact: .negative))
224 }
225
226 if let daysUntilExpiry = snapshot.sslInfo?.daysUntilExpiry, daysUntilExpiry <= 14 {
227 score += 10
228 factors.append(.init(description: "Certificate expires within 14 days", impact: .negative))
229 }
230
231 if snapshot.redirectChain.count >= 3 {
232 score += 8
233 factors.append(.init(description: "Redirect chain is longer than expected", impact: .negative))
234 }
235
236 if redirectLooksSensitive(snapshot.redirectChain.last?.url) {
237 score += 6
238 factors.append(.init(description: "Redirect target looks like an auth or account gateway", impact: .negative))
239 }
240
241 if let emailAssessment {
242 switch emailAssessment.grade {
243 case .a:
244 score -= 10
245 factors.append(.init(description: "Email protections are strong and aligned", impact: .positive))
246 case .b:
247 score -= 4
248 factors.append(.init(description: "Email protections are present with minor gaps", impact: .positive))
249 case .c:
250 score += 8
251 factors.append(.init(description: "Email protections are partial", impact: .negative))
252 case .f:
253 score += 18
254 factors.append(.init(description: "Email security protections are weak or absent", impact: .negative))
255 }
256 } else if hasMXRecords(snapshot) {
257 score += 14
258 factors.append(.init(description: "Mail is configured without enough email security evidence", impact: .negative))
259 }
260
261 let openPorts = snapshot.portScanResults.filter(\.open).map(\.port)
262 let sensitivePorts: Set<UInt16> = [21, 22, 23, 25, 3389, 5900]
263 let exposedSensitivePorts = openPorts.filter { sensitivePorts.contains($0) }
264 if !exposedSensitivePorts.isEmpty {
265 score += min(18, exposedSensitivePorts.count * 6)
266 factors.append(.init(description: "Sensitive management or mail ports are exposed", impact: .negative))
267 } else if Set(openPorts) == Set([80, 443]) {
268 factors.append(.init(description: "Exposure is limited to standard web ports", impact: .positive))
269 } else if openPorts.count >= 3 {
270 score += 8
271 factors.append(.init(description: "Multiple open services expand the attack surface", impact: .negative))
272 }
273
274 if !subdomainGroups.isEmpty {
275 let labels = Set(subdomainGroups.map(\.label))
276 if labels.contains("dev") || labels.contains("staging") {
277 score += 8
278 factors.append(.init(description: "Development or staging subdomains are discoverable", impact: .negative))
279 }
280 if labels.contains("admin") {
281 score += 10
282 factors.append(.init(description: "Administrative subdomains are discoverable", impact: .negative))
283 }
284 if snapshot.subdomains.count >= 8 {
285 score += 6
286 factors.append(.init(description: "Large passive subdomain footprint detected", impact: .negative))
287 }
288 }
289
290 if snapshot.ipGeolocation == nil,
291 snapshot.availabilityResult?.status == .registered,
292 snapshot.dnsSections.contains(where: { $0.recordType == .A && !$0.records.isEmpty }) {
293 score += 4
294 factors.append(.init(description: "Active host could not be geolocated", impact: .neutral))
295 }
296
297 if let previousSnapshot {
298 let previousAnalysis = analyze(snapshot: previousSnapshot)
299 let delta = score - previousAnalysis.riskAssessment.score
300 if delta >= 15 {
301 score += 4
302 factors.append(.init(description: "Observed risk has increased materially since the previous snapshot", impact: .negative))
303 }
304 }
305
306 if let reputation = snapshot.reputation {
307 switch reputation.status {
308 case .listed:
309 score += 25
310 let sourceList = reputation.listedSources.isEmpty ? "" : " (\(reputation.listedSources.joined(separator: ", ")))"
311 factors.append(.init(description: "Domain is flagged by a configured reputation source\(sourceList)", impact: .negative))
312 case .clean:
313 factors.append(.init(description: "Domain is clean against the configured reputation source", impact: .positive))
314 case .unknown:
315 break
316 }
317 }
318
319 let clampedScore = min(max(score, 0), 100)
320 let level: RiskLevel
321 switch clampedScore {
322 case 0..<35:
323 level = .low
324 case 35..<65:
325 level = .medium
326 default:
327 level = .high
328 }
329
330 return DomainRiskAssessment(score: clampedScore, level: level, factors: factors)
331 }
332
333 private static func insights(
334 for snapshot: LookupSnapshot,
335 previousSnapshot: LookupSnapshot?,
336 dnsPatterns: DNSPatternSummary,
337 emailAssessment: EmailSecurityAssessment?,
338 tlsAssessment: TLSSummaryAssessment,
339 subdomainGroups: [SubdomainGroup]
340 ) -> [String] {
341 var items: [String] = []
342
343 if snapshot.reputation?.status == .listed {
344 items.append("Domain is flagged by a configured reputation source")
345 }
346 if let group = subdomainGroups.first(where: { $0.label == "staging" || $0.label == "dev" }) {
347 items.append("Multiple \(group.label) subdomains suggest non-production environments are exposed")
348 }
349 if subdomainGroups.contains(where: { $0.label == "admin" }) {
350 items.append("Administrative subdomains are publicly discoverable")
351 }
352 if let emailAssessment, emailAssessment.grade == .f {
353 items.append("Domain lacks email security protections")
354 } else if let emailAssessment, emailAssessment.grade == .c {
355 items.append("Email security is only partially enforced")
356 }
357 if let daysUntilExpiry = snapshot.sslInfo?.daysUntilExpiry, daysUntilExpiry <= 30 {
358 items.append("Certificate expires soon")
359 }
360 if redirectLooksSensitive(snapshot.redirectChain.last?.url) {
361 items.append("Redirect chain may indicate login gateway")
362 }
363 items.append(contentsOf: dnsPatterns.patterns)
364
365 if let tlsVersion = snapshot.sslInfo?.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" {
366 items.append("TLS protocol version is outdated")
367 }
368 if tlsAssessment.grade == .f, snapshot.sslInfo == nil, snapshot.availabilityResult?.status == .registered {
369 items.append("HTTPS endpoint could not be validated")
370 }
371 if let previousSnapshot,
372 let previousURL = previousSnapshot.redirectChain.last?.url,
373 let currentURL = snapshot.redirectChain.last?.url,
374 previousURL != currentURL {
375 items.append("Redirect target changed since the previous snapshot")
376 }
377
378 var deduplicated: [String] = []
379 for item in items where !deduplicated.contains(item) {
380 deduplicated.append(item)
381 }
382 return deduplicated
383 }
384
385 private static func dnsPatterns(for snapshot: LookupSnapshot) -> DNSPatternSummary {
386 let nameservers = snapshot.ownership?.nameservers.map { $0.lowercased() } ?? []
387 let headerNames = Set(snapshot.httpHeaders.map { $0.name.lowercased() })
388 let headerValues = snapshot.httpHeaders.map { $0.value.lowercased() }
389 let allValues = snapshot.dnsSections.flatMap { section in
390 (section.records + section.wildcardRecords).map { $0.value.lowercased() }
391 }
392
393 var providers: [String] = []
394 if nameservers.contains(where: { $0.contains("cloudflare") }) || headerNames.contains("cf-ray") || headerNames.contains("cf-cache-status") {
395 providers.append("Cloudflare")
396 }
397 if nameservers.contains(where: { $0.contains("awsdns") }) || allValues.contains(where: { $0.contains("cloudfront.net") || $0.contains("elb.amazonaws.com") || $0.contains("amazonaws.com") }) {
398 providers.append("AWS")
399 }
400 if allValues.contains(where: { $0.contains("fastly.net") }) || headerValues.contains(where: { $0.contains("fastly") || $0.contains("cache-") }) {
401 providers.append("Fastly")
402 }
403
404 var patterns: [String] = []
405 let wildcardDetected = snapshot.dnsSections.contains { !$0.wildcardRecords.isEmpty }
406 if !providers.isEmpty {
407 patterns.append("CDN or edge network detected: \(providers.joined(separator: ", "))")
408 }
409 if wildcardDetected {
410 patterns.append("Wildcard DNS responses are present")
411 }
412 if hasMXRecords(snapshot), snapshot.emailSecurity == nil {
413 patterns.append("MX records exist without corresponding email security records")
414 }
415 if snapshot.sslInfo != nil, snapshot.dnsSections.first(where: { $0.recordType == .CAA })?.records.isEmpty != false {
416 patterns.append("TLS is active but no CAA record was found")
417 }
418
419 return DNSPatternSummary(providers: providers, wildcardDetected: wildcardDetected, patterns: patterns)
420 }
421
422 private static func emailAssessment(for result: EmailSecurityResult?) -> EmailSecurityAssessment? {
423 guard let result else { return nil }
424
425 let spfFound = result.spf.found
426 let dkimFound = result.dkim.found
427 let dmarcFound = result.dmarc.found
428 let dmarcStrict = isStrictDMARC(result.dmarc.value)
429
430 let dmarcReason: String
431 if !dmarcFound {
432 dmarcReason = "DMARC missing"
433 } else {
434 dmarcReason = dmarcStrict ? "DMARC policy is strict" : "DMARC policy is not strict"
435 }
436
437 let reasons = [
438 spfFound ? "SPF present" : "SPF missing",
439 dmarcReason,
440 dkimFound ? "DKIM present" : "DKIM not detected"
441 ]
442
443 let grade: EmailSecurityGrade
444 if spfFound && dkimFound && dmarcStrict {
445 grade = .a
446 } else if spfFound && dmarcFound && (dkimFound || dmarcStrict) {
447 grade = .b
448 } else if spfFound || dmarcFound || dkimFound {
449 grade = .c
450 } else {
451 grade = .f
452 }
453
454 return EmailSecurityAssessment(grade: grade, reasons: reasons)
455 }
456
457 private static func tlsAssessment(for snapshot: LookupSnapshot) -> TLSSummaryAssessment {
458 guard let sslInfo = snapshot.sslInfo else {
459 return TLSSummaryAssessment(grade: .f, highlights: ["TLS handshake failed or no certificate was returned"])
460 }
461
462 var issues: [String] = []
463 if sslInfo.daysUntilExpiry <= 14 {
464 issues.append("Certificate expires within 14 days")
465 } else if sslInfo.daysUntilExpiry <= 30 {
466 issues.append("Certificate expires within 30 days")
467 }
468 if let tlsVersion = sslInfo.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" {
469 issues.append("Uses \(tlsVersion)")
470 }
471 if let cipherSuite = sslInfo.cipherSuite?.lowercased(),
472 cipherSuite.contains("_cbc_") || cipherSuite.contains("3des") || cipherSuite.contains("rc4") {
473 issues.append("Negotiated cipher suite looks weak")
474 }
475
476 let grade: TLSGrade
477 if snapshot.sslError != nil {
478 grade = .f
479 } else if issues.contains(where: { $0.contains("14 days") || $0.contains("weak") || $0.contains("TLS 1.0") || $0.contains("TLS 1.1") }) {
480 grade = .c
481 } else if !issues.isEmpty {
482 grade = .b
483 } else {
484 grade = .a
485 }
486
487 return TLSSummaryAssessment(
488 grade: grade,
489 highlights: issues.isEmpty ? ["Certificate is valid and no weak TLS indicators were detected"] : issues
490 )
491 }
492
493 private static func groupedSubdomains(from subdomains: [String]) -> [SubdomainGroup] {
494 let labels = ["api", "dev", "staging", "admin"]
495 let normalized = Array(Set(subdomains.map { $0.lowercased() })).sorted()
496
497 return labels.compactMap { label in
498 let matches = normalized.filter {
499 guard let firstLabel = $0.split(separator: ".").first?.lowercased() else { return false }
500 return firstLabel == label
501 }
502 guard !matches.isEmpty else { return nil }
503 return SubdomainGroup(label: label, subdomains: matches)
504 }
505 }
506
507 private static func isStrictDMARC(_ value: String?) -> Bool {
508 guard let value = value?.lowercased() else { return false }
509 return value.contains("p=reject") || value.contains("p=quarantine")
510 }
511
512 private static func hasMXRecords(_ snapshot: LookupSnapshot) -> Bool {
513 snapshot.dnsSections.contains { $0.recordType == .MX && !$0.records.isEmpty }
514 }
515
516 private static func redirectLooksSensitive(_ urlString: String?) -> Bool {
517 guard let urlString = urlString?.lowercased() else { return false }
518 return urlString.contains("/login")
519 || urlString.contains("/signin")
520 || urlString.contains("/auth")
521 || urlString.contains("/account")
522 || urlString.contains("sso")
523 }
524
525 private static func appendSharedInsights(
526 title: String,
527 groups: [String: [String]],
528 into insights: inout [WorkflowInsight]
529 ) {
530 for domains in groups.values where domains.count >= 2 {
531 insights.append(
532 WorkflowInsight(
533 description: "\(title) across \(domains.count) domains",
534 domainsInvolved: domains.sorted()
535 )
536 )
537 }
538 }
539
540 private static func groupedDomains(
541 for reports: [DomainReport],
542 keyPath: KeyPath<DomainReport, String?>
543 ) -> [String: [String]] {
544 groupedDomains(for: reports) { $0[keyPath: keyPath]?.nilIfEmpty }
545 }
546
547 private static func groupedDomains(
548 for reports: [DomainReport],
549 transform: (DomainReport) -> String?
550 ) -> [String: [String]] {
551 var grouped: [String: [String]] = [:]
552 for report in reports {
553 guard let value = transform(report) else { continue }
554 grouped[value, default: []].append(report.domain)
555 }
556 return grouped
557 }
558}
559
560private extension String {
561 var nilIfEmpty: String? {
562 let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
563 return trimmed.isEmpty ? nil : trimmed
564 }
565}