Commit ceac99c05f

ceac99c05f180d9008b75c9329822466ff60255b

parent: 9384a668bf

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 16:28 UTC

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

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -354,7 +354,7 @@
354354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
355355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
356356 CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = 21;
357 CURRENT_PROJECT_VERSION = 22;
358358 DEVELOPMENT_TEAM = ZCNAX3VL9D;
359359 ENABLE_PREVIEWS = YES;
360360 GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +371,7 @@
371371 "$(inherited)",
372372 "@executable_path/Frameworks",
373373 );
374 MARKETING_VERSION = 2.8.0;
374 MARKETING_VERSION = 2.9.0;
375375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
376376 PRODUCT_NAME = "$(TARGET_NAME)";
377377 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +390,7 @@
390390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392392 CODE_SIGN_STYLE = Automatic;
393 CURRENT_PROJECT_VERSION = 21;
393 CURRENT_PROJECT_VERSION = 22;
394394 DEVELOPMENT_TEAM = ZCNAX3VL9D;
395395 ENABLE_PREVIEWS = YES;
396396 GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +407,7 @@
407407 "$(inherited)",
408408 "@executable_path/Frameworks",
409409 );
410 MARKETING_VERSION = 2.8.0;
410 MARKETING_VERSION = 2.9.0;
411411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
412412 PRODUCT_NAME = "$(TARGET_NAME)";
413413 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
22
33enum AppVersion {
44 static var current: String {
5 "2.8.0"
5 "2.9.0"
66 }
77}
DomainDig/BatchResultsView.swift +12 −3
@@ -73,6 +73,9 @@ struct BatchResultRowView: View {
7373
7474 HStack(spacing: 10) {
7575 AppStatusBadgeView(model: AppStatusFactory.availability(result.availability))
76 if let riskScore = result.riskScore, let riskLevel = result.riskLevel {
77 Text("Risk \(riskScore) \(riskLevel.title)")
78 }
7679 Text(result.primaryIP ?? "No IP")
7780 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
7881 }
@@ -85,6 +88,12 @@ struct BatchResultRowView: View {
8588 .foregroundStyle(.secondary)
8689 }
8790
91 if let changeClassification = result.changeClassification {
92 Text("Impact: \(changeClassification.title)")
93 .font(appDensity.font(.caption2))
94 .foregroundStyle(changeClassification == .critical ? .red : (changeClassification == .warning ? .yellow : .secondary))
95 }
96
8897 if let errorMessage = result.errorMessage {
8998 Text(errorMessage)
9099 .font(appDensity.font(.caption2))
@@ -114,10 +123,10 @@ struct BatchResultRowView: View {
114123 case .running:
115124 return .init(title: "Running", systemImage: "arrow.clockwise", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
116125 case .completed:
117 if result.changeSeverity == .high || result.certificateWarningLevel == .critical {
118 return .init(title: "High", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
126 if result.changeClassification == .critical || result.certificateWarningLevel == .critical || result.riskLevel == .high {
127 return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
119128 }
120 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
129 if result.changeClassification == .warning || result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
121130 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
122131 }
123132 if result.quickStatus == "Changed" {
DomainDig/BatchSweepSummaryView.swift +1 −6
@@ -11,12 +11,7 @@ struct BatchSweepSummaryView: View {
1111 return summary.results
1212 }
1313
14 return summary.results.filter {
15 $0.quickStatus == "Changed" ||
16 $0.quickStatus == "High" ||
17 $0.certificateWarningLevel != .none ||
18 $0.status == .failed
19 }
14 return summary.results.filter(\.hasMeaningfulChange)
2015 }
2116
2217 var body: some View {
DomainDig/ContentView.swift +202 −2
@@ -58,6 +58,12 @@ struct ContentView: View {
5858 if viewModel.resultsLoaded {
5959 SummaryView(fields: viewModel.summaryFields)
6060 .padding(.top, appDensity.metrics.cardSpacing)
61 if let report = viewModel.currentReport {
62 RiskSummaryCardView(report: report)
63 .padding(.top, appDensity.metrics.cardSpacing)
64 InsightsSummaryCardView(insights: report.insights)
65 .padding(.top, appDensity.metrics.cardSpacing)
66 }
6167 if let changeSummary = viewModel.currentChangeSummary {
6268 DomainChangeSummaryView(summary: changeSummary)
6369 .padding(.top, appDensity.metrics.cardSpacing)
@@ -114,6 +120,7 @@ struct ContentView: View {
114120 SubdomainsSectionView(
115121 isCollapsed: sectionCollapsedBinding(.subdomains),
116122 rows: viewModel.subdomainRows,
123 groups: viewModel.currentSubdomainGroups,
117124 loading: viewModel.subdomainsLoading,
118125 error: viewModel.subdomainsError,
119126 provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
@@ -133,6 +140,7 @@ struct ContentView: View {
133140 DNSSectionView(
134141 isCollapsed: sectionCollapsedBinding(.dns),
135142 dnssecLabel: viewModel.dnssecLabel,
143 patternSummary: viewModel.currentDNSPatterns,
136144 sections: viewModel.dnsRows,
137145 ptrMessage: viewModel.ptrMessage,
138146 loading: viewModel.dnsLoading || viewModel.ptrLoading,
@@ -145,6 +153,7 @@ struct ContentView: View {
145153 isCollapsed: sectionCollapsedBinding(.web),
146154 certificateRows: viewModel.webCertificateRows,
147155 sslInfo: viewModel.sslInfo,
156 tlsSummary: viewModel.currentTLSSummary,
148157 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
149158 sslError: viewModel.sslError,
150159 tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl],
@@ -163,6 +172,7 @@ struct ContentView: View {
163172 EmailSectionView(
164173 isCollapsed: sectionCollapsedBinding(.email),
165174 rows: viewModel.emailRows,
175 assessment: viewModel.currentEmailAssessment,
166176 loading: viewModel.emailSecurityLoading,
167177 provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity],
168178 confidence: viewModel.currentSnapshot.emailSecurityConfidence,
@@ -654,6 +664,107 @@ struct SummaryView: View {
654664 }
655665}
656666
667struct RiskSummaryCardView: View {
668 @Environment(\.appDensity) private var appDensity
669 let report: DomainReport
670
671 private var topFactors: [RiskFactor] {
672 Array(report.riskAssessment.factors.prefix(3))
673 }
674
675 var body: some View {
676 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
677 SectionTitleView(title: "Risk")
678 CardView(allowsHorizontalScroll: false) {
679 HStack(alignment: .firstTextBaseline) {
680 VStack(alignment: .leading, spacing: 4) {
681 Text("\(report.riskAssessment.score)")
682 .font(appDensity.font(.largeTitle, weight: .bold))
683 .foregroundStyle(levelColor)
684 Text(report.riskAssessment.level.title)
685 .font(appDensity.font(.caption))
686 .foregroundStyle(levelColor)
687 }
688 Spacer()
689 Text("Deterministic")
690 .font(appDensity.font(.caption2))
691 .foregroundStyle(.secondary)
692 }
693
694 if topFactors.isEmpty {
695 Text("No major risk factors identified")
696 .font(appDensity.font(.caption))
697 .foregroundStyle(.secondary)
698 } else {
699 ForEach(Array(topFactors.enumerated()), id: \.offset) { _, factor in
700 HStack(alignment: .top, spacing: 8) {
701 Circle()
702 .fill(factorColor(factor.impact))
703 .frame(width: 8, height: 8)
704 .padding(.top, 5)
705 Text(factor.description)
706 .font(appDensity.font(.caption))
707 .foregroundStyle(.primary)
708 }
709 }
710 }
711 }
712 }
713 }
714
715 private var levelColor: Color {
716 switch report.riskAssessment.level {
717 case .low:
718 return .green
719 case .medium:
720 return .yellow
721 case .high:
722 return .red
723 }
724 }
725
726 private func factorColor(_ impact: RiskImpact) -> Color {
727 switch impact {
728 case .positive:
729 return .green
730 case .neutral:
731 return .secondary
732 case .negative:
733 return .red
734 }
735 }
736}
737
738struct InsightsSummaryCardView: View {
739 @Environment(\.appDensity) private var appDensity
740 let insights: [String]
741
742 var body: some View {
743 VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) {
744 SectionTitleView(title: "Insights")
745 CardView(allowsHorizontalScroll: false) {
746 if insights.isEmpty {
747 Text("No deterministic insights triggered")
748 .font(appDensity.font(.caption))
749 .foregroundStyle(.secondary)
750 } else {
751 ForEach(Array(insights.enumerated()), id: \.offset) { _, insight in
752 HStack(alignment: .top, spacing: 8) {
753 Image(systemName: "sparkline")
754 .font(appDensity.font(.caption2))
755 .foregroundStyle(.cyan)
756 .padding(.top, 2)
757 Text(insight)
758 .font(appDensity.font(.caption))
759 .foregroundStyle(.primary)
760 }
761 }
762 }
763 }
764 }
765 }
766}
767
657768struct StickyLookupSummaryView: View {
658769 @Environment(\.appDensity) private var appDensity
659770
@@ -791,6 +902,13 @@ struct DomainChangeSummaryView: View {
791902 .padding(.vertical, 4)
792903 .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16))
793904 .clipShape(Capsule())
905 Text(summary.impactClassification.title.uppercased())
906 .font(appDensity.font(.caption2))
907 .foregroundStyle(impactColor(summary.impactClassification))
908 .padding(.horizontal, 8)
909 .padding(.vertical, 4)
910 .background(impactColor(summary.impactClassification).opacity(0.16))
911 .clipShape(Capsule())
794912 Text(summary.generatedAt, style: .time)
795913 .font(appDensity.font(.caption2))
796914 .foregroundStyle(.secondary)
@@ -822,6 +940,12 @@ struct DomainChangeSummaryView: View {
822940 }
823941 }
824942
943 if let riskScoreDelta = summary.riskScoreDelta {
944 Text("Risk delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
945 .font(appDensity.font(.caption2))
946 .foregroundStyle(riskScoreDelta > 0 ? .orange : .secondary)
947 }
948
825949 if let contextNote = summary.contextNote {
826950 Text(contextNote)
827951 .font(appDensity.font(.caption2))
@@ -846,6 +970,17 @@ struct DomainChangeSummaryView: View {
846970 return .red
847971 }
848972 }
973
974 private func impactColor(_ impact: ChangeImpactClassification) -> Color {
975 switch impact {
976 case .informational:
977 return .secondary
978 case .warning:
979 return .yellow
980 case .critical:
981 return .red
982 }
983 }
849984}
850985
851986struct DomainDiffView: View {
@@ -1215,6 +1350,7 @@ struct SubdomainsSectionView: View {
12151350 @Environment(\.appDensity) private var appDensity
12161351 @Binding var isCollapsed: Bool
12171352 let rows: [SubdomainRowViewData]
1353 let groups: [SubdomainGroup]
12181354 let loading: Bool
12191355 let error: String?
12201356 let provenance: SectionProvenance?
@@ -1235,6 +1371,22 @@ struct SubdomainsSectionView: View {
12351371 .padding(.top, 4)
12361372 }
12371373 } else {
1374 if !groups.isEmpty {
1375 Text("Groups")
1376 .font(appDensity.font(.caption2))
1377 .foregroundStyle(.secondary)
1378 ForEach(groups) { group in
1379 HStack {
1380 Text("\(group.label).*")
1381 .font(appDensity.font(.caption))
1382 .foregroundStyle(.cyan)
1383 Spacer()
1384 Text("\(group.subdomains.count)")
1385 .font(appDensity.font(.caption2))
1386 .foregroundStyle(.secondary)
1387 }
1388 }
1389 }
12381390 ForEach(rows) { row in
12391391 HStack(spacing: 8) {
12401392 Text(row.hostname)
@@ -1266,6 +1418,7 @@ struct SubdomainsSectionView: View {
12661418struct DNSSectionView: View {
12671419 @Binding var isCollapsed: Bool
12681420 let dnssecLabel: String?
1421 let patternSummary: DNSPatternSummary?
12691422 let sections: [DNSRecordSectionViewData]
12701423 let ptrMessage: SectionMessageViewData?
12711424 let loading: Bool
@@ -1283,6 +1436,16 @@ struct DNSSectionView: View {
12831436 if dnsProvenance != nil {
12841437 CardView(allowsHorizontalScroll: false) {
12851438 SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil)
1439 if let patternSummary {
1440 if !patternSummary.providers.isEmpty {
1441 MessageRowView(text: "Providers: \(patternSummary.providers.joined(separator: ", "))", isError: false)
1442 }
1443 if !patternSummary.patterns.isEmpty {
1444 ForEach(Array(patternSummary.patterns.enumerated()), id: \.offset) { _, pattern in
1445 MessageRowView(text: pattern, isError: false)
1446 }
1447 }
1448 }
12861449 }
12871450 }
12881451 ForEach(sections) { section in
@@ -1332,6 +1495,7 @@ struct WebSectionView: View {
13321495 @Binding var isCollapsed: Bool
13331496 let certificateRows: [InfoRowViewData]
13341497 let sslInfo: SSLCertificateInfo?
1498 let tlsSummary: WebResultSummary?
13351499 let sslLoading: Bool
13361500 let sslError: String?
13371501 let tlsProvenance: SectionProvenance?
@@ -1354,9 +1518,17 @@ struct WebSectionView: View {
13541518 .font(appDensity.font(.subheadline, weight: .semibold))
13551519 .foregroundStyle(.cyan)
13561520 Spacer()
1357 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
1521 if !sslLoading {
1522 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
1523 }
13581524 }
13591525 SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil)
1526 if !sslLoading, let tlsSummary {
1527 LabeledValueRow(row: InfoRowViewData(label: "TLS Grade", value: tlsSummary.tlsGrade.rawValue, tone: tlsSummary.tlsGrade == .a ? .success : (tlsSummary.tlsGrade == .f ? .failure : .warning)))
1528 ForEach(Array(tlsSummary.tlsHighlights.enumerated()), id: \.offset) { _, highlight in
1529 MessageRowView(text: highlight, isError: isTLSHighlightError(highlight))
1530 }
1531 }
13601532 if sslLoading {
13611533 ProgressView("Checking certificate…")
13621534 .appLoadingStyle()
@@ -1371,9 +1543,11 @@ struct WebSectionView: View {
13711543 .font(appDensity.font(.caption2))
13721544 .foregroundStyle(.secondary)
13731545 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
1374 HStack(spacing: 8) {
1546 HStack(alignment: .top, spacing: 8) {
13751547 Text(san)
13761548 .font(appDensity.font(.caption))
1549 .lineLimit(nil)
1550 .fixedSize(horizontal: false, vertical: true)
13771551 .textSelection(.enabled)
13781552 Spacer()
13791553 AppCopyButton(value: san, label: "Copy certificate SAN")
@@ -1462,12 +1636,24 @@ struct WebSectionView: View {
14621636 }
14631637 }
14641638 }
1639
1640 private func isTLSHighlightError(_ highlight: String) -> Bool {
1641 let normalized = highlight.lowercased()
1642 if normalized.contains("no weak tls indicators were detected") {
1643 return false
1644 }
1645 return normalized.contains("expires")
1646 || normalized.contains("weak")
1647 || normalized.contains("tls 1.0")
1648 || normalized.contains("tls 1.1")
1649 }
14651650}
14661651
14671652struct EmailSectionView: View {
14681653 @Environment(\.appDensity) private var appDensity
14691654 @Binding var isCollapsed: Bool
14701655 let rows: [EmailRowViewData]
1656 let assessment: EmailSecuritySummary?
14711657 let loading: Bool
14721658 let provenance: SectionProvenance?
14731659 let confidence: ConfidenceLevel?
@@ -1482,6 +1668,14 @@ struct EmailSectionView: View {
14821668 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
14831669 .opacity(loading ? 0 : 1)
14841670 }
1671 if let assessment, let grade = assessment.grade {
1672 LabeledValueRow(row: InfoRowViewData(label: "Grade", value: grade.rawValue, tone: grade == .a ? .success : (grade == .f ? .failure : .warning)))
1673 if !assessment.reasons.isEmpty {
1674 Text(assessment.reasons.joined(separator: " | "))
1675 .font(appDensity.font(.caption2))
1676 .foregroundStyle(.secondary)
1677 }
1678 }
14851679 if loading {
14861680 ProgressView("Checking email records…")
14871681 .appLoadingStyle()
@@ -1817,6 +2011,8 @@ struct MessageRowView: View {
18172011 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
18182012 .font(appDensity.font(.caption))
18192013 .foregroundStyle(isError ? .red : .secondary)
2014 .lineLimit(nil)
2015 .fixedSize(horizontal: false, vertical: true)
18202016 }
18212017}
18222018
@@ -1890,8 +2086,12 @@ struct LabeledValueRow: View {
18902086 Text(row.value)
18912087 .font(appDensity.font(.caption))
18922088 .foregroundStyle(ResultColors.color(for: row.tone))
2089 .lineLimit(nil)
2090 .fixedSize(horizontal: false, vertical: true)
18932091 .textSelection(.enabled)
18942092 }
2093 .frame(maxWidth: .infinity, alignment: .leading)
2094 .layoutPriority(1)
18952095 Spacer(minLength: 6)
18962096 if !row.value.isEmpty, row.value != "Unavailable" {
18972097 AppCopyButton(value: row.value, label: "Copy \(row.label)")
DomainDig/DomainDiffService.swift +18 −2
@@ -57,7 +57,9 @@ enum DomainDiffService {
5757 static func summary(
5858 from oldSnapshot: LookupSnapshot,
5959 to newSnapshot: LookupSnapshot,
60 generatedAt: Date = Date()
60 generatedAt: Date = Date(),
61 riskAssessment: DomainRiskAssessment? = nil,
62 insights: [String]? = nil
6163 ) -> DomainChangeSummary {
6264 let sections = diff(from: oldSnapshot, to: newSnapshot)
6365 let allChangedItems = sections
@@ -70,16 +72,30 @@ enum DomainDiffService {
7072 let observedFacts = observedFacts(from: allChangedItems)
7173 let inferredConclusions = highlights.isEmpty ? [] : [message]
7274 let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot)
75 let newAnalysis = DomainInsightEngine.analyze(snapshot: newSnapshot, previousSnapshot: oldSnapshot)
76 let currentRiskAssessment = riskAssessment ?? newAnalysis.riskAssessment
77 let currentInsights = insights ?? newAnalysis.insights
78 let oldRiskAssessment = DomainInsightEngine.analyze(snapshot: oldSnapshot).riskAssessment
79 let riskScoreDelta = currentRiskAssessment.score - oldRiskAssessment.score
80 let impactClassification = DomainInsightEngine.impactClassification(
81 severity: severity,
82 riskDelta: riskScoreDelta,
83 changedSections: highlights
84 )
7385
7486 return DomainChangeSummary(
7587 hasChanges: !allChangedItems.isEmpty,
7688 changedSections: highlights,
7789 message: message,
7890 severity: severity,
91 impactClassification: impactClassification,
7992 generatedAt: generatedAt,
8093 observedFacts: observedFacts,
8194 inferredConclusions: inferredConclusions,
82 contextNote: contextNote
95 contextNote: contextNote,
96 riskAssessment: currentRiskAssessment,
97 insights: currentInsights,
98 riskScoreDelta: riskScoreDelta
8399 )
84100 }
85101
DomainDig/DomainInsightEngine.swift added +542
@@ -0,0 +1,542 @@
1import Foundation
2
3enum RiskLevel: String, Codable {
4 case low
5 case medium
6 case high
7
8 var title: String { rawValue.capitalized }
9}
10
11enum RiskImpact: String, Codable {
12 case positive
13 case neutral
14 case negative
15}
16
17struct RiskFactor: Codable, Equatable {
18 let description: String
19 let impact: RiskImpact
20}
21
22struct DomainRiskAssessment: Codable, Equatable {
23 let score: Int
24 let level: RiskLevel
25 let factors: [RiskFactor]
26}
27
28enum ChangeImpactClassification: String, Codable {
29 case informational
30 case warning
31 case critical
32
33 var title: String { rawValue.capitalized }
34}
35
36enum EmailSecurityGrade: String, Codable {
37 case a = "A"
38 case b = "B"
39 case c = "C"
40 case f = "F"
41}
42
43struct EmailSecurityAssessment: Codable, Equatable {
44 let grade: EmailSecurityGrade
45 let reasons: [String]
46}
47
48enum TLSGrade: String, Codable {
49 case a = "A"
50 case b = "B"
51 case c = "C"
52 case f = "F"
53}
54
55struct TLSSummaryAssessment: Codable, Equatable {
56 let grade: TLSGrade
57 let highlights: [String]
58}
59
60struct DNSPatternSummary: Codable, Equatable {
61 let providers: [String]
62 let wildcardDetected: Bool
63 let patterns: [String]
64}
65
66struct SubdomainGroup: Codable, Equatable, Identifiable {
67 let label: String
68 let subdomains: [String]
69
70 var id: String { label }
71}
72
73struct WorkflowInsight: Codable, Equatable, Identifiable {
74 let description: String
75 let domainsInvolved: [String]
76
77 var id: String {
78 ([description] + domainsInvolved.sorted()).joined(separator: "|")
79 }
80}
81
82struct DomainAnalysisBundle {
83 let riskAssessment: DomainRiskAssessment
84 let insights: [String]
85 let dnsPatterns: DNSPatternSummary
86 let emailAssessment: EmailSecurityAssessment?
87 let tlsAssessment: TLSSummaryAssessment
88 let subdomainGroups: [SubdomainGroup]
89}
90
91enum DomainInsightEngine {
92 static func analyze(snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainAnalysisBundle {
93 let dnsPatterns = dnsPatterns(for: snapshot)
94 let emailAssessment = emailAssessment(for: snapshot.emailSecurity)
95 let tlsAssessment = tlsAssessment(for: snapshot)
96 let subdomainGroups = groupedSubdomains(from: snapshot.subdomains.map(\.hostname))
97 let riskAssessment = riskAssessment(
98 for: snapshot,
99 previousSnapshot: previousSnapshot,
100 dnsPatterns: dnsPatterns,
101 emailAssessment: emailAssessment,
102 tlsAssessment: tlsAssessment,
103 subdomainGroups: subdomainGroups
104 )
105 let insights = insights(
106 for: snapshot,
107 previousSnapshot: previousSnapshot,
108 dnsPatterns: dnsPatterns,
109 emailAssessment: emailAssessment,
110 tlsAssessment: tlsAssessment,
111 subdomainGroups: subdomainGroups
112 )
113
114 return DomainAnalysisBundle(
115 riskAssessment: riskAssessment,
116 insights: insights,
117 dnsPatterns: dnsPatterns,
118 emailAssessment: emailAssessment,
119 tlsAssessment: tlsAssessment,
120 subdomainGroups: subdomainGroups
121 )
122 }
123
124 static func workflowInsights(for reports: [DomainReport]) -> [WorkflowInsight] {
125 var insights: [WorkflowInsight] = []
126
127 appendSharedInsights(
128 title: "Shared IP address observed",
129 groups: groupedDomains(for: reports, keyPath: \.dns.primaryIP),
130 into: &insights
131 )
132 appendSharedInsights(
133 title: "Shared nameserver set detected",
134 groups: groupedDomains(for: reports) {
135 let value = $0.ownership?.nameservers.sorted().joined(separator: "|")
136 return value?.nilIfEmpty
137 },
138 into: &insights
139 )
140 appendSharedInsights(
141 title: "Shared registrar detected",
142 groups: groupedDomains(for: reports) { $0.ownership?.registrar?.nilIfEmpty },
143 into: &insights
144 )
145 appendSharedInsights(
146 title: "Shared TLS issuer detected",
147 groups: groupedDomains(for: reports) { $0.web.tls?.issuer.nilIfEmpty },
148 into: &insights
149 )
150
151 return insights
152 }
153
154 static func impactClassification(
155 severity: ChangeSeverity,
156 riskDelta: Int,
157 changedSections: [String]
158 ) -> ChangeImpactClassification {
159 if severity == .high || riskDelta >= 20 || changedSections.contains(where: {
160 $0.localizedCaseInsensitiveContains("availability")
161 || $0.localizedCaseInsensitiveContains("certificate expires")
162 }) {
163 return .critical
164 }
165 if severity == .medium || riskDelta >= 8 || !changedSections.isEmpty {
166 return .warning
167 }
168 return .informational
169 }
170
171 private static func riskAssessment(
172 for snapshot: LookupSnapshot,
173 previousSnapshot: LookupSnapshot?,
174 dnsPatterns: DNSPatternSummary,
175 emailAssessment: EmailSecurityAssessment?,
176 tlsAssessment: TLSSummaryAssessment,
177 subdomainGroups: [SubdomainGroup]
178 ) -> DomainRiskAssessment {
179 var score = 18
180 var factors: [RiskFactor] = []
181
182 switch snapshot.availabilityResult?.status ?? .unknown {
183 case .available:
184 score -= 12
185 factors.append(.init(description: "Domain appears available rather than actively deployed", impact: .positive))
186 case .unknown:
187 score += 8
188 factors.append(.init(description: "Ownership and availability could not be confirmed", impact: .negative))
189 case .registered:
190 if !snapshot.dnsSections.isEmpty || snapshot.sslInfo != nil || !snapshot.redirectChain.isEmpty {
191 score += 6
192 factors.append(.init(description: "Registered domain exposes active infrastructure", impact: .negative))
193 } else {
194 factors.append(.init(description: "Registered domain with limited active surface detected", impact: .neutral))
195 }
196 }
197
198 let recordTypes = snapshot.dnsSections.filter { !$0.records.isEmpty || !$0.wildcardRecords.isEmpty }.count
199 if recordTypes >= 5 {
200 score += 8
201 factors.append(.init(description: "DNS configuration is broad across multiple record types", impact: .negative))
202 }
203 if dnsPatterns.wildcardDetected {
204 score += 12
205 factors.append(.init(description: "Wildcard DNS is enabled", impact: .negative))
206 }
207 if let firstPattern = dnsPatterns.patterns.first {
208 factors.append(.init(description: firstPattern, impact: .neutral))
209 }
210
211 switch tlsAssessment.grade {
212 case .a:
213 score -= 8
214 factors.append(.init(description: "TLS configuration looks current and stable", impact: .positive))
215 case .b:
216 score -= 3
217 factors.append(.init(description: "TLS is valid with minor concerns", impact: .positive))
218 case .c:
219 score += 10
220 factors.append(.init(description: "TLS configuration has visible weaknesses", impact: .negative))
221 case .f:
222 score += 22
223 factors.append(.init(description: "TLS is missing, invalid, or near failure", impact: .negative))
224 }
225
226 if let daysUntilExpiry = snapshot.sslInfo?.daysUntilExpiry, daysUntilExpiry <= 14 {
227 score += 10
228 factors.append(.init(description: "Certificate expires within 14 days", impact: .negative))
229 }
230
231 if snapshot.redirectChain.count >= 3 {
232 score += 8
233 factors.append(.init(description: "Redirect chain is longer than expected", impact: .negative))
234 }
235
236 if redirectLooksSensitive(snapshot.redirectChain.last?.url) {
237 score += 6
238 factors.append(.init(description: "Redirect target looks like an auth or account gateway", impact: .negative))
239 }
240
241 if let emailAssessment {
242 switch emailAssessment.grade {
243 case .a:
244 score -= 10
245 factors.append(.init(description: "Email protections are strong and aligned", impact: .positive))
246 case .b:
247 score -= 4
248 factors.append(.init(description: "Email protections are present with minor gaps", impact: .positive))
249 case .c:
250 score += 8
251 factors.append(.init(description: "Email protections are partial", impact: .negative))
252 case .f:
253 score += 18
254 factors.append(.init(description: "Email security protections are weak or absent", impact: .negative))
255 }
256 } else if hasMXRecords(snapshot) {
257 score += 14
258 factors.append(.init(description: "Mail is configured without enough email security evidence", impact: .negative))
259 }
260
261 let openPorts = snapshot.portScanResults.filter(\.open).map(\.port)
262 let sensitivePorts: Set<UInt16> = [21, 22, 23, 25, 3389, 5900]
263 let exposedSensitivePorts = openPorts.filter { sensitivePorts.contains($0) }
264 if !exposedSensitivePorts.isEmpty {
265 score += min(18, exposedSensitivePorts.count * 6)
266 factors.append(.init(description: "Sensitive management or mail ports are exposed", impact: .negative))
267 } else if Set(openPorts) == Set([80, 443]) {
268 factors.append(.init(description: "Exposure is limited to standard web ports", impact: .positive))
269 } else if openPorts.count >= 3 {
270 score += 8
271 factors.append(.init(description: "Multiple open services expand the attack surface", impact: .negative))
272 }
273
274 if !subdomainGroups.isEmpty {
275 let labels = Set(subdomainGroups.map(\.label))
276 if labels.contains("dev") || labels.contains("staging") {
277 score += 8
278 factors.append(.init(description: "Development or staging subdomains are discoverable", impact: .negative))
279 }
280 if labels.contains("admin") {
281 score += 10
282 factors.append(.init(description: "Administrative subdomains are discoverable", impact: .negative))
283 }
284 if snapshot.subdomains.count >= 8 {
285 score += 6
286 factors.append(.init(description: "Large passive subdomain footprint detected", impact: .negative))
287 }
288 }
289
290 if snapshot.ipGeolocation == nil,
291 snapshot.availabilityResult?.status == .registered,
292 snapshot.dnsSections.contains(where: { $0.recordType == .A && !$0.records.isEmpty }) {
293 score += 4
294 factors.append(.init(description: "Active host could not be geolocated", impact: .neutral))
295 }
296
297 if let previousSnapshot {
298 let previousAnalysis = analyze(snapshot: previousSnapshot)
299 let delta = score - previousAnalysis.riskAssessment.score
300 if delta >= 15 {
301 score += 4
302 factors.append(.init(description: "Observed risk has increased materially since the previous snapshot", impact: .negative))
303 }
304 }
305
306 let clampedScore = min(max(score, 0), 100)
307 let level: RiskLevel
308 switch clampedScore {
309 case 0..<35:
310 level = .low
311 case 35..<65:
312 level = .medium
313 default:
314 level = .high
315 }
316
317 return DomainRiskAssessment(score: clampedScore, level: level, factors: factors)
318 }
319
320 private static func insights(
321 for snapshot: LookupSnapshot,
322 previousSnapshot: LookupSnapshot?,
323 dnsPatterns: DNSPatternSummary,
324 emailAssessment: EmailSecurityAssessment?,
325 tlsAssessment: TLSSummaryAssessment,
326 subdomainGroups: [SubdomainGroup]
327 ) -> [String] {
328 var items: [String] = []
329
330 if let group = subdomainGroups.first(where: { $0.label == "staging" || $0.label == "dev" }) {
331 items.append("Multiple \(group.label) subdomains suggest non-production environments are exposed")
332 }
333 if subdomainGroups.contains(where: { $0.label == "admin" }) {
334 items.append("Administrative subdomains are publicly discoverable")
335 }
336 if let emailAssessment, emailAssessment.grade == .f {
337 items.append("Domain lacks email security protections")
338 } else if let emailAssessment, emailAssessment.grade == .c {
339 items.append("Email security is only partially enforced")
340 }
341 if let daysUntilExpiry = snapshot.sslInfo?.daysUntilExpiry, daysUntilExpiry <= 30 {
342 items.append("Certificate expires soon")
343 }
344 if redirectLooksSensitive(snapshot.redirectChain.last?.url) {
345 items.append("Redirect chain may indicate login gateway")
346 }
347 items.append(contentsOf: dnsPatterns.patterns)
348
349 if let tlsVersion = snapshot.sslInfo?.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" {
350 items.append("TLS protocol version is outdated")
351 }
352 if tlsAssessment.grade == .f, snapshot.sslInfo == nil, snapshot.availabilityResult?.status == .registered {
353 items.append("HTTPS endpoint could not be validated")
354 }
355 if let previousSnapshot,
356 let previousURL = previousSnapshot.redirectChain.last?.url,
357 let currentURL = snapshot.redirectChain.last?.url,
358 previousURL != currentURL {
359 items.append("Redirect target changed since the previous snapshot")
360 }
361
362 var deduplicated: [String] = []
363 for item in items where !deduplicated.contains(item) {
364 deduplicated.append(item)
365 }
366 return deduplicated
367 }
368
369 private static func dnsPatterns(for snapshot: LookupSnapshot) -> DNSPatternSummary {
370 let nameservers = snapshot.ownership?.nameservers.map { $0.lowercased() } ?? []
371 let headerNames = Set(snapshot.httpHeaders.map { $0.name.lowercased() })
372 let headerValues = snapshot.httpHeaders.map { $0.value.lowercased() }
373 let allValues = snapshot.dnsSections.flatMap { section in
374 (section.records + section.wildcardRecords).map { $0.value.lowercased() }
375 }
376
377 var providers: [String] = []
378 if nameservers.contains(where: { $0.contains("cloudflare") }) || headerNames.contains("cf-ray") || headerNames.contains("cf-cache-status") {
379 providers.append("Cloudflare")
380 }
381 if nameservers.contains(where: { $0.contains("awsdns") }) || allValues.contains(where: { $0.contains("cloudfront.net") || $0.contains("elb.amazonaws.com") || $0.contains("amazonaws.com") }) {
382 providers.append("AWS")
383 }
384 if allValues.contains(where: { $0.contains("fastly.net") }) || headerValues.contains(where: { $0.contains("fastly") || $0.contains("cache-") }) {
385 providers.append("Fastly")
386 }
387
388 var patterns: [String] = []
389 let wildcardDetected = snapshot.dnsSections.contains { !$0.wildcardRecords.isEmpty }
390 if !providers.isEmpty {
391 patterns.append("CDN or edge network detected: \(providers.joined(separator: ", "))")
392 }
393 if wildcardDetected {
394 patterns.append("Wildcard DNS responses are present")
395 }
396 if hasMXRecords(snapshot), snapshot.emailSecurity == nil {
397 patterns.append("MX records exist without corresponding email security records")
398 }
399 if snapshot.sslInfo != nil && !(snapshot.dnsSections.first(where: { $0.recordType == .CAA })?.records.isEmpty == false) {
400 patterns.append("TLS is active but no CAA record was found")
401 }
402
403 return DNSPatternSummary(providers: providers, wildcardDetected: wildcardDetected, patterns: patterns)
404 }
405
406 private static func emailAssessment(for result: EmailSecurityResult?) -> EmailSecurityAssessment? {
407 guard let result else { return nil }
408
409 let spfFound = result.spf.found
410 let dkimFound = result.dkim.found
411 let dmarcFound = result.dmarc.found
412 let dmarcStrict = isStrictDMARC(result.dmarc.value)
413
414 let reasons = [
415 spfFound ? "SPF present" : "SPF missing",
416 dmarcFound ? (dmarcStrict ? "DMARC policy is strict" : "DMARC policy is not strict") : "DMARC missing",
417 dkimFound ? "DKIM present" : "DKIM not detected"
418 ]
419
420 let grade: EmailSecurityGrade
421 if spfFound && dkimFound && dmarcStrict {
422 grade = .a
423 } else if spfFound && dmarcFound && (dkimFound || dmarcStrict) {
424 grade = .b
425 } else if spfFound || dmarcFound || dkimFound {
426 grade = .c
427 } else {
428 grade = .f
429 }
430
431 return EmailSecurityAssessment(grade: grade, reasons: reasons)
432 }
433
434 private static func tlsAssessment(for snapshot: LookupSnapshot) -> TLSSummaryAssessment {
435 guard let sslInfo = snapshot.sslInfo else {
436 return TLSSummaryAssessment(grade: .f, highlights: ["TLS handshake failed or no certificate was returned"])
437 }
438
439 var issues: [String] = []
440 if sslInfo.daysUntilExpiry <= 14 {
441 issues.append("Certificate expires within 14 days")
442 } else if sslInfo.daysUntilExpiry <= 30 {
443 issues.append("Certificate expires within 30 days")
444 }
445 if let tlsVersion = sslInfo.tlsVersion, tlsVersion == "TLS 1.0" || tlsVersion == "TLS 1.1" {
446 issues.append("Uses \(tlsVersion)")
447 }
448 if let cipherSuite = sslInfo.cipherSuite?.lowercased(),
449 cipherSuite.contains("_cbc_") || cipherSuite.contains("3des") || cipherSuite.contains("rc4") {
450 issues.append("Negotiated cipher suite looks weak")
451 }
452
453 let grade: TLSGrade
454 if snapshot.sslError != nil {
455 grade = .f
456 } else if issues.contains(where: { $0.contains("14 days") || $0.contains("weak") || $0.contains("TLS 1.0") || $0.contains("TLS 1.1") }) {
457 grade = .c
458 } else if !issues.isEmpty {
459 grade = .b
460 } else {
461 grade = .a
462 }
463
464 return TLSSummaryAssessment(
465 grade: grade,
466 highlights: issues.isEmpty ? ["Certificate is valid and no weak TLS indicators were detected"] : issues
467 )
468 }
469
470 private static func groupedSubdomains(from subdomains: [String]) -> [SubdomainGroup] {
471 let labels = ["api", "dev", "staging", "admin"]
472 let normalized = Array(Set(subdomains.map { $0.lowercased() })).sorted()
473
474 return labels.compactMap { label in
475 let matches = normalized.filter {
476 guard let firstLabel = $0.split(separator: ".").first?.lowercased() else { return false }
477 return firstLabel == label
478 }
479 guard !matches.isEmpty else { return nil }
480 return SubdomainGroup(label: label, subdomains: matches)
481 }
482 }
483
484 private static func isStrictDMARC(_ value: String?) -> Bool {
485 guard let value = value?.lowercased() else { return false }
486 return value.contains("p=reject") || value.contains("p=quarantine")
487 }
488
489 private static func hasMXRecords(_ snapshot: LookupSnapshot) -> Bool {
490 snapshot.dnsSections.contains { $0.recordType == .MX && !$0.records.isEmpty }
491 }
492
493 private static func redirectLooksSensitive(_ urlString: String?) -> Bool {
494 guard let urlString = urlString?.lowercased() else { return false }
495 return urlString.contains("/login")
496 || urlString.contains("/signin")
497 || urlString.contains("/auth")
498 || urlString.contains("/account")
499 || urlString.contains("sso")
500 }
501
502 private static func appendSharedInsights(
503 title: String,
504 groups: [String: [String]],
505 into insights: inout [WorkflowInsight]
506 ) {
507 for domains in groups.values where domains.count >= 2 {
508 insights.append(
509 WorkflowInsight(
510 description: "\(title) across \(domains.count) domains",
511 domainsInvolved: domains.sorted()
512 )
513 )
514 }
515 }
516
517 private static func groupedDomains(
518 for reports: [DomainReport],
519 keyPath: KeyPath<DomainReport, String?>
520 ) -> [String: [String]] {
521 groupedDomains(for: reports) { $0[keyPath: keyPath]?.nilIfEmpty }
522 }
523
524 private static func groupedDomains(
525 for reports: [DomainReport],
526 transform: (DomainReport) -> String?
527 ) -> [String: [String]] {
528 var grouped: [String: [String]] = [:]
529 for report in reports {
530 guard let value = transform(report) else { continue }
531 grouped[value, default: []].append(report.domain)
532 }
533 return grouped
534 }
535}
536
537private extension String {
538 var nilIfEmpty: String? {
539 let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
540 return trimmed.isEmpty ? nil : trimmed
541 }
542}
DomainDig/DomainViewModel.swift +94 −22
@@ -96,6 +96,13 @@ private struct BatchLookupPayload {
9696 let snapshot: LookupSnapshot
9797}
9898
99private struct WorkflowExportPayload: Codable {
100 let workflowName: String
101 let generatedAt: Date
102 let workflowInsights: [WorkflowInsight]
103 let reports: [DomainReport]
104}
105
99106@MainActor
100107@Observable
101108final class DomainViewModel {
@@ -194,6 +201,7 @@ final class DomainViewModel {
194201 private(set) var currentStatusMessage: String?
195202 private(set) var currentSnapshotTimestamp = Date()
196203 private(set) var currentHistoryEntryID: UUID?
204 private(set) var currentReport: DomainReport?
197205
198206 private static let recentSearchesKey = "recentSearches"
199207 private static let maxRecent = 20
@@ -448,16 +456,28 @@ final class DomainViewModel {
448456 return history.first(where: { $0.id == currentHistoryEntryID })
449457 }
450458
451 var currentReport: DomainReport? {
452 guard !searchedDomain.isEmpty else { return nil }
453 return reportBuilder.build(
454 from: currentSnapshot,
455 previousSnapshot: previousSnapshot(
456 for: searchedDomain,
457 trackedDomainID: currentTrackedDomain?.id,
458 replacingLatest: false
459 )
460 )
459 var currentRiskAssessment: DomainRiskAssessment? {
460 currentReport?.riskAssessment
461 }
462
463 var currentInsights: [String] {
464 currentReport?.insights ?? []
465 }
466
467 var currentSubdomainGroups: [SubdomainGroup] {
468 currentReport?.subdomainGroups ?? []
469 }
470
471 var currentDNSPatterns: DNSPatternSummary? {
472 currentReport?.dns.patternSummary
473 }
474
475 var currentEmailAssessment: EmailSecuritySummary? {
476 currentReport?.email
477 }
478
479 var currentTLSSummary: WebResultSummary? {
480 currentReport?.web
461481 }
462482
463483 var summaryFields: [SummaryFieldViewData] {
@@ -684,6 +704,7 @@ final class DomainViewModel {
684704 currentDiffSections = []
685705 currentChangeSummary = nil
686706 ownershipDiff = []
707 currentReport = nil
687708 refreshingTrackedDomainID = nil
688709 clearBatchState()
689710 clearLookupState()
@@ -728,7 +749,10 @@ final class DomainViewModel {
728749 quickStatus: "Cancelled",
729750 summaryMessage: batchResults[index].summaryMessage,
730751 changeSeverity: batchResults[index].changeSeverity,
752 changeClassification: batchResults[index].changeClassification,
731753 certificateWarningLevel: batchResults[index].certificateWarningLevel,
754 riskScore: batchResults[index].riskScore,
755 riskLevel: batchResults[index].riskLevel,
732756 timestamp: Date(),
733757 status: .failed,
734758 errorMessage: "Lookup cancelled"
@@ -918,22 +942,36 @@ final class DomainViewModel {
918942 }
919943
920944 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
921 DomainReportExporter.batchText(
922 for: workflowReports(from: summary, changedOnly: changedOnly),
945 let reports = workflowReports(from: summary, changedOnly: changedOnly)
946 let base = DomainReportExporter.batchText(
947 for: reports,
923948 title: "\(summary.workflowName) Workflow Export"
924949 )
950 guard !summary.workflowInsights.isEmpty else { return base }
951 let insightLines = summary.workflowInsights.map {
952 "- \($0.description): \($0.domainsInvolved.joined(separator: ", "))"
953 }
954 return ([ "\(summary.workflowName) Workflow Insights", String(repeating: "-", count: 32) ] + insightLines + ["", base]).joined(separator: "\n")
925955 }
926956
927957 func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
928 DomainReportExporter.csv(for: workflowReports(from: summary, changedOnly: changedOnly))
958 DomainReportExporter.csv(
959 for: workflowReports(from: summary, changedOnly: changedOnly),
960 workflowInsights: summary.workflowInsights
961 )
929962 }
930963
931964 func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
932 try? DomainReportExporter.data(
933 for: workflowReports(from: summary, changedOnly: changedOnly),
934 format: .json,
935 title: "\(summary.workflowName) Workflow Export"
965 let payload = WorkflowExportPayload(
966 workflowName: summary.workflowName,
967 generatedAt: summary.generatedAt,
968 workflowInsights: summary.workflowInsights,
969 reports: workflowReports(from: summary, changedOnly: changedOnly)
936970 )
971 let encoder = JSONEncoder()
972 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
973 encoder.dateEncodingStrategy = .iso8601
974 return try? encoder.encode(payload)
937975 }
938976
939977 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
@@ -963,9 +1001,17 @@ final class DomainViewModel {
9631001 currentResultSource = snapshot.resultSource
9641002 currentCachedSections = snapshot.cachedSections
9651003 currentStatusMessage = snapshot.statusMessage
966 currentChangeSummary = snapshot.changeSummary
9671004 currentDiffSections = []
9681005 ownershipDiff = []
1006 currentReport = reportBuilder.build(
1007 from: snapshot,
1008 previousSnapshot: previousSnapshot(
1009 for: snapshot.domain,
1010 trackedDomainID: snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id,
1011 replacingLatest: false
1012 )
1013 )
1014 currentChangeSummary = currentReport?.changeSummary ?? snapshot.changeSummary
9691015
9701016 dnsSections = snapshot.dnsSections
9711017 dnsError = snapshot.dnsError
@@ -1406,8 +1452,15 @@ final class DomainViewModel {
14061452 private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? {
14071453 let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
14081454 let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
1455 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
14091456 let changeSummary = previousSnapshot.map {
1410 DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp)
1457 DomainDiffService.summary(
1458 from: $0,
1459 to: snapshot,
1460 generatedAt: snapshot.timestamp,
1461 riskAssessment: analysis.riskAssessment,
1462 insights: analysis.insights
1463 )
14111464 }
14121465 let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
14131466
@@ -1415,6 +1468,7 @@ final class DomainViewModel {
14151468 currentChangeSummary = changeSummary
14161469 currentDiffSections = diffSections
14171470 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
1471 currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
14181472 }
14191473
14201474 let entry = HistoryEntry(
@@ -1664,6 +1718,7 @@ final class DomainViewModel {
16641718 currentStatusMessage = nil
16651719 currentDiffSections = []
16661720 currentChangeSummary = nil
1721 currentReport = nil
16671722 ownershipDiff = []
16681723 clearLookupState()
16691724 setAllLoadingStates(true)
@@ -1807,11 +1862,14 @@ final class DomainViewModel {
18071862 }
18081863 }
18091864 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
1865 let riskAssessment = DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
18101866 let quickStatus: String
18111867 if entry?.changeSummary?.hasChanges == true {
1812 quickStatus = entry?.changeSummary?.severity == .high ? "High" : "Changed"
1868 quickStatus = entry?.changeSummary?.impactClassification == .critical ? "Critical" : (entry?.changeSummary?.severity == .high ? "High" : "Changed")
18131869 } else if certificateWarningLevel != .none {
18141870 quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
1871 } else if riskAssessment.level == .high {
1872 quickStatus = "High"
18151873 } else {
18161874 quickStatus = "Unchanged"
18171875 }
@@ -1834,9 +1892,14 @@ final class DomainViewModel {
18341892 refreshingTrackedDomainID = nil
18351893 batchTask = nil
18361894
1837 let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count
1895 let changedCount = batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" || $0.quickStatus == "Critical" }.count
18381896 let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count
1839 let warningCount = batchResults.filter { $0.certificateWarningLevel != .none }.count
1897 let warningCount = batchResults.filter {
1898 $0.certificateWarningLevel != .none
1899 || $0.changeClassification == .warning
1900 || $0.changeClassification == .critical
1901 || $0.riskLevel == .high
1902 }.count
18401903
18411904 let summary = BatchSweepSummary(
18421905 source: source,
@@ -1855,6 +1918,10 @@ final class DomainViewModel {
18551918 latestBatchSweepSummary = summary
18561919
18571920 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName {
1921 let workflowReports: [DomainReport] = summary.results.compactMap { result in
1922 guard let entry = historyEntry(for: result) else { return nil }
1923 return report(for: entry)
1924 }
18581925 latestWorkflowRunSummary = WorkflowRunSummary(
18591926 workflowID: activeWorkflowRunID,
18601927 workflowName: activeWorkflowRunName,
@@ -1863,6 +1930,7 @@ final class DomainViewModel {
18631930 unchangedDomains: unchangedCount,
18641931 warningDomains: warningCount,
18651932 results: summary.results,
1933 workflowInsights: DomainInsightEngine.workflowInsights(for: workflowReports),
18661934 generatedAt: summary.generatedAt
18671935 )
18681936 }
@@ -1896,7 +1964,10 @@ final class DomainViewModel {
18961964 quickStatus: quickStatus,
18971965 summaryMessage: entry?.changeSummary?.message,
18981966 changeSeverity: entry?.changeSummary?.severity,
1967 changeClassification: entry?.changeSummary?.impactClassification,
18991968 certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel,
1969 riskScore: entry.map { $0.changeSummary?.riskAssessment?.score ?? report(for: $0).riskAssessment.score },
1970 riskLevel: entry.map { $0.changeSummary?.riskAssessment?.level ?? report(for: $0).riskAssessment.level },
19001971 timestamp: entry?.timestamp ?? Date(),
19011972 status: status,
19021973 errorMessage: errorMessage
@@ -1956,6 +2027,7 @@ final class DomainViewModel {
19562027 currentResultSource = .live
19572028 currentCachedSections = []
19582029 currentStatusMessage = nil
2030 currentReport = nil
19592031 }
19602032
19612033 private func setAllLoadingStates(_ loading: Bool) {
DomainDig/HistoryView.swift +18 −2
@@ -167,12 +167,24 @@ struct HistoryDetailView: View {
167167 entry.snapshot
168168 }
169169
170 private var report: DomainReport {
171 DomainReportBuilder().build(from: entry, previousSnapshot: viewModel.comparisonSnapshot(for: entry))
172 }
173
174 private var trackedDomain: TrackedDomain? {
175 viewModel.trackedDomains.first { $0.domain.caseInsensitiveCompare(entry.domain) == .orderedSame }
176 }
177
170178 var body: some View {
171179 ScrollView(.vertical) {
172180 VStack(alignment: .leading, spacing: 0) {
173181 snapshotBanner
174182 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
175183 .padding(.top, 8)
184 RiskSummaryCardView(report: report)
185 .padding(.top, 8)
186 InsightsSummaryCardView(insights: report.insights)
187 .padding(.top, 8)
176188 DomainSectionView(
177189 isCollapsed: .constant(false),
178190 rows: DomainViewModel.domainRows(from: snapshot),
@@ -183,14 +195,14 @@ struct HistoryDetailView: View {
183195 provenance: snapshot.provenanceBySection[.availability],
184196 confidence: snapshot.availabilityConfidence,
185197 snapshotNote: entry.note,
186 trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }),
198 trackedDomain: trackedDomain,
187199 workflows: viewModel.workflowsContaining(domain: entry.domain),
188200 trackingLimitMessage: nil,
189201 onTrack: {
190202 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
191203 },
192204 onTogglePinned: {
193 guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return }
205 guard let trackedDomain else { return }
194206 viewModel.togglePinned(for: trackedDomain)
195207 },
196208 onEditNote: nil,
@@ -212,6 +224,7 @@ struct HistoryDetailView: View {
212224 SubdomainsSectionView(
213225 isCollapsed: .constant(false),
214226 rows: DomainViewModel.subdomainRows(from: snapshot),
227 groups: report.subdomainGroups,
215228 loading: false,
216229 error: snapshot.subdomainsError,
217230 provenance: snapshot.provenanceBySection[.subdomains],
@@ -235,6 +248,7 @@ struct HistoryDetailView: View {
235248 DNSSectionView(
236249 isCollapsed: .constant(false),
237250 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
251 patternSummary: report.dns.patternSummary,
238252 sections: DomainViewModel.dnsRows(from: snapshot),
239253 ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
240254 loading: false,
@@ -247,6 +261,7 @@ struct HistoryDetailView: View {
247261 isCollapsed: .constant(false),
248262 certificateRows: DomainViewModel.webCertificateRows(from: snapshot),
249263 sslInfo: snapshot.sslInfo,
264 tlsSummary: report.web,
250265 sslLoading: false,
251266 sslError: snapshot.sslError,
252267 tlsProvenance: snapshot.provenanceBySection[.ssl],
@@ -265,6 +280,7 @@ struct HistoryDetailView: View {
265280 EmailSectionView(
266281 isCollapsed: .constant(false),
267282 rows: DomainViewModel.emailRows(from: snapshot),
283 assessment: report.email,
268284 loading: false,
269285 provenance: snapshot.provenanceBySection[.emailSecurity],
270286 confidence: snapshot.emailSecurityConfidence,
DomainDig/Models.swift +30 −1
@@ -181,29 +181,41 @@ struct DomainChangeSummary: Codable, Equatable {
181181 let changedSections: [String]
182182 let message: String
183183 let severity: ChangeSeverity
184 let impactClassification: ChangeImpactClassification
184185 let generatedAt: Date
185186 let observedFacts: [String]
186187 let inferredConclusions: [String]
187188 let contextNote: String?
189 let riskAssessment: DomainRiskAssessment?
190 let insights: [String]
191 let riskScoreDelta: Int?
188192
189193 init(
190194 hasChanges: Bool,
191195 changedSections: [String],
192196 message: String,
193197 severity: ChangeSeverity,
198 impactClassification: ChangeImpactClassification,
194199 generatedAt: Date,
195200 observedFacts: [String] = [],
196201 inferredConclusions: [String] = [],
197 contextNote: String? = nil
202 contextNote: String? = nil,
203 riskAssessment: DomainRiskAssessment? = nil,
204 insights: [String] = [],
205 riskScoreDelta: Int? = nil
198206 ) {
199207 self.hasChanges = hasChanges
200208 self.changedSections = changedSections
201209 self.message = message
202210 self.severity = severity
211 self.impactClassification = impactClassification
203212 self.generatedAt = generatedAt
204213 self.observedFacts = observedFacts
205214 self.inferredConclusions = inferredConclusions
206215 self.contextNote = contextNote
216 self.riskAssessment = riskAssessment
217 self.insights = insights
218 self.riskScoreDelta = riskScoreDelta
207219 }
208220
209221 init(from decoder: Decoder) throws {
@@ -212,11 +224,16 @@ struct DomainChangeSummary: Codable, Equatable {
212224 changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? []
213225 generatedAt = try container.decode(Date.self, forKey: .generatedAt)
214226 severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low)
227 impactClassification = try container.decodeIfPresent(ChangeImpactClassification.self, forKey: .impactClassification)
228 ?? (severity == .high ? .critical : (hasChanges ? .warning : .informational))
215229 message = try container.decodeIfPresent(String.self, forKey: .message)
216230 ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • "))
217231 observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? []
218232 inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? []
219233 contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote)
234 riskAssessment = try container.decodeIfPresent(DomainRiskAssessment.self, forKey: .riskAssessment)
235 insights = try container.decodeIfPresent([String].self, forKey: .insights) ?? []
236 riskScoreDelta = try container.decodeIfPresent(Int.self, forKey: .riskScoreDelta)
220237 }
221238}
222239
@@ -243,7 +260,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
243260 let quickStatus: String
244261 let summaryMessage: String?
245262 let changeSeverity: ChangeSeverity?
263 let changeClassification: ChangeImpactClassification?
246264 let certificateWarningLevel: CertificateWarningLevel
265 let riskScore: Int?
266 let riskLevel: RiskLevel?
247267 let timestamp: Date
248268 let status: BatchLookupStatus
249269 let errorMessage: String?
@@ -258,7 +278,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
258278 quickStatus: String,
259279 summaryMessage: String? = nil,
260280 changeSeverity: ChangeSeverity? = nil,
281 changeClassification: ChangeImpactClassification? = nil,
261282 certificateWarningLevel: CertificateWarningLevel = .none,
283 riskScore: Int? = nil,
284 riskLevel: RiskLevel? = nil,
262285 timestamp: Date,
263286 status: BatchLookupStatus,
264287 errorMessage: String? = nil
@@ -272,7 +295,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
272295 self.quickStatus = quickStatus
273296 self.summaryMessage = summaryMessage
274297 self.changeSeverity = changeSeverity
298 self.changeClassification = changeClassification
275299 self.certificateWarningLevel = certificateWarningLevel
300 self.riskScore = riskScore
301 self.riskLevel = riskLevel
276302 self.timestamp = timestamp
277303 self.status = status
278304 self.errorMessage = errorMessage
@@ -281,7 +307,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
281307 var hasMeaningfulChange: Bool {
282308 quickStatus == "Changed"
283309 || quickStatus == "High"
310 || quickStatus == "Critical"
284311 || certificateWarningLevel != .none
312 || riskLevel == .high
285313 || status == .failed
286314 }
287315}
@@ -331,6 +359,7 @@ struct WorkflowRunSummary: Identifiable, Equatable {
331359 let unchangedDomains: Int
332360 let warningDomains: Int
333361 let results: [BatchLookupResult]
362 let workflowInsights: [WorkflowInsight]
334363 let generatedAt: Date
335364}
336365
DomainDig/WorkflowsView.swift +20
@@ -215,6 +215,11 @@ struct WorkflowDetailView: View {
215215 statRow(label: "Changed", value: "\(latestSummary.changedDomains)")
216216 statRow(label: "Warnings", value: "\(latestSummary.warningDomains)")
217217 statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)")
218 if !latestSummary.workflowInsights.isEmpty {
219 Text(latestSummary.workflowInsights[0].description)
220 .font(appDensity.font(.caption))
221 .foregroundStyle(.secondary)
222 }
218223
219224 Button {
220225 viewModel.latestWorkflowRunSummary = latestSummary
@@ -431,6 +436,21 @@ struct WorkflowRunSummaryView: View {
431436 Toggle("Show unchanged domains", isOn: $showAllResults)
432437 }
433438
439 if !summary.workflowInsights.isEmpty {
440 Section("Workflow Insights") {
441 ForEach(summary.workflowInsights) { insight in
442 VStack(alignment: .leading, spacing: 4) {
443 Text(insight.description)
444 .font(.system(.callout, design: .monospaced))
445 .foregroundStyle(.primary)
446 Text(insight.domainsInvolved.joined(separator: ", "))
447 .font(.system(.caption, design: .monospaced))
448 .foregroundStyle(.secondary)
449 }
450 }
451 }
452 }
453
434454 Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") {
435455 if visibleResults.isEmpty {
436456 Text("No domains with meaningful changes or warnings")
DomainReportBuilder.swift +35 −5
@@ -25,6 +25,9 @@ struct DomainReport: Codable {
2525 let email: EmailSecuritySummary
2626 let network: NetworkSummary
2727 let subdomains: [String]
28 let subdomainGroups: [SubdomainGroup]
29 let riskAssessment: DomainRiskAssessment
30 let insights: [String]
2831 let changeSummary: DomainChangeSummary?
2932}
3033
@@ -36,6 +39,7 @@ struct DNSResultSummary: Codable {
3639 let primaryIP: String?
3740 let ptrRecord: String?
3841 let dnssecSigned: Bool?
42 let patternSummary: DNSPatternSummary
3943 let error: String?
4044 let ptrError: String?
4145}
@@ -43,6 +47,8 @@ struct DNSResultSummary: Codable {
4347struct WebResultSummary: Codable {
4448 let tls: SSLCertificateInfo?
4549 let tlsStatus: String
50 let tlsGrade: TLSGrade
51 let tlsHighlights: [String]
4652 let certificateWarningLevel: CertificateWarningLevel
4753 let hstsPreloaded: Bool?
4854 let headers: [HTTPHeader]
@@ -61,6 +67,8 @@ struct WebResultSummary: Codable {
6167
6268struct EmailSecuritySummary: Codable {
6369 let records: EmailSecurityResult?
70 let grade: EmailSecurityGrade?
71 let reasons: [String]
6472 let summary: String
6573 let error: String?
6674}
@@ -81,6 +89,21 @@ struct NetworkSummary: Codable {
8189struct DomainReportBuilder {
8290 func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
8391 let primaryIP = primaryIPAddress(from: snapshot)
92 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
93 let changeSummary: DomainChangeSummary?
94 if let existingChangeSummary = snapshot.changeSummary, existingChangeSummary.riskAssessment != nil {
95 changeSummary = existingChangeSummary
96 } else {
97 changeSummary = previousSnapshot.map {
98 DomainDiffService.summary(
99 from: $0,
100 to: snapshot,
101 generatedAt: snapshot.timestamp,
102 riskAssessment: analysis.riskAssessment,
103 insights: analysis.insights
104 )
105 }
106 }
84107
85108 return DomainReport(
86109 domain: snapshot.domain,
@@ -110,12 +133,15 @@ struct DomainReportBuilder {
110133 primaryIP: primaryIP,
111134 ptrRecord: snapshot.ptrRecord,
112135 dnssecSigned: dnssecSigned(from: snapshot),
136 patternSummary: analysis.dnsPatterns,
113137 error: snapshot.dnsError,
114138 ptrError: snapshot.ptrError
115139 ),
116140 web: WebResultSummary(
117141 tls: snapshot.sslInfo,
118142 tlsStatus: tlsStatus(from: snapshot),
143 tlsGrade: analysis.tlsAssessment.grade,
144 tlsHighlights: analysis.tlsAssessment.highlights,
119145 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
120146 hstsPreloaded: snapshot.hstsPreloaded,
121147 headers: snapshot.httpHeaders,
@@ -133,7 +159,9 @@ struct DomainReportBuilder {
133159 ),
134160 email: EmailSecuritySummary(
135161 records: snapshot.emailSecurity,
136 summary: emailSummary(from: snapshot),
162 grade: analysis.emailAssessment?.grade,
163 reasons: analysis.emailAssessment?.reasons ?? [],
164 summary: emailSummary(from: snapshot, assessment: analysis.emailAssessment),
137165 error: snapshot.emailSecurityError
138166 ),
139167 network: NetworkSummary(
@@ -149,9 +177,10 @@ struct DomainReportBuilder {
149177 portScanError: snapshot.portScanError
150178 ),
151179 subdomains: snapshot.subdomains.map(\.hostname),
152 changeSummary: snapshot.changeSummary ?? previousSnapshot.map {
153 DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp)
154 }
180 subdomainGroups: analysis.subdomainGroups,
181 riskAssessment: analysis.riskAssessment,
182 insights: analysis.insights,
183 changeSummary: changeSummary
155184 )
156185 }
157186
@@ -177,12 +206,13 @@ struct DomainReportBuilder {
177206 return "unavailable"
178207 }
179208
180 private func emailSummary(from snapshot: LookupSnapshot) -> String {
209 private func emailSummary(from snapshot: LookupSnapshot, assessment: EmailSecurityAssessment?) -> String {
181210 guard let emailSecurity = snapshot.emailSecurity else {
182211 return snapshot.emailSecurityError ?? "Unavailable"
183212 }
184213
185214 return [
215 "Grade \(assessment?.grade.rawValue ?? "?")",
186216 "SPF \(emailSecurity.spf.found ? "Yes" : "No")",
187217 "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")",
188218 "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")",
DomainReportExporter.swift +75 −3
@@ -60,6 +60,7 @@ enum DomainReportExporter {
6060 appendSection("Summary", to: &lines) {
6161 [
6262 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
63 "Risk Score: \(report.riskAssessment.score) (\(report.riskAssessment.level.title))",
6364 "TLS Status: \(report.web.tlsStatus)",
6465 "HTTP: \(httpSummary(for: report))",
6566 "Email: \(report.email.summary)",
@@ -67,6 +68,26 @@ enum DomainReportExporter {
6768 ]
6869 }
6970
71 appendSection("Risk", to: &lines) {
72 var values = [
73 "Score: \(report.riskAssessment.score)",
74 "Level: \(report.riskAssessment.level.title)"
75 ]
76 if report.riskAssessment.factors.isEmpty {
77 values.append("Factors: None")
78 } else {
79 values.append("Factors:")
80 for factor in report.riskAssessment.factors {
81 values.append(" [\(factor.impact.rawValue)] \(factor.description)")
82 }
83 }
84 return values
85 }
86
87 appendSection("Insights", to: &lines) {
88 report.insights.isEmpty ? ["No deterministic insights triggered"] : report.insights.map { "- \($0)" }
89 }
90
7091 appendSection("Ownership", to: &lines) {
7192 var ownershipLines = [
7293 "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
@@ -93,7 +114,8 @@ enum DomainReportExporter {
93114 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
94115 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
95116 "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
96 "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))"
117 "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))",
118 "Patterns: \(report.dns.patternSummary.patterns.joined(separator: " | ").nilIfEmpty ?? "None")"
97119 ]
98120 if let provenance = report.sectionProvenance[.dns] {
99121 dnsLines.append("Provenance: \(provenanceLabel(provenance))")
@@ -117,6 +139,8 @@ enum DomainReportExporter {
117139 appendSection("Web", to: &lines) {
118140 var webLines = [
119141 "TLS Status: \(report.web.tlsStatus)",
142 "TLS Grade: \(report.web.tlsGrade.rawValue)",
143 "TLS Highlights: \(report.web.tlsHighlights.joined(separator: " | "))",
120144 "Certificate Warning: \(report.web.certificateWarningLevel.title)",
121145 "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
122146 "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
@@ -161,6 +185,12 @@ enum DomainReportExporter {
161185
162186 appendSection("Email", to: &lines) {
163187 var emailLines = [report.email.summary]
188 if let grade = report.email.grade {
189 emailLines.append("Grade: \(grade.rawValue)")
190 }
191 if !report.email.reasons.isEmpty {
192 emailLines.append("Why: \(report.email.reasons.joined(separator: " | "))")
193 }
164194 emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")")
165195 if let provenance = report.sectionProvenance[.emailSecurity] {
166196 emailLines.append("Provenance: \(provenanceLabel(provenance))")
@@ -217,6 +247,9 @@ enum DomainReportExporter {
217247 values.append("None")
218248 return values
219249 }
250 if !report.subdomainGroups.isEmpty {
251 values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
252 }
220253 values.append(contentsOf: report.subdomains.map { "- \($0)" })
221254 return values
222255 }
@@ -229,9 +262,16 @@ enum DomainReportExporter {
229262 var values = [
230263 "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
231264 "Severity: \(changeSummary.severity.title)",
265 "Impact: \(changeSummary.impactClassification.title)",
232266 "Inferred Summary: \(changeSummary.message)",
233267 "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
234268 ]
269 if let riskScoreDelta = changeSummary.riskScoreDelta {
270 values.append("Risk Delta: \(riskScoreDelta >= 0 ? "+" : "")\(riskScoreDelta)")
271 }
272 if !changeSummary.insights.isEmpty {
273 values.append("Insights: \(changeSummary.insights.joined(separator: " | "))")
274 }
235275 if !changeSummary.observedFacts.isEmpty {
236276 values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))")
237277 }
@@ -262,6 +302,11 @@ enum DomainReportExporter {
262302 }
263303
264304 static func csv(for reports: [DomainReport]) -> String {
305 csv(for: reports, workflowInsights: [])
306 }
307
308 static func csv(for reports: [DomainReport], workflowInsights: [WorkflowInsight]) -> String {
309 let workflowInsightSummary = workflowInsights.map(\.description).joined(separator: " | ")
265310 let headers = [
266311 "domain",
267312 "timestamp",
@@ -269,6 +314,10 @@ enum DomainReportExporter {
269314 "result_source",
270315 "resolver",
271316 "availability",
317 "risk_score",
318 "risk_level",
319 "risk_factors",
320 "insights",
272321 "availability_confidence",
273322 "registrar",
274323 "ownership_confidence",
@@ -277,15 +326,20 @@ enum DomainReportExporter {
277326 "primary_ip",
278327 "ptr_record",
279328 "dnssec_signed",
329 "dns_patterns",
280330 "tls_status",
331 "tls_grade",
332 "tls_highlights",
281333 "certificate_warning_level",
282334 "hsts_preloaded",
283335 "http_status",
284336 "http_security_grade",
285337 "final_url",
286338 "email_summary",
339 "email_grade",
287340 "email_confidence",
288341 "subdomain_count",
342 "subdomain_groups",
289343 "subdomain_confidence",
290344 "subdomains",
291345 "open_ports",
@@ -295,7 +349,9 @@ enum DomainReportExporter {
295349 "data_sources",
296350 "audit_note",
297351 "partial_snapshot",
298 "change_summary"
352 "change_summary",
353 "change_impact",
354 "workflow_insights"
299355 ]
300356
301357 let rows = reports.map { report in
@@ -307,6 +363,11 @@ enum DomainReportExporter {
307363 let subdomainCount = String(report.subdomains.count)
308364 let subdomains = report.subdomains.joined(separator: " | ")
309365 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
366 let riskFactors = report.riskAssessment.factors.map(\.description).joined(separator: " | ")
367 let insights = report.insights.joined(separator: " | ")
368 let dnsPatterns = report.dns.patternSummary.patterns.joined(separator: " | ")
369 let tlsHighlights = report.web.tlsHighlights.joined(separator: " | ")
370 let subdomainGroups = report.subdomainGroups.map { "\($0.label):\($0.subdomains.count)" }.joined(separator: " | ")
310371
311372 return [
312373 report.domain,
@@ -315,6 +376,10 @@ enum DomainReportExporter {
315376 report.resultSource.rawValue,
316377 report.resolverDisplayName,
317378 availabilityLabel(report.availability),
379 "\(report.riskAssessment.score)",
380 report.riskAssessment.level.rawValue,
381 riskFactors,
382 insights,
318383 report.availabilityConfidence?.rawValue ?? "",
319384 report.ownership?.registrar ?? "",
320385 report.ownershipConfidence?.rawValue ?? "",
@@ -323,15 +388,20 @@ enum DomainReportExporter {
323388 report.dns.primaryIP ?? "",
324389 report.dns.ptrRecord ?? "",
325390 dnssecSigned,
391 dnsPatterns,
326392 report.web.tlsStatus,
393 report.web.tlsGrade.rawValue,
394 tlsHighlights,
327395 report.web.certificateWarningLevel.rawValue,
328396 hstsPreloaded,
329397 httpStatus,
330398 report.web.securityGrade ?? "",
331399 report.web.finalURL ?? "",
332400 report.email.summary,
401 report.email.grade?.rawValue ?? "",
333402 report.emailConfidence?.rawValue ?? "",
334403 subdomainCount,
404 subdomainGroups,
335405 report.subdomainConfidence?.rawValue ?? "",
336406 subdomains,
337407 openPorts,
@@ -341,7 +411,9 @@ enum DomainReportExporter {
341411 report.dataSources.joined(separator: " | "),
342412 report.auditNote ?? "",
343413 report.isPartialSnapshot ? "true" : "false",
344 report.changeSummary?.message ?? ""
414 report.changeSummary?.message ?? "",
415 report.changeSummary?.impactClassification.rawValue ?? "",
416 workflowInsightSummary
345417 ]
346418 }
347419