krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.7.0: 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 reasons = [
431            spfFound ? "SPF present" : "SPF missing",
432            dmarcFound ? (dmarcStrict ? "DMARC policy is strict" : "DMARC policy is not strict") : "DMARC missing",
433            dkimFound ? "DKIM present" : "DKIM not detected"
434        ]
435
436        let grade: EmailSecurityGrade
437        if spfFound && dkimFound && dmarcStrict {
438            grade = .a
439        } else if spfFound && dmarcFound && (dkimFound || dmarcStrict) {
440            grade = .b
441        } else if spfFound || dmarcFound || dkimFound {
442            grade = .c
443        } else {
444            grade = .f
445        }
446
447        return EmailSecurityAssessment(grade: grade, reasons: reasons)
448    }
449
450    private static func tlsAssessment(for snapshot: LookupSnapshot) -> TLSSummaryAssessment {
451        guard let sslInfo = snapshot.sslInfo else {
452            return TLSSummaryAssessment(grade: .f, highlights: ["TLS handshake failed or no certificate was returned"])
453        }
454
455        var issues: [String] = []
456        if sslInfo.daysUntilExpiry <= 14 {
457            issues.append("Certificate expires within 14 days")
458        } else if sslInfo.daysUntilExpiry <= 30 {
459            issues.append("Certificate expires within 30 days")
460        }
461        if let tlsVersion = sslInfo.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" {
462            issues.append("Uses \(tlsVersion)")
463        }
464        if let cipherSuite = sslInfo.cipherSuite?.lowercased(),
465           cipherSuite.contains("_cbc_") || cipherSuite.contains("3des") || cipherSuite.contains("rc4") {
466            issues.append("Negotiated cipher suite looks weak")
467        }
468
469        let grade: TLSGrade
470        if snapshot.sslError != nil {
471            grade = .f
472        } else if issues.contains(where: { $0.contains("14 days") || $0.contains("weak") || $0.contains("TLS 1.0") || $0.contains("TLS 1.1") }) {
473            grade = .c
474        } else if !issues.isEmpty {
475            grade = .b
476        } else {
477            grade = .a
478        }
479
480        return TLSSummaryAssessment(
481            grade: grade,
482            highlights: issues.isEmpty ? ["Certificate is valid and no weak TLS indicators were detected"] : issues
483        )
484    }
485
486    private static func groupedSubdomains(from subdomains: [String]) -> [SubdomainGroup] {
487        let labels = ["api", "dev", "staging", "admin"]
488        let normalized = Array(Set(subdomains.map { $0.lowercased() })).sorted()
489
490        return labels.compactMap { label in
491            let matches = normalized.filter {
492                guard let firstLabel = $0.split(separator: ".").first?.lowercased() else { return false }
493                return firstLabel == label
494            }
495            guard !matches.isEmpty else { return nil }
496            return SubdomainGroup(label: label, subdomains: matches)
497        }
498    }
499
500    private static func isStrictDMARC(_ value: String?) -> Bool {
501        guard let value = value?.lowercased() else { return false }
502        return value.contains("p=reject") || value.contains("p=quarantine")
503    }
504
505    private static func hasMXRecords(_ snapshot: LookupSnapshot) -> Bool {
506        snapshot.dnsSections.contains { $0.recordType == .MX && !$0.records.isEmpty }
507    }
508
509    private static func redirectLooksSensitive(_ urlString: String?) -> Bool {
510        guard let urlString = urlString?.lowercased() else { return false }
511        return urlString.contains("/login")
512            || urlString.contains("/signin")
513            || urlString.contains("/auth")
514            || urlString.contains("/account")
515            || urlString.contains("sso")
516    }
517
518    private static func appendSharedInsights(
519        title: String,
520        groups: [String: [String]],
521        into insights: inout [WorkflowInsight]
522    ) {
523        for domains in groups.values where domains.count >= 2 {
524            insights.append(
525                WorkflowInsight(
526                    description: "\(title) across \(domains.count) domains",
527                    domainsInvolved: domains.sorted()
528                )
529            )
530        }
531    }
532
533    private static func groupedDomains(
534        for reports: [DomainReport],
535        keyPath: KeyPath<DomainReport, String?>
536    ) -> [String: [String]] {
537        groupedDomains(for: reports) { $0[keyPath: keyPath]?.nilIfEmpty }
538    }
539
540    private static func groupedDomains(
541        for reports: [DomainReport],
542        transform: (DomainReport) -> String?
543    ) -> [String: [String]] {
544        var grouped: [String: [String]] = [:]
545        for report in reports {
546            guard let value = transform(report) else { continue }
547            grouped[value, default: []].append(report.domain)
548        }
549        return grouped
550    }
551}
552
553private extension String {
554    var nilIfEmpty: String? {
555        let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
556        return trimmed.isEmpty ? nil : trimmed
557    }
558}