From ceac99c05f180d9008b75c9329822466ff60255b Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 22 Apr 2026 11:28:28 -0500 Subject: feat(v2.9.0): add risk scoring and deterministic insight engine * introduce domain risk assessment with transparent factors * add insight engine for actionable observations * implement cross-domain insights for workflows * improve DNS, subdomain, email, and TLS interpretation * classify change impact severity * include insights and risk in export --- DomainDig/DomainInsightEngine.swift | 542 ++++++++++++++++++++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 DomainDig/DomainInsightEngine.swift (limited to 'DomainDig/DomainInsightEngine.swift') diff --git a/DomainDig/DomainInsightEngine.swift b/DomainDig/DomainInsightEngine.swift new file mode 100644 index 0000000..2547727 --- /dev/null +++ b/DomainDig/DomainInsightEngine.swift @@ -0,0 +1,542 @@ +import Foundation + +enum RiskLevel: String, Codable { + case low + case medium + case high + + var title: String { rawValue.capitalized } +} + +enum RiskImpact: String, Codable { + case positive + case neutral + case negative +} + +struct RiskFactor: Codable, Equatable { + let description: String + let impact: RiskImpact +} + +struct DomainRiskAssessment: Codable, Equatable { + let score: Int + let level: RiskLevel + let factors: [RiskFactor] +} + +enum ChangeImpactClassification: String, Codable { + case informational + case warning + case critical + + var title: String { rawValue.capitalized } +} + +enum EmailSecurityGrade: String, Codable { + case a = "A" + case b = "B" + case c = "C" + case f = "F" +} + +struct EmailSecurityAssessment: Codable, Equatable { + let grade: EmailSecurityGrade + let reasons: [String] +} + +enum TLSGrade: String, Codable { + case a = "A" + case b = "B" + case c = "C" + case f = "F" +} + +struct TLSSummaryAssessment: Codable, Equatable { + let grade: TLSGrade + let highlights: [String] +} + +struct DNSPatternSummary: Codable, Equatable { + let providers: [String] + let wildcardDetected: Bool + let patterns: [String] +} + +struct SubdomainGroup: Codable, Equatable, Identifiable { + let label: String + let subdomains: [String] + + var id: String { label } +} + +struct WorkflowInsight: Codable, Equatable, Identifiable { + let description: String + let domainsInvolved: [String] + + var id: String { + ([description] + domainsInvolved.sorted()).joined(separator: "|") + } +} + +struct DomainAnalysisBundle { + let riskAssessment: DomainRiskAssessment + let insights: [String] + let dnsPatterns: DNSPatternSummary + let emailAssessment: EmailSecurityAssessment? + let tlsAssessment: TLSSummaryAssessment + let subdomainGroups: [SubdomainGroup] +} + +enum DomainInsightEngine { + static func analyze(snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainAnalysisBundle { + let dnsPatterns = dnsPatterns(for: snapshot) + let emailAssessment = emailAssessment(for: snapshot.emailSecurity) + let tlsAssessment = tlsAssessment(for: snapshot) + let subdomainGroups = groupedSubdomains(from: snapshot.subdomains.map(\.hostname)) + let riskAssessment = riskAssessment( + for: snapshot, + previousSnapshot: previousSnapshot, + dnsPatterns: dnsPatterns, + emailAssessment: emailAssessment, + tlsAssessment: tlsAssessment, + subdomainGroups: subdomainGroups + ) + let insights = insights( + for: snapshot, + previousSnapshot: previousSnapshot, + dnsPatterns: dnsPatterns, + emailAssessment: emailAssessment, + tlsAssessment: tlsAssessment, + subdomainGroups: subdomainGroups + ) + + return DomainAnalysisBundle( + riskAssessment: riskAssessment, + insights: insights, + dnsPatterns: dnsPatterns, + emailAssessment: emailAssessment, + tlsAssessment: tlsAssessment, + subdomainGroups: subdomainGroups + ) + } + + static func workflowInsights(for reports: [DomainReport]) -> [WorkflowInsight] { + var insights: [WorkflowInsight] = [] + + appendSharedInsights( + title: "Shared IP address observed", + groups: groupedDomains(for: reports, keyPath: \.dns.primaryIP), + into: &insights + ) + appendSharedInsights( + title: "Shared nameserver set detected", + groups: groupedDomains(for: reports) { + let value = $0.ownership?.nameservers.sorted().joined(separator: "|") + return value?.nilIfEmpty + }, + into: &insights + ) + appendSharedInsights( + title: "Shared registrar detected", + groups: groupedDomains(for: reports) { $0.ownership?.registrar?.nilIfEmpty }, + into: &insights + ) + appendSharedInsights( + title: "Shared TLS issuer detected", + groups: groupedDomains(for: reports) { $0.web.tls?.issuer.nilIfEmpty }, + into: &insights + ) + + return insights + } + + static func impactClassification( + severity: ChangeSeverity, + riskDelta: Int, + changedSections: [String] + ) -> ChangeImpactClassification { + if severity == .high || riskDelta >= 20 || changedSections.contains(where: { + $0.localizedCaseInsensitiveContains("availability") + || $0.localizedCaseInsensitiveContains("certificate expires") + }) { + return .critical + } + if severity == .medium || riskDelta >= 8 || !changedSections.isEmpty { + return .warning + } + return .informational + } + + private static func riskAssessment( + for snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot?, + dnsPatterns: DNSPatternSummary, + emailAssessment: EmailSecurityAssessment?, + tlsAssessment: TLSSummaryAssessment, + subdomainGroups: [SubdomainGroup] + ) -> DomainRiskAssessment { + var score = 18 + var factors: [RiskFactor] = [] + + switch snapshot.availabilityResult?.status ?? .unknown { + case .available: + score -= 12 + factors.append(.init(description: "Domain appears available rather than actively deployed", impact: .positive)) + case .unknown: + score += 8 + factors.append(.init(description: "Ownership and availability could not be confirmed", impact: .negative)) + case .registered: + if !snapshot.dnsSections.isEmpty || snapshot.sslInfo != nil || !snapshot.redirectChain.isEmpty { + score += 6 + factors.append(.init(description: "Registered domain exposes active infrastructure", impact: .negative)) + } else { + factors.append(.init(description: "Registered domain with limited active surface detected", impact: .neutral)) + } + } + + let recordTypes = snapshot.dnsSections.filter { !$0.records.isEmpty || !$0.wildcardRecords.isEmpty }.count + if recordTypes >= 5 { + score += 8 + factors.append(.init(description: "DNS configuration is broad across multiple record types", impact: .negative)) + } + if dnsPatterns.wildcardDetected { + score += 12 + factors.append(.init(description: "Wildcard DNS is enabled", impact: .negative)) + } + if let firstPattern = dnsPatterns.patterns.first { + factors.append(.init(description: firstPattern, impact: .neutral)) + } + + switch tlsAssessment.grade { + case .a: + score -= 8 + factors.append(.init(description: "TLS configuration looks current and stable", impact: .positive)) + case .b: + score -= 3 + factors.append(.init(description: "TLS is valid with minor concerns", impact: .positive)) + case .c: + score += 10 + factors.append(.init(description: "TLS configuration has visible weaknesses", impact: .negative)) + case .f: + score += 22 + factors.append(.init(description: "TLS is missing, invalid, or near failure", impact: .negative)) + } + + if let daysUntilExpiry = snapshot.sslInfo?.daysUntilExpiry, daysUntilExpiry <= 14 { + score += 10 + factors.append(.init(description: "Certificate expires within 14 days", impact: .negative)) + } + + if snapshot.redirectChain.count >= 3 { + score += 8 + factors.append(.init(description: "Redirect chain is longer than expected", impact: .negative)) + } + + if redirectLooksSensitive(snapshot.redirectChain.last?.url) { + score += 6 + factors.append(.init(description: "Redirect target looks like an auth or account gateway", impact: .negative)) + } + + if let emailAssessment { + switch emailAssessment.grade { + case .a: + score -= 10 + factors.append(.init(description: "Email protections are strong and aligned", impact: .positive)) + case .b: + score -= 4 + factors.append(.init(description: "Email protections are present with minor gaps", impact: .positive)) + case .c: + score += 8 + factors.append(.init(description: "Email protections are partial", impact: .negative)) + case .f: + score += 18 + factors.append(.init(description: "Email security protections are weak or absent", impact: .negative)) + } + } else if hasMXRecords(snapshot) { + score += 14 + factors.append(.init(description: "Mail is configured without enough email security evidence", impact: .negative)) + } + + let openPorts = snapshot.portScanResults.filter(\.open).map(\.port) + let sensitivePorts: Set = [21, 22, 23, 25, 3389, 5900] + let exposedSensitivePorts = openPorts.filter { sensitivePorts.contains($0) } + if !exposedSensitivePorts.isEmpty { + score += min(18, exposedSensitivePorts.count * 6) + factors.append(.init(description: "Sensitive management or mail ports are exposed", impact: .negative)) + } else if Set(openPorts) == Set([80, 443]) { + factors.append(.init(description: "Exposure is limited to standard web ports", impact: .positive)) + } else if openPorts.count >= 3 { + score += 8 + factors.append(.init(description: "Multiple open services expand the attack surface", impact: .negative)) + } + + if !subdomainGroups.isEmpty { + let labels = Set(subdomainGroups.map(\.label)) + if labels.contains("dev") || labels.contains("staging") { + score += 8 + factors.append(.init(description: "Development or staging subdomains are discoverable", impact: .negative)) + } + if labels.contains("admin") { + score += 10 + factors.append(.init(description: "Administrative subdomains are discoverable", impact: .negative)) + } + if snapshot.subdomains.count >= 8 { + score += 6 + factors.append(.init(description: "Large passive subdomain footprint detected", impact: .negative)) + } + } + + if snapshot.ipGeolocation == nil, + snapshot.availabilityResult?.status == .registered, + snapshot.dnsSections.contains(where: { $0.recordType == .A && !$0.records.isEmpty }) { + score += 4 + factors.append(.init(description: "Active host could not be geolocated", impact: .neutral)) + } + + if let previousSnapshot { + let previousAnalysis = analyze(snapshot: previousSnapshot) + let delta = score - previousAnalysis.riskAssessment.score + if delta >= 15 { + score += 4 + factors.append(.init(description: "Observed risk has increased materially since the previous snapshot", impact: .negative)) + } + } + + let clampedScore = min(max(score, 0), 100) + let level: RiskLevel + switch clampedScore { + case 0..<35: + level = .low + case 35..<65: + level = .medium + default: + level = .high + } + + return DomainRiskAssessment(score: clampedScore, level: level, factors: factors) + } + + private static func insights( + for snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot?, + dnsPatterns: DNSPatternSummary, + emailAssessment: EmailSecurityAssessment?, + tlsAssessment: TLSSummaryAssessment, + subdomainGroups: [SubdomainGroup] + ) -> [String] { + var items: [String] = [] + + if let group = subdomainGroups.first(where: { $0.label == "staging" || $0.label == "dev" }) { + items.append("Multiple \(group.label) subdomains suggest non-production environments are exposed") + } + if subdomainGroups.contains(where: { $0.label == "admin" }) { + items.append("Administrative subdomains are publicly discoverable") + } + if let emailAssessment, emailAssessment.grade == .f { + items.append("Domain lacks email security protections") + } else if let emailAssessment, emailAssessment.grade == .c { + items.append("Email security is only partially enforced") + } + if let daysUntilExpiry = snapshot.sslInfo?.daysUntilExpiry, daysUntilExpiry <= 30 { + items.append("Certificate expires soon") + } + if redirectLooksSensitive(snapshot.redirectChain.last?.url) { + items.append("Redirect chain may indicate login gateway") + } + items.append(contentsOf: dnsPatterns.patterns) + + if let tlsVersion = snapshot.sslInfo?.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" { + items.append("TLS protocol version is outdated") + } + if tlsAssessment.grade == .f, snapshot.sslInfo == nil, snapshot.availabilityResult?.status == .registered { + items.append("HTTPS endpoint could not be validated") + } + if let previousSnapshot, + let previousURL = previousSnapshot.redirectChain.last?.url, + let currentURL = snapshot.redirectChain.last?.url, + previousURL != currentURL { + items.append("Redirect target changed since the previous snapshot") + } + + var deduplicated: [String] = [] + for item in items where !deduplicated.contains(item) { + deduplicated.append(item) + } + return deduplicated + } + + private static func dnsPatterns(for snapshot: LookupSnapshot) -> DNSPatternSummary { + let nameservers = snapshot.ownership?.nameservers.map { $0.lowercased() } ?? [] + let headerNames = Set(snapshot.httpHeaders.map { $0.name.lowercased() }) + let headerValues = snapshot.httpHeaders.map { $0.value.lowercased() } + let allValues = snapshot.dnsSections.flatMap { section in + (section.records + section.wildcardRecords).map { $0.value.lowercased() } + } + + var providers: [String] = [] + if nameservers.contains(where: { $0.contains("cloudflare") }) || headerNames.contains("cf-ray") || headerNames.contains("cf-cache-status") { + providers.append("Cloudflare") + } + if nameservers.contains(where: { $0.contains("awsdns") }) || allValues.contains(where: { $0.contains("cloudfront.net") || $0.contains("elb.amazonaws.com") || $0.contains("amazonaws.com") }) { + providers.append("AWS") + } + if allValues.contains(where: { $0.contains("fastly.net") }) || headerValues.contains(where: { $0.contains("fastly") || $0.contains("cache-") }) { + providers.append("Fastly") + } + + var patterns: [String] = [] + let wildcardDetected = snapshot.dnsSections.contains { !$0.wildcardRecords.isEmpty } + if !providers.isEmpty { + patterns.append("CDN or edge network detected: \(providers.joined(separator: ", "))") + } + if wildcardDetected { + patterns.append("Wildcard DNS responses are present") + } + if hasMXRecords(snapshot), snapshot.emailSecurity == nil { + patterns.append("MX records exist without corresponding email security records") + } + if snapshot.sslInfo != nil && !(snapshot.dnsSections.first(where: { $0.recordType == .CAA })?.records.isEmpty == false) { + patterns.append("TLS is active but no CAA record was found") + } + + return DNSPatternSummary(providers: providers, wildcardDetected: wildcardDetected, patterns: patterns) + } + + private static func emailAssessment(for result: EmailSecurityResult?) -> EmailSecurityAssessment? { + guard let result else { return nil } + + let spfFound = result.spf.found + let dkimFound = result.dkim.found + let dmarcFound = result.dmarc.found + let dmarcStrict = isStrictDMARC(result.dmarc.value) + + let reasons = [ + spfFound ? "SPF present" : "SPF missing", + dmarcFound ? (dmarcStrict ? "DMARC policy is strict" : "DMARC policy is not strict") : "DMARC missing", + dkimFound ? "DKIM present" : "DKIM not detected" + ] + + let grade: EmailSecurityGrade + if spfFound && dkimFound && dmarcStrict { + grade = .a + } else if spfFound && dmarcFound && (dkimFound || dmarcStrict) { + grade = .b + } else if spfFound || dmarcFound || dkimFound { + grade = .c + } else { + grade = .f + } + + return EmailSecurityAssessment(grade: grade, reasons: reasons) + } + + private static func tlsAssessment(for snapshot: LookupSnapshot) -> TLSSummaryAssessment { + guard let sslInfo = snapshot.sslInfo else { + return TLSSummaryAssessment(grade: .f, highlights: ["TLS handshake failed or no certificate was returned"]) + } + + var issues: [String] = [] + if sslInfo.daysUntilExpiry <= 14 { + issues.append("Certificate expires within 14 days") + } else if sslInfo.daysUntilExpiry <= 30 { + issues.append("Certificate expires within 30 days") + } + if let tlsVersion = sslInfo.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" { + issues.append("Uses \(tlsVersion)") + } + if let cipherSuite = sslInfo.cipherSuite?.lowercased(), + cipherSuite.contains("_cbc_") || cipherSuite.contains("3des") || cipherSuite.contains("rc4") { + issues.append("Negotiated cipher suite looks weak") + } + + let grade: TLSGrade + if snapshot.sslError != nil { + grade = .f + } else if issues.contains(where: { $0.contains("14 days") || $0.contains("weak") || $0.contains("TLS 1.0") || $0.contains("TLS 1.1") }) { + grade = .c + } else if !issues.isEmpty { + grade = .b + } else { + grade = .a + } + + return TLSSummaryAssessment( + grade: grade, + highlights: issues.isEmpty ? ["Certificate is valid and no weak TLS indicators were detected"] : issues + ) + } + + private static func groupedSubdomains(from subdomains: [String]) -> [SubdomainGroup] { + let labels = ["api", "dev", "staging", "admin"] + let normalized = Array(Set(subdomains.map { $0.lowercased() })).sorted() + + return labels.compactMap { label in + let matches = normalized.filter { + guard let firstLabel = $0.split(separator: ".").first?.lowercased() else { return false } + return firstLabel == label + } + guard !matches.isEmpty else { return nil } + return SubdomainGroup(label: label, subdomains: matches) + } + } + + private static func isStrictDMARC(_ value: String?) -> Bool { + guard let value = value?.lowercased() else { return false } + return value.contains("p=reject") || value.contains("p=quarantine") + } + + private static func hasMXRecords(_ snapshot: LookupSnapshot) -> Bool { + snapshot.dnsSections.contains { $0.recordType == .MX && !$0.records.isEmpty } + } + + private static func redirectLooksSensitive(_ urlString: String?) -> Bool { + guard let urlString = urlString?.lowercased() else { return false } + return urlString.contains("/login") + || urlString.contains("/signin") + || urlString.contains("/auth") + || urlString.contains("/account") + || urlString.contains("sso") + } + + private static func appendSharedInsights( + title: String, + groups: [String: [String]], + into insights: inout [WorkflowInsight] + ) { + for domains in groups.values where domains.count >= 2 { + insights.append( + WorkflowInsight( + description: "\(title) across \(domains.count) domains", + domainsInvolved: domains.sorted() + ) + ) + } + } + + private static func groupedDomains( + for reports: [DomainReport], + keyPath: KeyPath + ) -> [String: [String]] { + groupedDomains(for: reports) { $0[keyPath: keyPath]?.nilIfEmpty } + } + + private static func groupedDomains( + for reports: [DomainReport], + transform: (DomainReport) -> String? + ) -> [String: [String]] { + var grouped: [String: [String]] = [:] + for report in reports { + guard let value = transform(report) else { continue } + grouped[value, default: []].append(report.domain) + } + return grouped + } +} + +private extension String { + var nilIfEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} -- cgit v1.2.3