summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/BatchResultsView.swift15
-rw-r--r--DomainDig/BatchSweepSummaryView.swift7
-rw-r--r--DomainDig/ContentView.swift204
-rw-r--r--DomainDig/DomainDiffService.swift20
-rw-r--r--DomainDig/DomainInsightEngine.swift542
-rw-r--r--DomainDig/DomainViewModel.swift116
-rw-r--r--DomainDig/HistoryView.swift20
-rw-r--r--DomainDig/Models.swift31
-rw-r--r--DomainDig/WorkflowsView.swift20
10 files changed, 938 insertions, 39 deletions
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")