krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.6.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        let clampedScore = min(max(score, 0), 100)
307        let level: RiskLevel
308        switch clampedScore {
309        case 0..<35:
310            level = .low
311        case 35..<65:
312            level = .medium
313        default:
314            level = .high
315        }
316
317        return DomainRiskAssessment(score: clampedScore, level: level, factors: factors)
318    }
319
320    private static func insights(
321        for snapshot: LookupSnapshot,
322        previousSnapshot: LookupSnapshot?,
323        dnsPatterns: DNSPatternSummary,
324        emailAssessment: EmailSecurityAssessment?,
325        tlsAssessment: TLSSummaryAssessment,
326        subdomainGroups: [SubdomainGroup]
327    ) -> [String] {
328        var items: [String] = []
329
330        if let group = subdomainGroups.first(where: { $0.label == "staging" || $0.label == "dev" }) {
331            items.append("Multiple \(group.label) subdomains suggest non-production environments are exposed")
332        }
333        if subdomainGroups.contains(where: { $0.label == "admin" }) {
334            items.append("Administrative subdomains are publicly discoverable")
335        }
336        if let emailAssessment, emailAssessment.grade == .f {
337            items.append("Domain lacks email security protections")
338        } else if let emailAssessment, emailAssessment.grade == .c {
339            items.append("Email security is only partially enforced")
340        }
341        if let daysUntilExpiry = snapshot.sslInfo?.daysUntilExpiry, daysUntilExpiry <= 30 {
342            items.append("Certificate expires soon")
343        }
344        if redirectLooksSensitive(snapshot.redirectChain.last?.url) {
345            items.append("Redirect chain may indicate login gateway")
346        }
347        items.append(contentsOf: dnsPatterns.patterns)
348
349        if let tlsVersion = snapshot.sslInfo?.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" {
350            items.append("TLS protocol version is outdated")
351        }
352        if tlsAssessment.grade == .f, snapshot.sslInfo == nil, snapshot.availabilityResult?.status == .registered {
353            items.append("HTTPS endpoint could not be validated")
354        }
355        if let previousSnapshot,
356           let previousURL = previousSnapshot.redirectChain.last?.url,
357           let currentURL = snapshot.redirectChain.last?.url,
358           previousURL != currentURL {
359            items.append("Redirect target changed since the previous snapshot")
360        }
361
362        var deduplicated: [String] = []
363        for item in items where !deduplicated.contains(item) {
364            deduplicated.append(item)
365        }
366        return deduplicated
367    }
368
369    private static func dnsPatterns(for snapshot: LookupSnapshot) -> DNSPatternSummary {
370        let nameservers = snapshot.ownership?.nameservers.map { $0.lowercased() } ?? []
371        let headerNames = Set(snapshot.httpHeaders.map { $0.name.lowercased() })
372        let headerValues = snapshot.httpHeaders.map { $0.value.lowercased() }
373        let allValues = snapshot.dnsSections.flatMap { section in
374            (section.records + section.wildcardRecords).map { $0.value.lowercased() }
375        }
376
377        var providers: [String] = []
378        if nameservers.contains(where: { $0.contains("cloudflare") }) || headerNames.contains("cf-ray") || headerNames.contains("cf-cache-status") {
379            providers.append("Cloudflare")
380        }
381        if nameservers.contains(where: { $0.contains("awsdns") }) || allValues.contains(where: { $0.contains("cloudfront.net") || $0.contains("elb.amazonaws.com") || $0.contains("amazonaws.com") }) {
382            providers.append("AWS")
383        }
384        if allValues.contains(where: { $0.contains("fastly.net") }) || headerValues.contains(where: { $0.contains("fastly") || $0.contains("cache-") }) {
385            providers.append("Fastly")
386        }
387
388        var patterns: [String] = []
389        let wildcardDetected = snapshot.dnsSections.contains { !$0.wildcardRecords.isEmpty }
390        if !providers.isEmpty {
391            patterns.append("CDN or edge network detected: \(providers.joined(separator: ", "))")
392        }
393        if wildcardDetected {
394            patterns.append("Wildcard DNS responses are present")
395        }
396        if hasMXRecords(snapshot), snapshot.emailSecurity == nil {
397            patterns.append("MX records exist without corresponding email security records")
398        }
399        if snapshot.sslInfo != nil && !(snapshot.dnsSections.first(where: { $0.recordType == .CAA })?.records.isEmpty == false) {
400            patterns.append("TLS is active but no CAA record was found")
401        }
402
403        return DNSPatternSummary(providers: providers, wildcardDetected: wildcardDetected, patterns: patterns)
404    }
405
406    private static func emailAssessment(for result: EmailSecurityResult?) -> EmailSecurityAssessment? {
407        guard let result else { return nil }
408
409        let spfFound = result.spf.found
410        let dkimFound = result.dkim.found
411        let dmarcFound = result.dmarc.found
412        let dmarcStrict = isStrictDMARC(result.dmarc.value)
413
414        let reasons = [
415            spfFound ? "SPF present" : "SPF missing",
416            dmarcFound ? (dmarcStrict ? "DMARC policy is strict" : "DMARC policy is not strict") : "DMARC missing",
417            dkimFound ? "DKIM present" : "DKIM not detected"
418        ]
419
420        let grade: EmailSecurityGrade
421        if spfFound && dkimFound && dmarcStrict {
422            grade = .a
423        } else if spfFound && dmarcFound && (dkimFound || dmarcStrict) {
424            grade = .b
425        } else if spfFound || dmarcFound || dkimFound {
426            grade = .c
427        } else {
428            grade = .f
429        }
430
431        return EmailSecurityAssessment(grade: grade, reasons: reasons)
432    }
433
434    private static func tlsAssessment(for snapshot: LookupSnapshot) -> TLSSummaryAssessment {
435        guard let sslInfo = snapshot.sslInfo else {
436            return TLSSummaryAssessment(grade: .f, highlights: ["TLS handshake failed or no certificate was returned"])
437        }
438
439        var issues: [String] = []
440        if sslInfo.daysUntilExpiry <= 14 {
441            issues.append("Certificate expires within 14 days")
442        } else if sslInfo.daysUntilExpiry <= 30 {
443            issues.append("Certificate expires within 30 days")
444        }
445        if let tlsVersion = sslInfo.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" {
446            issues.append("Uses \(tlsVersion)")
447        }
448        if let cipherSuite = sslInfo.cipherSuite?.lowercased(),
449           cipherSuite.contains("_cbc_") || cipherSuite.contains("3des") || cipherSuite.contains("rc4") {
450            issues.append("Negotiated cipher suite looks weak")
451        }
452
453        let grade: TLSGrade
454        if snapshot.sslError != nil {
455            grade = .f
456        } else if issues.contains(where: { $0.contains("14 days") || $0.contains("weak") || $0.contains("TLS 1.0") || $0.contains("TLS 1.1") }) {
457            grade = .c
458        } else if !issues.isEmpty {
459            grade = .b
460        } else {
461            grade = .a
462        }
463
464        return TLSSummaryAssessment(
465            grade: grade,
466            highlights: issues.isEmpty ? ["Certificate is valid and no weak TLS indicators were detected"] : issues
467        )
468    }
469
470    private static func groupedSubdomains(from subdomains: [String]) -> [SubdomainGroup] {
471        let labels = ["api", "dev", "staging", "admin"]
472        let normalized = Array(Set(subdomains.map { $0.lowercased() })).sorted()
473
474        return labels.compactMap { label in
475            let matches = normalized.filter {
476                guard let firstLabel = $0.split(separator: ".").first?.lowercased() else { return false }
477                return firstLabel == label
478            }
479            guard !matches.isEmpty else { return nil }
480            return SubdomainGroup(label: label, subdomains: matches)
481        }
482    }
483
484    private static func isStrictDMARC(_ value: String?) -> Bool {
485        guard let value = value?.lowercased() else { return false }
486        return value.contains("p=reject") || value.contains("p=quarantine")
487    }
488
489    private static func hasMXRecords(_ snapshot: LookupSnapshot) -> Bool {
490        snapshot.dnsSections.contains { $0.recordType == .MX && !$0.records.isEmpty }
491    }
492
493    private static func redirectLooksSensitive(_ urlString: String?) -> Bool {
494        guard let urlString = urlString?.lowercased() else { return false }
495        return urlString.contains("/login")
496            || urlString.contains("/signin")
497            || urlString.contains("/auth")
498            || urlString.contains("/account")
499            || urlString.contains("sso")
500    }
501
502    private static func appendSharedInsights(
503        title: String,
504        groups: [String: [String]],
505        into insights: inout [WorkflowInsight]
506    ) {
507        for domains in groups.values where domains.count >= 2 {
508            insights.append(
509                WorkflowInsight(
510                    description: "\(title) across \(domains.count) domains",
511                    domainsInvolved: domains.sorted()
512                )
513            )
514        }
515    }
516
517    private static func groupedDomains(
518        for reports: [DomainReport],
519        keyPath: KeyPath<DomainReport, String?>
520    ) -> [String: [String]] {
521        groupedDomains(for: reports) { $0[keyPath: keyPath]?.nilIfEmpty }
522    }
523
524    private static func groupedDomains(
525        for reports: [DomainReport],
526        transform: (DomainReport) -> String?
527    ) -> [String: [String]] {
528        var grouped: [String: [String]] = [:]
529        for report in reports {
530            guard let value = transform(report) else { continue }
531            grouped[value, default: []].append(report.domain)
532        }
533        return grouped
534    }
535}
536
537private extension String {
538    var nilIfEmpty: String? {
539        let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
540        return trimmed.isEmpty ? nil : trimmed
541    }
542}