diff options
| author | Christian Cleberg <[email protected]> | 2026-04-22 11:28:28 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-22 11:28:28 -0500 |
| commit | ceac99c05f180d9008b75c9329822466ff60255b (patch) | |
| tree | 073ea7cc959613479391020dc849c0705d98c0f5 | |
| parent | 9384a668bf36f23f71e25d05b490d1040d308845 (diff) | |
| download | domain-dig-ceac99c05f180d9008b75c9329822466ff60255b.tar.gz domain-dig-ceac99c05f180d9008b75c9329822466ff60255b.tar.bz2 domain-dig-ceac99c05f180d9008b75c9329822466ff60255b.zip | |
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
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 8 | ||||
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/BatchResultsView.swift | 15 | ||||
| -rw-r--r-- | DomainDig/BatchSweepSummaryView.swift | 7 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 204 | ||||
| -rw-r--r-- | DomainDig/DomainDiffService.swift | 20 | ||||
| -rw-r--r-- | DomainDig/DomainInsightEngine.swift | 542 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 116 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 20 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 31 | ||||
| -rw-r--r-- | DomainDig/WorkflowsView.swift | 20 | ||||
| -rw-r--r-- | DomainReportBuilder.swift | 40 | ||||
| -rw-r--r-- | DomainReportExporter.swift | 78 |
13 files changed, 1052 insertions, 51 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 345e575..fc06760 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -354,7 +354,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -371,7 +371,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -390,7 +390,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 21; + CURRENT_PROJECT_VERSION = 22; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -407,7 +407,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index fb7481e..9342324 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { static var current: String { - "2.8.0" + "2.9.0" } } diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift index 51f6415..c8dde42 100644 --- a/DomainDig/BatchResultsView.swift +++ b/DomainDig/BatchResultsView.swift @@ -73,6 +73,9 @@ struct BatchResultRowView: View { HStack(spacing: 10) { AppStatusBadgeView(model: AppStatusFactory.availability(result.availability)) + if let riskScore = result.riskScore, let riskLevel = result.riskLevel { + Text("Risk \(riskScore) \(riskLevel.title)") + } Text(result.primaryIP ?? "No IP") Text(result.timestamp.formatted(date: .abbreviated, time: .shortened)) } @@ -85,6 +88,12 @@ struct BatchResultRowView: View { .foregroundStyle(.secondary) } + if let changeClassification = result.changeClassification { + Text("Impact: \(changeClassification.title)") + .font(appDensity.font(.caption2)) + .foregroundStyle(changeClassification == .critical ? .red : (changeClassification == .warning ? .yellow : .secondary)) + } + if let errorMessage = result.errorMessage { Text(errorMessage) .font(appDensity.font(.caption2)) @@ -114,10 +123,10 @@ struct BatchResultRowView: View { case .running: return .init(title: "Running", systemImage: "arrow.clockwise", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16)) case .completed: - if result.changeSeverity == .high || result.certificateWarningLevel == .critical { - return .init(title: "High", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) + if result.changeClassification == .critical || result.certificateWarningLevel == .critical || result.riskLevel == .high { + return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) } - if result.changeSeverity == .medium || result.certificateWarningLevel == .warning { + if result.changeClassification == .warning || result.changeSeverity == .medium || result.certificateWarningLevel == .warning { return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) } if result.quickStatus == "Changed" { diff --git a/DomainDig/BatchSweepSummaryView.swift b/DomainDig/BatchSweepSummaryView.swift index 61b3e37..43ffa74 100644 --- a/DomainDig/BatchSweepSummaryView.swift +++ b/DomainDig/BatchSweepSummaryView.swift @@ -11,12 +11,7 @@ struct BatchSweepSummaryView: View { return summary.results } - return summary.results.filter { - $0.quickStatus == "Changed" || - $0.quickStatus == "High" || - $0.certificateWarningLevel != .none || - $0.status == .failed - } + return summary.results.filter(\.hasMeaningfulChange) } var body: some View { diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index b023547..79ecf64 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -58,6 +58,12 @@ struct ContentView: View { if viewModel.resultsLoaded { SummaryView(fields: viewModel.summaryFields) .padding(.top, appDensity.metrics.cardSpacing) + if let report = viewModel.currentReport { + RiskSummaryCardView(report: report) + .padding(.top, appDensity.metrics.cardSpacing) + InsightsSummaryCardView(insights: report.insights) + .padding(.top, appDensity.metrics.cardSpacing) + } if let changeSummary = viewModel.currentChangeSummary { DomainChangeSummaryView(summary: changeSummary) .padding(.top, appDensity.metrics.cardSpacing) @@ -114,6 +120,7 @@ struct ContentView: View { SubdomainsSectionView( isCollapsed: sectionCollapsedBinding(.subdomains), rows: viewModel.subdomainRows, + groups: viewModel.currentSubdomainGroups, loading: viewModel.subdomainsLoading, error: viewModel.subdomainsError, provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], @@ -133,6 +140,7 @@ struct ContentView: View { DNSSectionView( isCollapsed: sectionCollapsedBinding(.dns), dnssecLabel: viewModel.dnssecLabel, + patternSummary: viewModel.currentDNSPatterns, sections: viewModel.dnsRows, ptrMessage: viewModel.ptrMessage, loading: viewModel.dnsLoading || viewModel.ptrLoading, @@ -145,6 +153,7 @@ struct ContentView: View { isCollapsed: sectionCollapsedBinding(.web), certificateRows: viewModel.webCertificateRows, sslInfo: viewModel.sslInfo, + tlsSummary: viewModel.currentTLSSummary, sslLoading: viewModel.sslLoading || viewModel.hstsLoading, sslError: viewModel.sslError, tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl], @@ -163,6 +172,7 @@ struct ContentView: View { EmailSectionView( isCollapsed: sectionCollapsedBinding(.email), rows: viewModel.emailRows, + assessment: viewModel.currentEmailAssessment, loading: viewModel.emailSecurityLoading, provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity], confidence: viewModel.currentSnapshot.emailSecurityConfidence, @@ -654,6 +664,107 @@ struct SummaryView: View { } } +struct RiskSummaryCardView: View { + @Environment(\.appDensity) private var appDensity + let report: DomainReport + + private var topFactors: [RiskFactor] { + Array(report.riskAssessment.factors.prefix(3)) + } + + var body: some View { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + SectionTitleView(title: "Risk") + CardView(allowsHorizontalScroll: false) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text("\(report.riskAssessment.score)") + .font(appDensity.font(.largeTitle, weight: .bold)) + .foregroundStyle(levelColor) + Text(report.riskAssessment.level.title) + .font(appDensity.font(.caption)) + .foregroundStyle(levelColor) + } + Spacer() + Text("Deterministic") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + + if topFactors.isEmpty { + Text("No major risk factors identified") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } else { + ForEach(Array(topFactors.enumerated()), id: \.offset) { _, factor in + HStack(alignment: .top, spacing: 8) { + Circle() + .fill(factorColor(factor.impact)) + .frame(width: 8, height: 8) + .padding(.top, 5) + Text(factor.description) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + } + } + } + } + } + } + + private var levelColor: Color { + switch report.riskAssessment.level { + case .low: + return .green + case .medium: + return .yellow + case .high: + return .red + } + } + + private func factorColor(_ impact: RiskImpact) -> Color { + switch impact { + case .positive: + return .green + case .neutral: + return .secondary + case .negative: + return .red + } + } +} + +struct InsightsSummaryCardView: View { + @Environment(\.appDensity) private var appDensity + let insights: [String] + + var body: some View { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + SectionTitleView(title: "Insights") + CardView(allowsHorizontalScroll: false) { + if insights.isEmpty { + Text("No deterministic insights triggered") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } else { + ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in + HStack(alignment: .top, spacing: 8) { + Image(systemName: "sparkline") + .font(appDensity.font(.caption2)) + .foregroundStyle(.cyan) + .padding(.top, 2) + Text(insight) + .font(appDensity.font(.caption)) + .foregroundStyle(.primary) + } + } + } + } + } + } +} + struct StickyLookupSummaryView: View { @Environment(\.appDensity) private var appDensity @@ -791,6 +902,13 @@ struct DomainChangeSummaryView: View { .padding(.vertical, 4) .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16)) .clipShape(Capsule()) + Text(summary.impactClassification.title.uppercased()) + .font(appDensity.font(.caption2)) + .foregroundStyle(impactColor(summary.impactClassification)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(impactColor(summary.impactClassification).opacity(0.16)) + .clipShape(Capsule()) Text(summary.generatedAt, style: .time) .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) @@ -822,6 +940,12 @@ struct DomainChangeSummaryView: View { } } + if let riskScoreDelta = summary.riskScoreDelta { + Text("Risk delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)") + .font(appDensity.font(.caption2)) + .foregroundStyle(riskScoreDelta > 0 ? .orange : .secondary) + } + if let contextNote = summary.contextNote { Text(contextNote) .font(appDensity.font(.caption2)) @@ -846,6 +970,17 @@ struct DomainChangeSummaryView: View { return .red } } + + private func impactColor(_ impact: ChangeImpactClassification) -> Color { + switch impact { + case .informational: + return .secondary + case .warning: + return .yellow + case .critical: + return .red + } + } } struct DomainDiffView: View { @@ -1215,6 +1350,7 @@ struct SubdomainsSectionView: View { @Environment(\.appDensity) private var appDensity @Binding var isCollapsed: Bool let rows: [SubdomainRowViewData] + let groups: [SubdomainGroup] let loading: Bool let error: String? let provenance: SectionProvenance? @@ -1235,6 +1371,22 @@ struct SubdomainsSectionView: View { .padding(.top, 4) } } else { + if !groups.isEmpty { + Text("Groups") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + ForEach(groups) { group in + HStack { + Text("\(group.label).*") + .font(appDensity.font(.caption)) + .foregroundStyle(.cyan) + Spacer() + Text("\(group.subdomains.count)") + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } + } ForEach(rows) { row in HStack(spacing: 8) { Text(row.hostname) @@ -1266,6 +1418,7 @@ struct SubdomainsSectionView: View { struct DNSSectionView: View { @Binding var isCollapsed: Bool let dnssecLabel: String? + let patternSummary: DNSPatternSummary? let sections: [DNSRecordSectionViewData] let ptrMessage: SectionMessageViewData? let loading: Bool @@ -1283,6 +1436,16 @@ struct DNSSectionView: View { if dnsProvenance != nil { CardView(allowsHorizontalScroll: false) { SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil) + if let patternSummary { + if !patternSummary.providers.isEmpty { + MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false) + } + if !patternSummary.patterns.isEmpty { + ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in + MessageRowView(text: pattern, isError: false) + } + } + } } } ForEach(sections) { section in @@ -1332,6 +1495,7 @@ struct WebSectionView: View { @Binding var isCollapsed: Bool let certificateRows: [InfoRowViewData] let sslInfo: SSLCertificateInfo? + let tlsSummary: WebResultSummary? let sslLoading: Bool let sslError: String? let tlsProvenance: SectionProvenance? @@ -1354,9 +1518,17 @@ struct WebSectionView: View { .font(appDensity.font(.subheadline, weight: .semibold)) .foregroundStyle(.cyan) Spacer() - AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) + if !sslLoading { + AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) + } } SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil) + if !sslLoading, let tlsSummary { + LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade == .a ? .success : (tlsSummary.tlsGrade == .f ? .failure : .warning))) + ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in + MessageRowView(text: highlight, isError: isTLSHighlightError(highlight)) + } + } if sslLoading { ProgressView("Checking certificate…") .appLoadingStyle() @@ -1371,9 +1543,11 @@ struct WebSectionView: View { .font(appDensity.font(.caption2)) .foregroundStyle(.secondary) ForEach(sslInfo.subjectAltNames, id: \.self) { san in - HStack(spacing: 8) { + HStack(alignment: .top, spacing: 8) { Text(san) .font(appDensity.font(.caption)) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) Spacer() AppCopyButton(value: san, label: "Copy certificate SAN") @@ -1462,12 +1636,24 @@ struct WebSectionView: View { } } } + + private func isTLSHighlightError(_ highlight: String) -> Bool { + let normalized = highlight.lowercased() + if normalized.contains("no weak tls indicators were detected") { + return false + } + return normalized.contains("expires") + || normalized.contains("weak") + || normalized.contains("tls 1.0") + || normalized.contains("tls 1.1") + } } struct EmailSectionView: View { @Environment(\.appDensity) private var appDensity @Binding var isCollapsed: Bool let rows: [EmailRowViewData] + let assessment: EmailSecuritySummary? let loading: Bool let provenance: SectionProvenance? let confidence: ConfidenceLevel? @@ -1482,6 +1668,14 @@ struct EmailSectionView: View { AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) .opacity(loading ? 0 : 1) } + if let assessment, let grade = assessment.grade { + LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade == .a ? .success : (grade == .f ? .failure : .warning))) + if !assessment.reasons.isEmpty { + Text(assessment.reasons.joined(separator: " | ")) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + } if loading { ProgressView("Checking email records…") .appLoadingStyle() @@ -1817,6 +2011,8 @@ struct MessageRowView: View { Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") .font(appDensity.font(.caption)) .foregroundStyle(isError ? .red : .secondary) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) } } @@ -1890,8 +2086,12 @@ struct LabeledValueRow: View { Text(row.value) .font(appDensity.font(.caption)) .foregroundStyle(ResultColors.color(for: row.tone)) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) Spacer(minLength: 6) if !row.value.isEmpty, row.value != "Unavailable" { AppCopyButton(value: row.value, label: "Copy \(row.label)") diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift index 528841d..0d8795a 100644 --- a/DomainDig/DomainDiffService.swift +++ b/DomainDig/DomainDiffService.swift @@ -57,7 +57,9 @@ enum DomainDiffService { static func summary( from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, - generatedAt: Date = Date() + generatedAt: Date = Date(), + riskAssessment: DomainRiskAssessment? = nil, + insights: [String]? = nil ) -> DomainChangeSummary { let sections = diff(from: oldSnapshot, to: newSnapshot) let allChangedItems = sections @@ -70,16 +72,30 @@ enum DomainDiffService { let observedFacts = observedFacts(from: allChangedItems) let inferredConclusions = highlights.isEmpty ? [] : [message] let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot) + let newAnalysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot) + let currentRiskAssessment = riskAssessment ?? newAnalysis.riskAssessment + let currentInsights = insights ?? newAnalysis.insights + let oldRiskAssessment = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment + let riskScoreDelta = currentRiskAssessment.score - oldRiskAssessment.score + let impactClassification = DomainInsightEngine.impactClassification( + severity: severity, + riskDelta: riskScoreDelta, + changedSections: highlights + ) return DomainChangeSummary( hasChanges: !allChangedItems.isEmpty, changedSections: highlights, message: message, severity: severity, + impactClassification: impactClassification, generatedAt: generatedAt, observedFacts: observedFacts, inferredConclusions: inferredConclusions, - contextNote: contextNote + contextNote: contextNote, + riskAssessment: currentRiskAssessment, + insights: currentInsights, + riskScoreDelta: riskScoreDelta ) } 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<UInt16> = [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<DomainReport, String?> + ) -> [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 + } +} diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index f9a6523..18ce4c2 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -96,6 +96,13 @@ private struct BatchLookupPayload { let snapshot: LookupSnapshot } +private struct WorkflowExportPayload: Codable { + let workflowName: String + let generatedAt: Date + let workflowInsights: [WorkflowInsight] + let reports: [DomainReport] +} + @MainActor @Observable final class DomainViewModel { @@ -194,6 +201,7 @@ final class DomainViewModel { private(set) var currentStatusMessage: String? private(set) var currentSnapshotTimestamp = Date() private(set) var currentHistoryEntryID: UUID? + private(set) var currentReport: DomainReport? private static let recentSearchesKey = "recentSearches" private static let maxRecent = 20 @@ -448,16 +456,28 @@ final class DomainViewModel { return history.first(where: { $0.id == currentHistoryEntryID }) } - var currentReport: DomainReport? { - guard !searchedDomain.isEmpty else { return nil } - return reportBuilder.build( - from: currentSnapshot, - previousSnapshot: previousSnapshot( - for: searchedDomain, - trackedDomainID: currentTrackedDomain?.id, - replacingLatest: false - ) - ) + var currentRiskAssessment: DomainRiskAssessment? { + currentReport?.riskAssessment + } + + var currentInsights: [String] { + currentReport?.insights ?? [] + } + + var currentSubdomainGroups: [SubdomainGroup] { + currentReport?.subdomainGroups ?? [] + } + + var currentDNSPatterns: DNSPatternSummary? { + currentReport?.dns.patternSummary + } + + var currentEmailAssessment: EmailSecuritySummary? { + currentReport?.email + } + + var currentTLSSummary: WebResultSummary? { + currentReport?.web } var summaryFields: [SummaryFieldViewData] { @@ -684,6 +704,7 @@ final class DomainViewModel { currentDiffSections = [] currentChangeSummary = nil ownershipDiff = [] + currentReport = nil refreshingTrackedDomainID = nil clearBatchState() clearLookupState() @@ -728,7 +749,10 @@ final class DomainViewModel { quickStatus: "Cancelled", summaryMessage: batchResults[index].summaryMessage, changeSeverity: batchResults[index].changeSeverity, + changeClassification: batchResults[index].changeClassification, certificateWarningLevel: batchResults[index].certificateWarningLevel, + riskScore: batchResults[index].riskScore, + riskLevel: batchResults[index].riskLevel, timestamp: Date(), status: .failed, errorMessage: "Lookup cancelled" @@ -918,22 +942,36 @@ final class DomainViewModel { } func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String { - DomainReportExporter.batchText( - for: workflowReports(from: summary, changedOnly: changedOnly), + let reports = workflowReports(from: summary, changedOnly: changedOnly) + let base = DomainReportExporter.batchText( + for: reports, title: "\(summary.workflowName) Workflow Export" ) + guard !summary.workflowInsights.isEmpty else { return base } + let insightLines = summary.workflowInsights.map { + "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))" + } + return ([ "\(summary.workflowName) Workflow Insights", String(repeating: "-", count: 32) ] + insightLines + ["", base]).joined(separator: "\n") } func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String { - DomainReportExporter.csv(for: workflowReports(from: summary, changedOnly: changedOnly)) + DomainReportExporter.csv( + for: workflowReports(from: summary, changedOnly: changedOnly), + workflowInsights: summary.workflowInsights + ) } func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? { - try? DomainReportExporter.data( - for: workflowReports(from: summary, changedOnly: changedOnly), - format: .json, - title: "\(summary.workflowName) Workflow Export" + let payload = WorkflowExportPayload( + workflowName: summary.workflowName, + generatedAt: summary.generatedAt, + workflowInsights: summary.workflowInsights, + reports: workflowReports(from: summary, changedOnly: changedOnly) ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return try? encoder.encode(payload) } private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { @@ -963,9 +1001,17 @@ final class DomainViewModel { currentResultSource = snapshot.resultSource currentCachedSections = snapshot.cachedSections currentStatusMessage = snapshot.statusMessage - currentChangeSummary = snapshot.changeSummary currentDiffSections = [] ownershipDiff = [] + currentReport = reportBuilder.build( + from: snapshot, + previousSnapshot: previousSnapshot( + for: snapshot.domain, + trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id, + replacingLatest: false + ) + ) + currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary dnsSections = snapshot.dnsSections dnsError = snapshot.dnsError @@ -1406,8 +1452,15 @@ final class DomainViewModel { private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? { let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) + let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) let changeSummary = previousSnapshot.map { - DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp) + DomainDiffService.summary( + from: $0, + to: snapshot, + generatedAt: snapshot.timestamp, + riskAssessment: analysis.riskAssessment, + insights: analysis.insights + ) } let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] @@ -1415,6 +1468,7 @@ final class DomainViewModel { currentChangeSummary = changeSummary currentDiffSections = diffSections ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] + currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot) } let entry = HistoryEntry( @@ -1664,6 +1718,7 @@ final class DomainViewModel { currentStatusMessage = nil currentDiffSections = [] currentChangeSummary = nil + currentReport = nil ownershipDiff = [] clearLookupState() setAllLoadingStates(true) @@ -1807,11 +1862,14 @@ final class DomainViewModel { } } let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) + let riskAssessment = DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment let quickStatus: String if entry?.changeSummary?.hasChanges == true { - quickStatus = entry?.changeSummary?.severity == .high ? "High" : "Changed" + quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed") } else if certificateWarningLevel != .none { quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning" + } else if riskAssessment.level == .high { + quickStatus = "High" } else { quickStatus = "Unchanged" } @@ -1834,9 +1892,14 @@ final class DomainViewModel { refreshingTrackedDomainID = nil batchTask = nil - let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count + let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" || $0.quickStatus == "Critical" }.count let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count - let warningCount = batchResults.filter { $0.certificateWarningLevel != .none }.count + let warningCount = batchResults.filter { + $0.certificateWarningLevel != .none + || $0.changeClassification == .warning + || $0.changeClassification == .critical + || $0.riskLevel == .high + }.count let summary = BatchSweepSummary( source: source, @@ -1855,6 +1918,10 @@ final class DomainViewModel { latestBatchSweepSummary = summary if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName { + let workflowReports: [DomainReport] = summary.results.compactMap { result in + guard let entry = historyEntry(for: result) else { return nil } + return report(for: entry) + } latestWorkflowRunSummary = WorkflowRunSummary( workflowID: activeWorkflowRunID, workflowName: activeWorkflowRunName, @@ -1863,6 +1930,7 @@ final class DomainViewModel { unchangedDomains: unchangedCount, warningDomains: warningCount, results: summary.results, + workflowInsights: DomainInsightEngine.workflowInsights(for: workflowReports), generatedAt: summary.generatedAt ) } @@ -1896,7 +1964,10 @@ final class DomainViewModel { quickStatus: quickStatus, summaryMessage: entry?.changeSummary?.message, changeSeverity: entry?.changeSummary?.severity, + changeClassification: entry?.changeSummary?.impactClassification, certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel, + riskScore: entry.map { $0.changeSummary?.riskAssessment?.score ?? report(for: $0).riskAssessment.score }, + riskLevel: entry.map { $0.changeSummary?.riskAssessment?.level ?? report(for: $0).riskAssessment.level }, timestamp: entry?.timestamp ?? Date(), status: status, errorMessage: errorMessage @@ -1956,6 +2027,7 @@ final class DomainViewModel { currentResultSource = .live currentCachedSections = [] currentStatusMessage = nil + currentReport = nil } private func setAllLoadingStates(_ loading: Bool) { diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 3bda59d..e3a77f4 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -167,12 +167,24 @@ struct HistoryDetailView: View { entry.snapshot } + private var report: DomainReport { + DomainReportBuilder().build(from: entry, previousSnapshot: viewModel.comparisonSnapshot(for: entry)) + } + + private var trackedDomain: TrackedDomain? { + viewModel.trackedDomains.first { $0.domain.caseInsensitiveCompare(entry.domain) == .orderedSame } + } + var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 0) { snapshotBanner SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) .padding(.top, 8) + RiskSummaryCardView(report: report) + .padding(.top, 8) + InsightsSummaryCardView(insights: report.insights) + .padding(.top, 8) DomainSectionView( isCollapsed: .constant(false), rows: DomainViewModel.domainRows(from: snapshot), @@ -183,14 +195,14 @@ struct HistoryDetailView: View { provenance: snapshot.provenanceBySection[.availability], confidence: snapshot.availabilityConfidence, snapshotNote: entry.note, - trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), + trackedDomain: trackedDomain, workflows: viewModel.workflowsContaining(domain: entry.domain), trackingLimitMessage: nil, onTrack: { _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) }, onTogglePinned: { - guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return } + guard let trackedDomain else { return } viewModel.togglePinned(for: trackedDomain) }, onEditNote: nil, @@ -212,6 +224,7 @@ struct HistoryDetailView: View { SubdomainsSectionView( isCollapsed: .constant(false), rows: DomainViewModel.subdomainRows(from: snapshot), + groups: report.subdomainGroups, loading: false, error: snapshot.subdomainsError, provenance: snapshot.provenanceBySection[.subdomains], @@ -235,6 +248,7 @@ struct HistoryDetailView: View { DNSSectionView( isCollapsed: .constant(false), dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), + patternSummary: report.dns.patternSummary, sections: DomainViewModel.dnsRows(from: snapshot), ptrMessage: DomainViewModel.ptrMessage(from: snapshot), loading: false, @@ -247,6 +261,7 @@ struct HistoryDetailView: View { isCollapsed: .constant(false), certificateRows: DomainViewModel.webCertificateRows(from: snapshot), sslInfo: snapshot.sslInfo, + tlsSummary: report.web, sslLoading: false, sslError: snapshot.sslError, tlsProvenance: snapshot.provenanceBySection[.ssl], @@ -265,6 +280,7 @@ struct HistoryDetailView: View { EmailSectionView( isCollapsed: .constant(false), rows: DomainViewModel.emailRows(from: snapshot), + assessment: report.email, loading: false, provenance: snapshot.provenanceBySection[.emailSecurity], confidence: snapshot.emailSecurityConfidence, diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 0c2b4c2..6b7c2c1 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -181,29 +181,41 @@ struct DomainChangeSummary: Codable, Equatable { let changedSections: [String] let message: String let severity: ChangeSeverity + let impactClassification: ChangeImpactClassification let generatedAt: Date let observedFacts: [String] let inferredConclusions: [String] let contextNote: String? + let riskAssessment: DomainRiskAssessment? + let insights: [String] + let riskScoreDelta: Int? init( hasChanges: Bool, changedSections: [String], message: String, severity: ChangeSeverity, + impactClassification: ChangeImpactClassification, generatedAt: Date, observedFacts: [String] = [], inferredConclusions: [String] = [], - contextNote: String? = nil + contextNote: String? = nil, + riskAssessment: DomainRiskAssessment? = nil, + insights: [String] = [], + riskScoreDelta: Int? = nil ) { self.hasChanges = hasChanges self.changedSections = changedSections self.message = message self.severity = severity + self.impactClassification = impactClassification self.generatedAt = generatedAt self.observedFacts = observedFacts self.inferredConclusions = inferredConclusions self.contextNote = contextNote + self.riskAssessment = riskAssessment + self.insights = insights + self.riskScoreDelta = riskScoreDelta } init(from decoder: Decoder) throws { @@ -212,11 +224,16 @@ struct DomainChangeSummary: Codable, Equatable { changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? [] generatedAt = try container.decode(Date.self, forKey: .generatedAt) severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low) + impactClassification = try container.decodeIfPresent(ChangeImpactClassification.self, forKey: .impactClassification) + ?? (severity == .high ? .critical : (hasChanges ? .warning : .informational)) message = try container.decodeIfPresent(String.self, forKey: .message) ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • ")) observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? [] inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? [] contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote) + riskAssessment = try container.decodeIfPresent(DomainRiskAssessment.self, forKey: .riskAssessment) + insights = try container.decodeIfPresent([String].self, forKey: .insights) ?? [] + riskScoreDelta = try container.decodeIfPresent(Int.self, forKey: .riskScoreDelta) } } @@ -243,7 +260,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { let quickStatus: String let summaryMessage: String? let changeSeverity: ChangeSeverity? + let changeClassification: ChangeImpactClassification? let certificateWarningLevel: CertificateWarningLevel + let riskScore: Int? + let riskLevel: RiskLevel? let timestamp: Date let status: BatchLookupStatus let errorMessage: String? @@ -258,7 +278,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { quickStatus: String, summaryMessage: String? = nil, changeSeverity: ChangeSeverity? = nil, + changeClassification: ChangeImpactClassification? = nil, certificateWarningLevel: CertificateWarningLevel = .none, + riskScore: Int? = nil, + riskLevel: RiskLevel? = nil, timestamp: Date, status: BatchLookupStatus, errorMessage: String? = nil @@ -272,7 +295,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { self.quickStatus = quickStatus self.summaryMessage = summaryMessage self.changeSeverity = changeSeverity + self.changeClassification = changeClassification self.certificateWarningLevel = certificateWarningLevel + self.riskScore = riskScore + self.riskLevel = riskLevel self.timestamp = timestamp self.status = status self.errorMessage = errorMessage @@ -281,7 +307,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { var hasMeaningfulChange: Bool { quickStatus == "Changed" || quickStatus == "High" + || quickStatus == "Critical" || certificateWarningLevel != .none + || riskLevel == .high || status == .failed } } @@ -331,6 +359,7 @@ struct WorkflowRunSummary: Identifiable, Equatable { let unchangedDomains: Int let warningDomains: Int let results: [BatchLookupResult] + let workflowInsights: [WorkflowInsight] let generatedAt: Date } diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift index ba88487..b870409 100644 --- a/DomainDig/WorkflowsView.swift +++ b/DomainDig/WorkflowsView.swift @@ -215,6 +215,11 @@ struct WorkflowDetailView: View { statRow(label: "Changed", value: "\(latestSummary.changedDomains)") statRow(label: "Warnings", value: "\(latestSummary.warningDomains)") statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)") + if !latestSummary.workflowInsights.isEmpty { + Text(latestSummary.workflowInsights[0].description) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + } Button { viewModel.latestWorkflowRunSummary = latestSummary @@ -431,6 +436,21 @@ struct WorkflowRunSummaryView: View { Toggle("Show unchanged domains", isOn: $showAllResults) } + if !summary.workflowInsights.isEmpty { + Section("Workflow Insights") { + ForEach(summary.workflowInsights) { insight in + VStack(alignment: .leading, spacing: 4) { + Text(insight.description) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + Text(insight.domainsInvolved.joined(separator: ", ")) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") { if visibleResults.isEmpty { Text("No domains with meaningful changes or warnings") diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift index 765ad4f..ceda05e 100644 --- a/DomainReportBuilder.swift +++ b/DomainReportBuilder.swift @@ -25,6 +25,9 @@ struct DomainReport: Codable { let email: EmailSecuritySummary let network: NetworkSummary let subdomains: [String] + let subdomainGroups: [SubdomainGroup] + let riskAssessment: DomainRiskAssessment + let insights: [String] let changeSummary: DomainChangeSummary? } @@ -36,6 +39,7 @@ struct DNSResultSummary: Codable { let primaryIP: String? let ptrRecord: String? let dnssecSigned: Bool? + let patternSummary: DNSPatternSummary let error: String? let ptrError: String? } @@ -43,6 +47,8 @@ struct DNSResultSummary: Codable { struct WebResultSummary: Codable { let tls: SSLCertificateInfo? let tlsStatus: String + let tlsGrade: TLSGrade + let tlsHighlights: [String] let certificateWarningLevel: CertificateWarningLevel let hstsPreloaded: Bool? let headers: [HTTPHeader] @@ -61,6 +67,8 @@ struct WebResultSummary: Codable { struct EmailSecuritySummary: Codable { let records: EmailSecurityResult? + let grade: EmailSecurityGrade? + let reasons: [String] let summary: String let error: String? } @@ -81,6 +89,21 @@ struct NetworkSummary: Codable { struct DomainReportBuilder { func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { let primaryIP = primaryIPAddress(from: snapshot) + let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot) + let changeSummary: DomainChangeSummary? + if let existingChangeSummary = snapshot.changeSummary, existingChangeSummary.riskAssessment != nil { + changeSummary = existingChangeSummary + } else { + changeSummary = previousSnapshot.map { + DomainDiffService.summary( + from: $0, + to: snapshot, + generatedAt: snapshot.timestamp, + riskAssessment: analysis.riskAssessment, + insights: analysis.insights + ) + } + } return DomainReport( domain: snapshot.domain, @@ -110,12 +133,15 @@ struct DomainReportBuilder { primaryIP: primaryIP, ptrRecord: snapshot.ptrRecord, dnssecSigned: dnssecSigned(from: snapshot), + patternSummary: analysis.dnsPatterns, error: snapshot.dnsError, ptrError: snapshot.ptrError ), web: WebResultSummary( tls: snapshot.sslInfo, tlsStatus: tlsStatus(from: snapshot), + tlsGrade: analysis.tlsAssessment.grade, + tlsHighlights: analysis.tlsAssessment.highlights, certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), hstsPreloaded: snapshot.hstsPreloaded, headers: snapshot.httpHeaders, @@ -133,7 +159,9 @@ struct DomainReportBuilder { ), email: EmailSecuritySummary( records: snapshot.emailSecurity, - summary: emailSummary(from: snapshot), + grade: analysis.emailAssessment?.grade, + reasons: analysis.emailAssessment?.reasons ?? [], + summary: emailSummary(from: snapshot, assessment: analysis.emailAssessment), error: snapshot.emailSecurityError ), network: NetworkSummary( @@ -149,9 +177,10 @@ struct DomainReportBuilder { portScanError: snapshot.portScanError ), subdomains: snapshot.subdomains.map(\.hostname), - changeSummary: snapshot.changeSummary ?? previousSnapshot.map { - DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp) - } + subdomainGroups: analysis.subdomainGroups, + riskAssessment: analysis.riskAssessment, + insights: analysis.insights, + changeSummary: changeSummary ) } @@ -177,12 +206,13 @@ struct DomainReportBuilder { return "unavailable" } - private func emailSummary(from snapshot: LookupSnapshot) -> String { + private func emailSummary(from snapshot: LookupSnapshot, assessment: EmailSecurityAssessment?) -> String { guard let emailSecurity = snapshot.emailSecurity else { return snapshot.emailSecurityError ?? "Unavailable" } return [ + "Grade \(assessment?.grade.rawValue ?? "?")", "SPF \(emailSecurity.spf.found ? "Yes" : "No")", "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")", "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")", diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift index 0841fcf..9b20066 100644 --- a/DomainReportExporter.swift +++ b/DomainReportExporter.swift @@ -60,6 +60,7 @@ enum DomainReportExporter { appendSection("Summary", to: &lines) { [ "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", + "Risk Score: \(report.riskAssessment.score) (\(report.riskAssessment.level.title))", "TLS Status: \(report.web.tlsStatus)", "HTTP: \(httpSummary(for: report))", "Email: \(report.email.summary)", @@ -67,6 +68,26 @@ enum DomainReportExporter { ] } + appendSection("Risk", to: &lines) { + var values = [ + "Score: \(report.riskAssessment.score)", + "Level: \(report.riskAssessment.level.title)" + ] + if report.riskAssessment.factors.isEmpty { + values.append("Factors: None") + } else { + values.append("Factors:") + for factor in report.riskAssessment.factors { + values.append(" [\(factor.impact.rawValue)] \(factor.description)") + } + } + return values + } + + appendSection("Insights", to: &lines) { + report.insights.isEmpty ? ["No deterministic insights triggered"] : report.insights.map { "- \($0)" } + } + appendSection("Ownership", to: &lines) { var ownershipLines = [ "Registrar: \(report.ownership?.registrar ?? "Unavailable")", @@ -93,7 +114,8 @@ enum DomainReportExporter { "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")", - "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))" + "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))", + "Patterns: \(report.dns.patternSummary.patterns.joined(separator: " | ").nilIfEmpty ?? "None")" ] if let provenance = report.sectionProvenance[.dns] { dnsLines.append("Provenance: \(provenanceLabel(provenance))") @@ -117,6 +139,8 @@ enum DomainReportExporter { appendSection("Web", to: &lines) { var webLines = [ "TLS Status: \(report.web.tlsStatus)", + "TLS Grade: \(report.web.tlsGrade.rawValue)", + "TLS Highlights: \(report.web.tlsHighlights.joined(separator: " | "))", "Certificate Warning: \(report.web.certificateWarningLevel.title)", "Security Grade: \(report.web.securityGrade ?? "Unavailable")", "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")", @@ -161,6 +185,12 @@ enum DomainReportExporter { appendSection("Email", to: &lines) { var emailLines = [report.email.summary] + if let grade = report.email.grade { + emailLines.append("Grade: \(grade.rawValue)") + } + if !report.email.reasons.isEmpty { + emailLines.append("Why: \(report.email.reasons.joined(separator: " | "))") + } emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")") if let provenance = report.sectionProvenance[.emailSecurity] { emailLines.append("Provenance: \(provenanceLabel(provenance))") @@ -217,6 +247,9 @@ enum DomainReportExporter { values.append("None") return values } + if !report.subdomainGroups.isEmpty { + values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))") + } values.append(contentsOf: report.subdomains.map { "- \($0)" }) return values } @@ -229,9 +262,16 @@ enum DomainReportExporter { var values = [ "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")", "Severity: \(changeSummary.severity.title)", + "Impact: \(changeSummary.impactClassification.title)", "Inferred Summary: \(changeSummary.message)", "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))" ] + if let riskScoreDelta = changeSummary.riskScoreDelta { + values.append("Risk Delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)") + } + if !changeSummary.insights.isEmpty { + values.append("Insights: \(changeSummary.insights.joined(separator: " | "))") + } if !changeSummary.observedFacts.isEmpty { values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))") } @@ -262,6 +302,11 @@ enum DomainReportExporter { } static func csv(for reports: [DomainReport]) -> String { + csv(for: reports, workflowInsights: []) + } + + static func csv(for reports: [DomainReport], workflowInsights: [WorkflowInsight]) -> String { + let workflowInsightSummary = workflowInsights.map(\.description).joined(separator: " | ") let headers = [ "domain", "timestamp", @@ -269,6 +314,10 @@ enum DomainReportExporter { "result_source", "resolver", "availability", + "risk_score", + "risk_level", + "risk_factors", + "insights", "availability_confidence", "registrar", "ownership_confidence", @@ -277,15 +326,20 @@ enum DomainReportExporter { "primary_ip", "ptr_record", "dnssec_signed", + "dns_patterns", "tls_status", + "tls_grade", + "tls_highlights", "certificate_warning_level", "hsts_preloaded", "http_status", "http_security_grade", "final_url", "email_summary", + "email_grade", "email_confidence", "subdomain_count", + "subdomain_groups", "subdomain_confidence", "subdomains", "open_ports", @@ -295,7 +349,9 @@ enum DomainReportExporter { "data_sources", "audit_note", "partial_snapshot", - "change_summary" + "change_summary", + "change_impact", + "workflow_insights" ] let rows = reports.map { report in @@ -307,6 +363,11 @@ enum DomainReportExporter { let subdomainCount = String(report.subdomains.count) let subdomains = report.subdomains.joined(separator: " | ") let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ") + let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ") + let insights = report.insights.joined(separator: " | ") + let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ") + let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ") + let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ") return [ report.domain, @@ -315,6 +376,10 @@ enum DomainReportExporter { report.resultSource.rawValue, report.resolverDisplayName, availabilityLabel(report.availability), + "\(report.riskAssessment.score)", + report.riskAssessment.level.rawValue, + riskFactors, + insights, report.availabilityConfidence?.rawValue ?? "", report.ownership?.registrar ?? "", report.ownershipConfidence?.rawValue ?? "", @@ -323,15 +388,20 @@ enum DomainReportExporter { report.dns.primaryIP ?? "", report.dns.ptrRecord ?? "", dnssecSigned, + dnsPatterns, report.web.tlsStatus, + report.web.tlsGrade.rawValue, + tlsHighlights, report.web.certificateWarningLevel.rawValue, hstsPreloaded, httpStatus, report.web.securityGrade ?? "", report.web.finalURL ?? "", report.email.summary, + report.email.grade?.rawValue ?? "", report.emailConfidence?.rawValue ?? "", subdomainCount, + subdomainGroups, report.subdomainConfidence?.rawValue ?? "", subdomains, openPorts, @@ -341,7 +411,9 @@ enum DomainReportExporter { report.dataSources.joined(separator: " | "), report.auditNote ?? "", report.isPartialSnapshot ? "true" : "false", - report.changeSummary?.message ?? "" + report.changeSummary?.message ?? "", + report.changeSummary?.impactClassification.rawValue ?? "", + workflowInsightSummary ] } |
