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/ContentView.swift | 204 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 2 deletions(-) (limited to 'DomainDig/ContentView.swift') 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)") -- cgit v1.2.3