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 @@
354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
356 CODE_SIGN_STYLE = Automatic; 356 CODE_SIGN_STYLE = Automatic;
357 CURRENT_PROJECT_VERSION = 21; 357 CURRENT_PROJECT_VERSION = 22;
358 DEVELOPMENT_TEAM = ZCNAX3VL9D; 358 DEVELOPMENT_TEAM = ZCNAX3VL9D;
359 ENABLE_PREVIEWS = YES; 359 ENABLE_PREVIEWS = YES;
360 GENERATE_INFOPLIST_FILE = YES; 360 GENERATE_INFOPLIST_FILE = YES;
@@ -371,7 +371,7 @@
371 "$(inherited)", 371 "$(inherited)",
372 "@executable_path/Frameworks", 372 "@executable_path/Frameworks",
373 ); 373 );
374 MARKETING_VERSION = 2.8.0; 374 MARKETING_VERSION = 2.9.0;
375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
376 PRODUCT_NAME = "$(TARGET_NAME)"; 376 PRODUCT_NAME = "$(TARGET_NAME)";
377 STRING_CATALOG_GENERATE_SYMBOLS = YES; 377 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -390,7 +390,7 @@
390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
392 CODE_SIGN_STYLE = Automatic; 392 CODE_SIGN_STYLE = Automatic;
393 CURRENT_PROJECT_VERSION = 21; 393 CURRENT_PROJECT_VERSION = 22;
394 DEVELOPMENT_TEAM = ZCNAX3VL9D; 394 DEVELOPMENT_TEAM = ZCNAX3VL9D;
395 ENABLE_PREVIEWS = YES; 395 ENABLE_PREVIEWS = YES;
396 GENERATE_INFOPLIST_FILE = YES; 396 GENERATE_INFOPLIST_FILE = YES;
@@ -407,7 +407,7 @@
407 "$(inherited)", 407 "$(inherited)",
408 "@executable_path/Frameworks", 408 "@executable_path/Frameworks",
409 ); 409 );
410 MARKETING_VERSION = 2.8.0; 410 MARKETING_VERSION = 2.9.0;
411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
412 PRODUCT_NAME = "$(TARGET_NAME)"; 412 PRODUCT_NAME = "$(TARGET_NAME)";
413 STRING_CATALOG_GENERATE_SYMBOLS = YES; 413 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 static var current: String { 4 static var current: String {
5 "2.8.0" 5 "2.9.0"
6 } 6 }
7} 7}
DomainDig/BatchResultsView.swift +12 −3
@@ -73,6 +73,9 @@ struct BatchResultRowView: View {
73 73
74 HStack(spacing: 10) { 74 HStack(spacing: 10) {
75 AppStatusBadgeView(model: AppStatusFactory.availability(result.availability)) 75 AppStatusBadgeView(model: AppStatusFactory.availability(result.availability))
76 if let riskScore = result.riskScore, let riskLevel = result.riskLevel {
77 Text("Risk \(riskScore) \(riskLevel.title)")
78 }
76 Text(result.primaryIP ?? "No IP") 79 Text(result.primaryIP ?? "No IP")
77 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened)) 80 Text(result.timestamp.formatted(date: .abbreviated, time: .shortened))
78 } 81 }
@@ -85,6 +88,12 @@ struct BatchResultRowView: View {
85 .foregroundStyle(.secondary) 88 .foregroundStyle(.secondary)
86 } 89 }
87 90
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
88 if let errorMessage = result.errorMessage { 97 if let errorMessage = result.errorMessage {
89 Text(errorMessage) 98 Text(errorMessage)
90 .font(appDensity.font(.caption2)) 99 .font(appDensity.font(.caption2))
@@ -114,10 +123,10 @@ struct BatchResultRowView: View {
114 case .running: 123 case .running:
115 return .init(title: "Running", systemImage: "arrow.clockwise", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16)) 124 return .init(title: "Running", systemImage: "arrow.clockwise", foregroundColor: .cyan, backgroundColor: .cyan.opacity(0.16))
116 case .completed: 125 case .completed:
117 if result.changeSeverity == .high || result.certificateWarningLevel == .critical { 126 if result.changeClassification == .critical || result.certificateWarningLevel == .critical || result.riskLevel == .high {
118 return .init(title: "High", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16)) 127 return .init(title: "Critical", systemImage: "exclamationmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
119 } 128 }
120 if result.changeSeverity == .medium || result.certificateWarningLevel == .warning { 129 if result.changeClassification == .warning || result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
121 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16)) 130 return .init(title: "Warning", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
122 } 131 }
123 if result.quickStatus == "Changed" { 132 if result.quickStatus == "Changed" {
DomainDig/BatchSweepSummaryView.swift +1 −6
@@ -11,12 +11,7 @@ struct BatchSweepSummaryView: View {
11 return summary.results 11 return summary.results
12 } 12 }
13 13
14 return summary.results.filter { 14 return summary.results.filter(\.hasMeaningfulChange)
15 $0.quickStatus == "Changed" ||
16 $0.quickStatus == "High" ||
17 $0.certificateWarningLevel != .none ||
18 $0.status == .failed
19 }
20 } 15 }
21 16
22 var body: some View { 17 var body: some View {
DomainDig/ContentView.swift +202 −2
@@ -58,6 +58,12 @@ struct ContentView: View {
58 if viewModel.resultsLoaded { 58 if viewModel.resultsLoaded {
59 SummaryView(fields: viewModel.summaryFields) 59 SummaryView(fields: viewModel.summaryFields)
60 .padding(.top, appDensity.metrics.cardSpacing) 60 .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 }
61 if let changeSummary = viewModel.currentChangeSummary { 67 if let changeSummary = viewModel.currentChangeSummary {
62 DomainChangeSummaryView(summary: changeSummary) 68 DomainChangeSummaryView(summary: changeSummary)
63 .padding(.top, appDensity.metrics.cardSpacing) 69 .padding(.top, appDensity.metrics.cardSpacing)
@@ -114,6 +120,7 @@ struct ContentView: View {
114 SubdomainsSectionView( 120 SubdomainsSectionView(
115 isCollapsed: sectionCollapsedBinding(.subdomains), 121 isCollapsed: sectionCollapsedBinding(.subdomains),
116 rows: viewModel.subdomainRows, 122 rows: viewModel.subdomainRows,
123 groups: viewModel.currentSubdomainGroups,
117 loading: viewModel.subdomainsLoading, 124 loading: viewModel.subdomainsLoading,
118 error: viewModel.subdomainsError, 125 error: viewModel.subdomainsError,
119 provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains], 126 provenance: viewModel.currentSnapshot.provenanceBySection[.subdomains],
@@ -133,6 +140,7 @@ struct ContentView: View {
133 DNSSectionView( 140 DNSSectionView(
134 isCollapsed: sectionCollapsedBinding(.dns), 141 isCollapsed: sectionCollapsedBinding(.dns),
135 dnssecLabel: viewModel.dnssecLabel, 142 dnssecLabel: viewModel.dnssecLabel,
143 patternSummary: viewModel.currentDNSPatterns,
136 sections: viewModel.dnsRows, 144 sections: viewModel.dnsRows,
137 ptrMessage: viewModel.ptrMessage, 145 ptrMessage: viewModel.ptrMessage,
138 loading: viewModel.dnsLoading || viewModel.ptrLoading, 146 loading: viewModel.dnsLoading || viewModel.ptrLoading,
@@ -145,6 +153,7 @@ struct ContentView: View {
145 isCollapsed: sectionCollapsedBinding(.web), 153 isCollapsed: sectionCollapsedBinding(.web),
146 certificateRows: viewModel.webCertificateRows, 154 certificateRows: viewModel.webCertificateRows,
147 sslInfo: viewModel.sslInfo, 155 sslInfo: viewModel.sslInfo,
156 tlsSummary: viewModel.currentTLSSummary,
148 sslLoading: viewModel.sslLoading || viewModel.hstsLoading, 157 sslLoading: viewModel.sslLoading || viewModel.hstsLoading,
149 sslError: viewModel.sslError, 158 sslError: viewModel.sslError,
150 tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl], 159 tlsProvenance: viewModel.currentSnapshot.provenanceBySection[.ssl],
@@ -163,6 +172,7 @@ struct ContentView: View {
163 EmailSectionView( 172 EmailSectionView(
164 isCollapsed: sectionCollapsedBinding(.email), 173 isCollapsed: sectionCollapsedBinding(.email),
165 rows: viewModel.emailRows, 174 rows: viewModel.emailRows,
175 assessment: viewModel.currentEmailAssessment,
166 loading: viewModel.emailSecurityLoading, 176 loading: viewModel.emailSecurityLoading,
167 provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity], 177 provenance: viewModel.currentSnapshot.provenanceBySection[.emailSecurity],
168 confidence: viewModel.currentSnapshot.emailSecurityConfidence, 178 confidence: viewModel.currentSnapshot.emailSecurityConfidence,
@@ -654,6 +664,107 @@ struct SummaryView: View {
654 } 664 }
655} 665}
656 666
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
657struct StickyLookupSummaryView: View { 768struct StickyLookupSummaryView: View {
658 @Environment(\.appDensity) private var appDensity 769 @Environment(\.appDensity) private var appDensity
659 770
@@ -791,6 +902,13 @@ struct DomainChangeSummaryView: View {
791 .padding(.vertical, 4) 902 .padding(.vertical, 4)
792 .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16)) 903 .background((summary.hasChanges ? severityColor(summary.severity) : .secondary).opacity(0.16))
793 .clipShape(Capsule()) 904 .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())
794 Text(summary.generatedAt, style: .time) 912 Text(summary.generatedAt, style: .time)
795 .font(appDensity.font(.caption2)) 913 .font(appDensity.font(.caption2))
796 .foregroundStyle(.secondary) 914 .foregroundStyle(.secondary)
@@ -822,6 +940,12 @@ struct DomainChangeSummaryView: View {
822 } 940 }
823 } 941 }
824 942
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
825 if let contextNote = summary.contextNote { 949 if let contextNote = summary.contextNote {
826 Text(contextNote) 950 Text(contextNote)
827 .font(appDensity.font(.caption2)) 951 .font(appDensity.font(.caption2))
@@ -846,6 +970,17 @@ struct DomainChangeSummaryView: View {
846 return .red 970 return .red
847 } 971 }
848 } 972 }
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 }
849} 984}
850 985
851struct DomainDiffView: View { 986struct DomainDiffView: View {
@@ -1215,6 +1350,7 @@ struct SubdomainsSectionView: View {
1215 @Environment(\.appDensity) private var appDensity 1350 @Environment(\.appDensity) private var appDensity
1216 @Binding var isCollapsed: Bool 1351 @Binding var isCollapsed: Bool
1217 let rows: [SubdomainRowViewData] 1352 let rows: [SubdomainRowViewData]
1353 let groups: [SubdomainGroup]
1218 let loading: Bool 1354 let loading: Bool
1219 let error: String? 1355 let error: String?
1220 let provenance: SectionProvenance? 1356 let provenance: SectionProvenance?
@@ -1235,6 +1371,22 @@ struct SubdomainsSectionView: View {
1235 .padding(.top, 4) 1371 .padding(.top, 4)
1236 } 1372 }
1237 } else { 1373 } 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 }
1238 ForEach(rows) { row in 1390 ForEach(rows) { row in
1239 HStack(spacing: 8) { 1391 HStack(spacing: 8) {
1240 Text(row.hostname) 1392 Text(row.hostname)
@@ -1266,6 +1418,7 @@ struct SubdomainsSectionView: View {
1266struct DNSSectionView: View { 1418struct DNSSectionView: View {
1267 @Binding var isCollapsed: Bool 1419 @Binding var isCollapsed: Bool
1268 let dnssecLabel: String? 1420 let dnssecLabel: String?
1421 let patternSummary: DNSPatternSummary?
1269 let sections: [DNSRecordSectionViewData] 1422 let sections: [DNSRecordSectionViewData]
1270 let ptrMessage: SectionMessageViewData? 1423 let ptrMessage: SectionMessageViewData?
1271 let loading: Bool 1424 let loading: Bool
@@ -1283,6 +1436,16 @@ struct DNSSectionView: View {
1283 if dnsProvenance != nil { 1436 if dnsProvenance != nil {
1284 CardView(allowsHorizontalScroll: false) { 1437 CardView(allowsHorizontalScroll: false) {
1285 SectionTrustMetadataView(provenance: dnsProvenance, confidence: nil) 1438 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 }
1286 } 1449 }
1287 } 1450 }
1288 ForEach(sections) { section in 1451 ForEach(sections) { section in
@@ -1332,6 +1495,7 @@ struct WebSectionView: View {
1332 @Binding var isCollapsed: Bool 1495 @Binding var isCollapsed: Bool
1333 let certificateRows: [InfoRowViewData] 1496 let certificateRows: [InfoRowViewData]
1334 let sslInfo: SSLCertificateInfo? 1497 let sslInfo: SSLCertificateInfo?
1498 let tlsSummary: WebResultSummary?
1335 let sslLoading: Bool 1499 let sslLoading: Bool
1336 let sslError: String? 1500 let sslError: String?
1337 let tlsProvenance: SectionProvenance? 1501 let tlsProvenance: SectionProvenance?
@@ -1354,9 +1518,17 @@ struct WebSectionView: View {
1354 .font(appDensity.font(.subheadline, weight: .semibold)) 1518 .font(appDensity.font(.subheadline, weight: .semibold))
1355 .foregroundStyle(.cyan) 1519 .foregroundStyle(.cyan)
1356 Spacer() 1520 Spacer()
1357 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError)) 1521 if !sslLoading {
1522 AppStatusBadgeView(model: AppStatusFactory.tls(sslInfo: sslInfo, error: sslError))
1523 }
1358 } 1524 }
1359 SectionTrustMetadataView(provenance: tlsProvenance, confidence: nil) 1525 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 }
1360 if sslLoading { 1532 if sslLoading {
1361 ProgressView("Checking certificate…") 1533 ProgressView("Checking certificate…")
1362 .appLoadingStyle() 1534 .appLoadingStyle()
@@ -1371,9 +1543,11 @@ struct WebSectionView: View {
1371 .font(appDensity.font(.caption2)) 1543 .font(appDensity.font(.caption2))
1372 .foregroundStyle(.secondary) 1544 .foregroundStyle(.secondary)
1373 ForEach(sslInfo.subjectAltNames, id: \.self) { san in 1545 ForEach(sslInfo.subjectAltNames, id: \.self) { san in
1374 HStack(spacing: 8) { 1546 HStack(alignment: .top, spacing: 8) {
1375 Text(san) 1547 Text(san)
1376 .font(appDensity.font(.caption)) 1548 .font(appDensity.font(.caption))
1549 .lineLimit(nil)
1550 .fixedSize(horizontal: false, vertical: true)
1377 .textSelection(.enabled) 1551 .textSelection(.enabled)
1378 Spacer() 1552 Spacer()
1379 AppCopyButton(value: san, label: "Copy certificate SAN") 1553 AppCopyButton(value: san, label: "Copy certificate SAN")
@@ -1462,12 +1636,24 @@ struct WebSectionView: View {
1462 } 1636 }
1463 } 1637 }
1464 } 1638 }
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 }
1465} 1650}
1466 1651
1467struct EmailSectionView: View { 1652struct EmailSectionView: View {
1468 @Environment(\.appDensity) private var appDensity 1653 @Environment(\.appDensity) private var appDensity
1469 @Binding var isCollapsed: Bool 1654 @Binding var isCollapsed: Bool
1470 let rows: [EmailRowViewData] 1655 let rows: [EmailRowViewData]
1656 let assessment: EmailSecuritySummary?
1471 let loading: Bool 1657 let loading: Bool
1472 let provenance: SectionProvenance? 1658 let provenance: SectionProvenance?
1473 let confidence: ConfidenceLevel? 1659 let confidence: ConfidenceLevel?
@@ -1482,6 +1668,14 @@ struct EmailSectionView: View {
1482 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error)) 1668 AppStatusBadgeView(model: AppStatusFactory.email(nil, error: error))
1483 .opacity(loading ? 0 : 1) 1669 .opacity(loading ? 0 : 1)
1484 } 1670 }
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 }
1485 if loading { 1679 if loading {
1486 ProgressView("Checking email records…") 1680 ProgressView("Checking email records…")
1487 .appLoadingStyle() 1681 .appLoadingStyle()
@@ -1817,6 +2011,8 @@ struct MessageRowView: View {
1817 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") 2011 Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle")
1818 .font(appDensity.font(.caption)) 2012 .font(appDensity.font(.caption))
1819 .foregroundStyle(isError ? .red : .secondary) 2013 .foregroundStyle(isError ? .red : .secondary)
2014 .lineLimit(nil)
2015 .fixedSize(horizontal: false, vertical: true)
1820 } 2016 }
1821} 2017}
1822 2018
@@ -1890,8 +2086,12 @@ struct LabeledValueRow: View {
1890 Text(row.value) 2086 Text(row.value)
1891 .font(appDensity.font(.caption)) 2087 .font(appDensity.font(.caption))
1892 .foregroundStyle(ResultColors.color(for: row.tone)) 2088 .foregroundStyle(ResultColors.color(for: row.tone))
2089 .lineLimit(nil)
2090 .fixedSize(horizontal: false, vertical: true)
1893 .textSelection(.enabled) 2091 .textSelection(.enabled)
1894 } 2092 }
2093 .frame(maxWidth: .infinity, alignment: .leading)
2094 .layoutPriority(1)
1895 Spacer(minLength: 6) 2095 Spacer(minLength: 6)
1896 if !row.value.isEmpty, row.value != "Unavailable" { 2096 if !row.value.isEmpty, row.value != "Unavailable" {
1897 AppCopyButton(value: row.value, label: "Copy \(row.label)") 2097 AppCopyButton(value: row.value, label: "Copy \(row.label)")
DomainDig/DomainDiffService.swift +18 −2
@@ -57,7 +57,9 @@ enum DomainDiffService {
57 static func summary( 57 static func summary(
58 from oldSnapshot: LookupSnapshot, 58 from oldSnapshot: LookupSnapshot,
59 to newSnapshot: LookupSnapshot, 59 to newSnapshot: LookupSnapshot,
60 generatedAt: Date = Date() 60 generatedAt: Date = Date(),
61 riskAssessment: DomainRiskAssessment? = nil,
62 insights: [String]? = nil
61 ) -> DomainChangeSummary { 63 ) -> DomainChangeSummary {
62 let sections = diff(from: oldSnapshot, to: newSnapshot) 64 let sections = diff(from: oldSnapshot, to: newSnapshot)
63 let allChangedItems = sections 65 let allChangedItems = sections
@@ -70,16 +72,30 @@ enum DomainDiffService {
70 let observedFacts = observedFacts(from: allChangedItems) 72 let observedFacts = observedFacts(from: allChangedItems)
71 let inferredConclusions = highlights.isEmpty ? [] : [message] 73 let inferredConclusions = highlights.isEmpty ? [] : [message]
72 let contextNote = comparisonContextNote(from: oldSnapshot, to: newSnapshot) 74 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 )
73 85
74 return DomainChangeSummary( 86 return DomainChangeSummary(
75 hasChanges: !allChangedItems.isEmpty, 87 hasChanges: !allChangedItems.isEmpty,
76 changedSections: highlights, 88 changedSections: highlights,
77 message: message, 89 message: message,
78 severity: severity, 90 severity: severity,
91 impactClassification: impactClassification,
79 generatedAt: generatedAt, 92 generatedAt: generatedAt,
80 observedFacts: observedFacts, 93 observedFacts: observedFacts,
81 inferredConclusions: inferredConclusions, 94 inferredConclusions: inferredConclusions,
82 contextNote: contextNote 95 contextNote: contextNote,
96 riskAssessment: currentRiskAssessment,
97 insights: currentInsights,
98 riskScoreDelta: riskScoreDelta
83 ) 99 )
84 } 100 }
85 101
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 {
96 let snapshot: LookupSnapshot 96 let snapshot: LookupSnapshot
97} 97}
98 98
99private struct WorkflowExportPayload: Codable {
100 let workflowName: String
101 let generatedAt: Date
102 let workflowInsights: [WorkflowInsight]
103 let reports: [DomainReport]
104}
105
99@MainActor 106@MainActor
100@Observable 107@Observable
101final class DomainViewModel { 108final class DomainViewModel {
@@ -194,6 +201,7 @@ final class DomainViewModel {
194 private(set) var currentStatusMessage: String? 201 private(set) var currentStatusMessage: String?
195 private(set) var currentSnapshotTimestamp = Date() 202 private(set) var currentSnapshotTimestamp = Date()
196 private(set) var currentHistoryEntryID: UUID? 203 private(set) var currentHistoryEntryID: UUID?
204 private(set) var currentReport: DomainReport?
197 205
198 private static let recentSearchesKey = "recentSearches" 206 private static let recentSearchesKey = "recentSearches"
199 private static let maxRecent = 20 207 private static let maxRecent = 20
@@ -448,16 +456,28 @@ final class DomainViewModel {
448 return history.first(where: { $0.id == currentHistoryEntryID }) 456 return history.first(where: { $0.id == currentHistoryEntryID })
449 } 457 }
450 458
451 var currentReport: DomainReport? { 459 var currentRiskAssessment: DomainRiskAssessment? {
452 guard !searchedDomain.isEmpty else { return nil } 460 currentReport?.riskAssessment
453 return reportBuilder.build( 461 }
454 from: currentSnapshot, 462
455 previousSnapshot: previousSnapshot( 463 var currentInsights: [String] {
456 for: searchedDomain, 464 currentReport?.insights ?? []
457 trackedDomainID: currentTrackedDomain?.id, 465 }
458 replacingLatest: false 466
459 ) 467 var currentSubdomainGroups: [SubdomainGroup] {
460 ) 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
461 } 481 }
462 482
463 var summaryFields: [SummaryFieldViewData] { 483 var summaryFields: [SummaryFieldViewData] {
@@ -684,6 +704,7 @@ final class DomainViewModel {
684 currentDiffSections = [] 704 currentDiffSections = []
685 currentChangeSummary = nil 705 currentChangeSummary = nil
686 ownershipDiff = [] 706 ownershipDiff = []
707 currentReport = nil
687 refreshingTrackedDomainID = nil 708 refreshingTrackedDomainID = nil
688 clearBatchState() 709 clearBatchState()
689 clearLookupState() 710 clearLookupState()
@@ -728,7 +749,10 @@ final class DomainViewModel {
728 quickStatus: "Cancelled", 749 quickStatus: "Cancelled",
729 summaryMessage: batchResults[index].summaryMessage, 750 summaryMessage: batchResults[index].summaryMessage,
730 changeSeverity: batchResults[index].changeSeverity, 751 changeSeverity: batchResults[index].changeSeverity,
752 changeClassification: batchResults[index].changeClassification,
731 certificateWarningLevel: batchResults[index].certificateWarningLevel, 753 certificateWarningLevel: batchResults[index].certificateWarningLevel,
754 riskScore: batchResults[index].riskScore,
755 riskLevel: batchResults[index].riskLevel,
732 timestamp: Date(), 756 timestamp: Date(),
733 status: .failed, 757 status: .failed,
734 errorMessage: "Lookup cancelled" 758 errorMessage: "Lookup cancelled"
@@ -918,22 +942,36 @@ final class DomainViewModel {
918 } 942 }
919 943
920 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String { 944 func exportWorkflowText(summary: WorkflowRunSummary, changedOnly: Bool) -> String {
921 DomainReportExporter.batchText( 945 let reports = workflowReports(from: summary, changedOnly: changedOnly)
922 for: workflowReports(from: summary, changedOnly: changedOnly), 946 let base = DomainReportExporter.batchText(
947 for: reports,
923 title: "\(summary.workflowName) Workflow Export" 948 title: "\(summary.workflowName) Workflow Export"
924 ) 949 )
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")
925 } 955 }
926 956
927 func exportWorkflowCSV(summary: WorkflowRunSummary, changedOnly: Bool) -> String { 957 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 )
929 } 962 }
930 963
931 func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? { 964 func exportWorkflowJSONData(summary: WorkflowRunSummary, changedOnly: Bool) -> Data? {
932 try? DomainReportExporter.data( 965 let payload = WorkflowExportPayload(
933 for: workflowReports(from: summary, changedOnly: changedOnly), 966 workflowName: summary.workflowName,
934 format: .json, 967 generatedAt: summary.generatedAt,
935 title: "\(summary.workflowName) Workflow Export" 968 workflowInsights: summary.workflowInsights,
969 reports: workflowReports(from: summary, changedOnly: changedOnly)
936 ) 970 )
971 let encoder = JSONEncoder()
972 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
973 encoder.dateEncodingStrategy = .iso8601
974 return try? encoder.encode(payload)
937 } 975 }
938 976
939 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { 977 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
@@ -963,9 +1001,17 @@ final class DomainViewModel {
963 currentResultSource = snapshot.resultSource 1001 currentResultSource = snapshot.resultSource
964 currentCachedSections = snapshot.cachedSections 1002 currentCachedSections = snapshot.cachedSections
965 currentStatusMessage = snapshot.statusMessage 1003 currentStatusMessage = snapshot.statusMessage
966 currentChangeSummary = snapshot.changeSummary
967 currentDiffSections = [] 1004 currentDiffSections = []
968 ownershipDiff = [] 1005 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
969 1015
970 dnsSections = snapshot.dnsSections 1016 dnsSections = snapshot.dnsSections
971 dnsError = snapshot.dnsError 1017 dnsError = snapshot.dnsError
@@ -1406,8 +1452,15 @@ final class DomainViewModel {
1406 private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? { 1452 private func saveHistoryEntry(from snapshot: LookupSnapshot, replaceLatest: Bool, updateCurrentState: Bool) -> HistoryEntry? {
1407 let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id 1453 let trackedDomainID = snapshot.trackedDomainID ?? trackedDomain(for: snapshot.domain)?.id
1408 let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest) 1454 let previousSnapshot = previousSnapshot(for: snapshot.domain, trackedDomainID: trackedDomainID, replacingLatest: replaceLatest)
1455 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
1409 let changeSummary = previousSnapshot.map { 1456 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 )
1411 } 1464 }
1412 let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [] 1465 let diffSections = previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? []
1413 1466
@@ -1415,6 +1468,7 @@ final class DomainViewModel {
1415 currentChangeSummary = changeSummary 1468 currentChangeSummary = changeSummary
1416 currentDiffSections = diffSections 1469 currentDiffSections = diffSections
1417 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? [] 1470 ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
1471 currentReport = reportBuilder.build(from: snapshot, previousSnapshot: previousSnapshot)
1418 } 1472 }
1419 1473
1420 let entry = HistoryEntry( 1474 let entry = HistoryEntry(
@@ -1664,6 +1718,7 @@ final class DomainViewModel {
1664 currentStatusMessage = nil 1718 currentStatusMessage = nil
1665 currentDiffSections = [] 1719 currentDiffSections = []
1666 currentChangeSummary = nil 1720 currentChangeSummary = nil
1721 currentReport = nil
1667 ownershipDiff = [] 1722 ownershipDiff = []
1668 clearLookupState() 1723 clearLookupState()
1669 setAllLoadingStates(true) 1724 setAllLoadingStates(true)
@@ -1807,11 +1862,14 @@ final class DomainViewModel {
1807 } 1862 }
1808 } 1863 }
1809 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) 1864 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
1865 let riskAssessment = DomainInsightEngine.analyze(snapshot: payload.snapshot).riskAssessment
1810 let quickStatus: String 1866 let quickStatus: String
1811 if entry?.changeSummary?.hasChanges == true { 1867 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")
1813 } else if certificateWarningLevel != .none { 1869 } else if certificateWarningLevel != .none {
1814 quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning" 1870 quickStatus = certificateWarningLevel == .critical ? "Critical" : "Warning"
1871 } else if riskAssessment.level == .high {
1872 quickStatus = "High"
1815 } else { 1873 } else {
1816 quickStatus = "Unchanged" 1874 quickStatus = "Unchanged"
1817 } 1875 }
@@ -1834,9 +1892,14 @@ final class DomainViewModel {
1834 refreshingTrackedDomainID = nil 1892 refreshingTrackedDomainID = nil
1835 batchTask = nil 1893 batchTask = nil
1836 1894
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
1838 let unchangedCount = batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count 1896 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
1840 1903
1841 let summary = BatchSweepSummary( 1904 let summary = BatchSweepSummary(
1842 source: source, 1905 source: source,
@@ -1855,6 +1918,10 @@ final class DomainViewModel {
1855 latestBatchSweepSummary = summary 1918 latestBatchSweepSummary = summary
1856 1919
1857 if source == .workflow, let activeWorkflowRunID, let activeWorkflowRunName { 1920 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 }
1858 latestWorkflowRunSummary = WorkflowRunSummary( 1925 latestWorkflowRunSummary = WorkflowRunSummary(
1859 workflowID: activeWorkflowRunID, 1926 workflowID: activeWorkflowRunID,
1860 workflowName: activeWorkflowRunName, 1927 workflowName: activeWorkflowRunName,
@@ -1863,6 +1930,7 @@ final class DomainViewModel {
1863 unchangedDomains: unchangedCount, 1930 unchangedDomains: unchangedCount,
1864 warningDomains: warningCount, 1931 warningDomains: warningCount,
1865 results: summary.results, 1932 results: summary.results,
1933 workflowInsights: DomainInsightEngine.workflowInsights(for: workflowReports),
1866 generatedAt: summary.generatedAt 1934 generatedAt: summary.generatedAt
1867 ) 1935 )
1868 } 1936 }
@@ -1896,7 +1964,10 @@ final class DomainViewModel {
1896 quickStatus: quickStatus, 1964 quickStatus: quickStatus,
1897 summaryMessage: entry?.changeSummary?.message, 1965 summaryMessage: entry?.changeSummary?.message,
1898 changeSeverity: entry?.changeSummary?.severity, 1966 changeSeverity: entry?.changeSummary?.severity,
1967 changeClassification: entry?.changeSummary?.impactClassification,
1899 certificateWarningLevel: entry.map { DomainDiffService.certificateWarningLevel(for: $0.snapshot) } ?? batchResults[index].certificateWarningLevel, 1968 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 },
1900 timestamp: entry?.timestamp ?? Date(), 1971 timestamp: entry?.timestamp ?? Date(),
1901 status: status, 1972 status: status,
1902 errorMessage: errorMessage 1973 errorMessage: errorMessage
@@ -1956,6 +2027,7 @@ final class DomainViewModel {
1956 currentResultSource = .live 2027 currentResultSource = .live
1957 currentCachedSections = [] 2028 currentCachedSections = []
1958 currentStatusMessage = nil 2029 currentStatusMessage = nil
2030 currentReport = nil
1959 } 2031 }
1960 2032
1961 private func setAllLoadingStates(_ loading: Bool) { 2033 private func setAllLoadingStates(_ loading: Bool) {
DomainDig/HistoryView.swift +18 −2
@@ -167,12 +167,24 @@ struct HistoryDetailView: View {
167 entry.snapshot 167 entry.snapshot
168 } 168 }
169 169
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
170 var body: some View { 178 var body: some View {
171 ScrollView(.vertical) { 179 ScrollView(.vertical) {
172 VStack(alignment: .leading, spacing: 0) { 180 VStack(alignment: .leading, spacing: 0) {
173 snapshotBanner 181 snapshotBanner
174 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) 182 SummaryView(fields: DomainViewModel.summaryFields(from: snapshot))
175 .padding(.top, 8) 183 .padding(.top, 8)
184 RiskSummaryCardView(report: report)
185 .padding(.top, 8)
186 InsightsSummaryCardView(insights: report.insights)
187 .padding(.top, 8)
176 DomainSectionView( 188 DomainSectionView(
177 isCollapsed: .constant(false), 189 isCollapsed: .constant(false),
178 rows: DomainViewModel.domainRows(from: snapshot), 190 rows: DomainViewModel.domainRows(from: snapshot),
@@ -183,14 +195,14 @@ struct HistoryDetailView: View {
183 provenance: snapshot.provenanceBySection[.availability], 195 provenance: snapshot.provenanceBySection[.availability],
184 confidence: snapshot.availabilityConfidence, 196 confidence: snapshot.availabilityConfidence,
185 snapshotNote: entry.note, 197 snapshotNote: entry.note,
186 trackedDomain: viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }), 198 trackedDomain: trackedDomain,
187 workflows: viewModel.workflowsContaining(domain: entry.domain), 199 workflows: viewModel.workflowsContaining(domain: entry.domain),
188 trackingLimitMessage: nil, 200 trackingLimitMessage: nil,
189 onTrack: { 201 onTrack: {
190 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status) 202 _ = viewModel.trackDomain(domain: entry.domain, availabilityStatus: entry.availabilityResult?.status)
191 }, 203 },
192 onTogglePinned: { 204 onTogglePinned: {
193 guard let trackedDomain = viewModel.trackedDomains.first(where: { $0.domain.lowercased() == entry.domain.lowercased() }) else { return } 205 guard let trackedDomain else { return }
194 viewModel.togglePinned(for: trackedDomain) 206 viewModel.togglePinned(for: trackedDomain)
195 }, 207 },
196 onEditNote: nil, 208 onEditNote: nil,
@@ -212,6 +224,7 @@ struct HistoryDetailView: View {
212 SubdomainsSectionView( 224 SubdomainsSectionView(
213 isCollapsed: .constant(false), 225 isCollapsed: .constant(false),
214 rows: DomainViewModel.subdomainRows(from: snapshot), 226 rows: DomainViewModel.subdomainRows(from: snapshot),
227 groups: report.subdomainGroups,
215 loading: false, 228 loading: false,
216 error: snapshot.subdomainsError, 229 error: snapshot.subdomainsError,
217 provenance: snapshot.provenanceBySection[.subdomains], 230 provenance: snapshot.provenanceBySection[.subdomains],
@@ -235,6 +248,7 @@ struct HistoryDetailView: View {
235 DNSSectionView( 248 DNSSectionView(
236 isCollapsed: .constant(false), 249 isCollapsed: .constant(false),
237 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), 250 dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot),
251 patternSummary: report.dns.patternSummary,
238 sections: DomainViewModel.dnsRows(from: snapshot), 252 sections: DomainViewModel.dnsRows(from: snapshot),
239 ptrMessage: DomainViewModel.ptrMessage(from: snapshot), 253 ptrMessage: DomainViewModel.ptrMessage(from: snapshot),
240 loading: false, 254 loading: false,
@@ -247,6 +261,7 @@ struct HistoryDetailView: View {
247 isCollapsed: .constant(false), 261 isCollapsed: .constant(false),
248 certificateRows: DomainViewModel.webCertificateRows(from: snapshot), 262 certificateRows: DomainViewModel.webCertificateRows(from: snapshot),
249 sslInfo: snapshot.sslInfo, 263 sslInfo: snapshot.sslInfo,
264 tlsSummary: report.web,
250 sslLoading: false, 265 sslLoading: false,
251 sslError: snapshot.sslError, 266 sslError: snapshot.sslError,
252 tlsProvenance: snapshot.provenanceBySection[.ssl], 267 tlsProvenance: snapshot.provenanceBySection[.ssl],
@@ -265,6 +280,7 @@ struct HistoryDetailView: View {
265 EmailSectionView( 280 EmailSectionView(
266 isCollapsed: .constant(false), 281 isCollapsed: .constant(false),
267 rows: DomainViewModel.emailRows(from: snapshot), 282 rows: DomainViewModel.emailRows(from: snapshot),
283 assessment: report.email,
268 loading: false, 284 loading: false,
269 provenance: snapshot.provenanceBySection[.emailSecurity], 285 provenance: snapshot.provenanceBySection[.emailSecurity],
270 confidence: snapshot.emailSecurityConfidence, 286 confidence: snapshot.emailSecurityConfidence,
DomainDig/Models.swift +30 −1
@@ -181,29 +181,41 @@ struct DomainChangeSummary: Codable, Equatable {
181 let changedSections: [String] 181 let changedSections: [String]
182 let message: String 182 let message: String
183 let severity: ChangeSeverity 183 let severity: ChangeSeverity
184 let impactClassification: ChangeImpactClassification
184 let generatedAt: Date 185 let generatedAt: Date
185 let observedFacts: [String] 186 let observedFacts: [String]
186 let inferredConclusions: [String] 187 let inferredConclusions: [String]
187 let contextNote: String? 188 let contextNote: String?
189 let riskAssessment: DomainRiskAssessment?
190 let insights: [String]
191 let riskScoreDelta: Int?
188 192
189 init( 193 init(
190 hasChanges: Bool, 194 hasChanges: Bool,
191 changedSections: [String], 195 changedSections: [String],
192 message: String, 196 message: String,
193 severity: ChangeSeverity, 197 severity: ChangeSeverity,
198 impactClassification: ChangeImpactClassification,
194 generatedAt: Date, 199 generatedAt: Date,
195 observedFacts: [String] = [], 200 observedFacts: [String] = [],
196 inferredConclusions: [String] = [], 201 inferredConclusions: [String] = [],
197 contextNote: String? = nil 202 contextNote: String? = nil,
203 riskAssessment: DomainRiskAssessment? = nil,
204 insights: [String] = [],
205 riskScoreDelta: Int? = nil
198 ) { 206 ) {
199 self.hasChanges = hasChanges 207 self.hasChanges = hasChanges
200 self.changedSections = changedSections 208 self.changedSections = changedSections
201 self.message = message 209 self.message = message
202 self.severity = severity 210 self.severity = severity
211 self.impactClassification = impactClassification
203 self.generatedAt = generatedAt 212 self.generatedAt = generatedAt
204 self.observedFacts = observedFacts 213 self.observedFacts = observedFacts
205 self.inferredConclusions = inferredConclusions 214 self.inferredConclusions = inferredConclusions
206 self.contextNote = contextNote 215 self.contextNote = contextNote
216 self.riskAssessment = riskAssessment
217 self.insights = insights
218 self.riskScoreDelta = riskScoreDelta
207 } 219 }
208 220
209 init(from decoder: Decoder) throws { 221 init(from decoder: Decoder) throws {
@@ -212,11 +224,16 @@ struct DomainChangeSummary: Codable, Equatable {
212 changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? [] 224 changedSections = try container.decodeIfPresent([String].self, forKey: .changedSections) ?? []
213 generatedAt = try container.decode(Date.self, forKey: .generatedAt) 225 generatedAt = try container.decode(Date.self, forKey: .generatedAt)
214 severity = try container.decodeIfPresent(ChangeSeverity.self, forKey: .severity) ?? (hasChanges ? .medium : .low) 226 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))
215 message = try container.decodeIfPresent(String.self, forKey: .message) 229 message = try container.decodeIfPresent(String.self, forKey: .message)
216 ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • ")) 230 ?? (changedSections.isEmpty ? "No meaningful changes" : changedSections.joined(separator: " • "))
217 observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? [] 231 observedFacts = try container.decodeIfPresent([String].self, forKey: .observedFacts) ?? []
218 inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? [] 232 inferredConclusions = try container.decodeIfPresent([String].self, forKey: .inferredConclusions) ?? []
219 contextNote = try container.decodeIfPresent(String.self, forKey: .contextNote) 233 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)
220 } 237 }
221} 238}
222 239
@@ -243,7 +260,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
243 let quickStatus: String 260 let quickStatus: String
244 let summaryMessage: String? 261 let summaryMessage: String?
245 let changeSeverity: ChangeSeverity? 262 let changeSeverity: ChangeSeverity?
263 let changeClassification: ChangeImpactClassification?
246 let certificateWarningLevel: CertificateWarningLevel 264 let certificateWarningLevel: CertificateWarningLevel
265 let riskScore: Int?
266 let riskLevel: RiskLevel?
247 let timestamp: Date 267 let timestamp: Date
248 let status: BatchLookupStatus 268 let status: BatchLookupStatus
249 let errorMessage: String? 269 let errorMessage: String?
@@ -258,7 +278,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
258 quickStatus: String, 278 quickStatus: String,
259 summaryMessage: String? = nil, 279 summaryMessage: String? = nil,
260 changeSeverity: ChangeSeverity? = nil, 280 changeSeverity: ChangeSeverity? = nil,
281 changeClassification: ChangeImpactClassification? = nil,
261 certificateWarningLevel: CertificateWarningLevel = .none, 282 certificateWarningLevel: CertificateWarningLevel = .none,
283 riskScore: Int? = nil,
284 riskLevel: RiskLevel? = nil,
262 timestamp: Date, 285 timestamp: Date,
263 status: BatchLookupStatus, 286 status: BatchLookupStatus,
264 errorMessage: String? = nil 287 errorMessage: String? = nil
@@ -272,7 +295,10 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
272 self.quickStatus = quickStatus 295 self.quickStatus = quickStatus
273 self.summaryMessage = summaryMessage 296 self.summaryMessage = summaryMessage
274 self.changeSeverity = changeSeverity 297 self.changeSeverity = changeSeverity
298 self.changeClassification = changeClassification
275 self.certificateWarningLevel = certificateWarningLevel 299 self.certificateWarningLevel = certificateWarningLevel
300 self.riskScore = riskScore
301 self.riskLevel = riskLevel
276 self.timestamp = timestamp 302 self.timestamp = timestamp
277 self.status = status 303 self.status = status
278 self.errorMessage = errorMessage 304 self.errorMessage = errorMessage
@@ -281,7 +307,9 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
281 var hasMeaningfulChange: Bool { 307 var hasMeaningfulChange: Bool {
282 quickStatus == "Changed" 308 quickStatus == "Changed"
283 || quickStatus == "High" 309 || quickStatus == "High"
310 || quickStatus == "Critical"
284 || certificateWarningLevel != .none 311 || certificateWarningLevel != .none
312 || riskLevel == .high
285 || status == .failed 313 || status == .failed
286 } 314 }
287} 315}
@@ -331,6 +359,7 @@ struct WorkflowRunSummary: Identifiable, Equatable {
331 let unchangedDomains: Int 359 let unchangedDomains: Int
332 let warningDomains: Int 360 let warningDomains: Int
333 let results: [BatchLookupResult] 361 let results: [BatchLookupResult]
362 let workflowInsights: [WorkflowInsight]
334 let generatedAt: Date 363 let generatedAt: Date
335} 364}
336 365
DomainDig/WorkflowsView.swift +20
@@ -215,6 +215,11 @@ struct WorkflowDetailView: View {
215 statRow(label: "Changed", value: "\(latestSummary.changedDomains)") 215 statRow(label: "Changed", value: "\(latestSummary.changedDomains)")
216 statRow(label: "Warnings", value: "\(latestSummary.warningDomains)") 216 statRow(label: "Warnings", value: "\(latestSummary.warningDomains)")
217 statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)") 217 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 }
218 223
219 Button { 224 Button {
220 viewModel.latestWorkflowRunSummary = latestSummary 225 viewModel.latestWorkflowRunSummary = latestSummary
@@ -431,6 +436,21 @@ struct WorkflowRunSummaryView: View {
431 Toggle("Show unchanged domains", isOn: $showAllResults) 436 Toggle("Show unchanged domains", isOn: $showAllResults)
432 } 437 }
433 438
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
434 Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") { 454 Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") {
435 if visibleResults.isEmpty { 455 if visibleResults.isEmpty {
436 Text("No domains with meaningful changes or warnings") 456 Text("No domains with meaningful changes or warnings")
DomainReportBuilder.swift +35 −5
@@ -25,6 +25,9 @@ struct DomainReport: Codable {
25 let email: EmailSecuritySummary 25 let email: EmailSecuritySummary
26 let network: NetworkSummary 26 let network: NetworkSummary
27 let subdomains: [String] 27 let subdomains: [String]
28 let subdomainGroups: [SubdomainGroup]
29 let riskAssessment: DomainRiskAssessment
30 let insights: [String]
28 let changeSummary: DomainChangeSummary? 31 let changeSummary: DomainChangeSummary?
29} 32}
30 33
@@ -36,6 +39,7 @@ struct DNSResultSummary: Codable {
36 let primaryIP: String? 39 let primaryIP: String?
37 let ptrRecord: String? 40 let ptrRecord: String?
38 let dnssecSigned: Bool? 41 let dnssecSigned: Bool?
42 let patternSummary: DNSPatternSummary
39 let error: String? 43 let error: String?
40 let ptrError: String? 44 let ptrError: String?
41} 45}
@@ -43,6 +47,8 @@ struct DNSResultSummary: Codable {
43struct WebResultSummary: Codable { 47struct WebResultSummary: Codable {
44 let tls: SSLCertificateInfo? 48 let tls: SSLCertificateInfo?
45 let tlsStatus: String 49 let tlsStatus: String
50 let tlsGrade: TLSGrade
51 let tlsHighlights: [String]
46 let certificateWarningLevel: CertificateWarningLevel 52 let certificateWarningLevel: CertificateWarningLevel
47 let hstsPreloaded: Bool? 53 let hstsPreloaded: Bool?
48 let headers: [HTTPHeader] 54 let headers: [HTTPHeader]
@@ -61,6 +67,8 @@ struct WebResultSummary: Codable {
61 67
62struct EmailSecuritySummary: Codable { 68struct EmailSecuritySummary: Codable {
63 let records: EmailSecurityResult? 69 let records: EmailSecurityResult?
70 let grade: EmailSecurityGrade?
71 let reasons: [String]
64 let summary: String 72 let summary: String
65 let error: String? 73 let error: String?
66} 74}
@@ -81,6 +89,21 @@ struct NetworkSummary: Codable {
81struct DomainReportBuilder { 89struct DomainReportBuilder {
82 func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport { 90 func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
83 let primaryIP = primaryIPAddress(from: snapshot) 91 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 }
84 107
85 return DomainReport( 108 return DomainReport(
86 domain: snapshot.domain, 109 domain: snapshot.domain,
@@ -110,12 +133,15 @@ struct DomainReportBuilder {
110 primaryIP: primaryIP, 133 primaryIP: primaryIP,
111 ptrRecord: snapshot.ptrRecord, 134 ptrRecord: snapshot.ptrRecord,
112 dnssecSigned: dnssecSigned(from: snapshot), 135 dnssecSigned: dnssecSigned(from: snapshot),
136 patternSummary: analysis.dnsPatterns,
113 error: snapshot.dnsError, 137 error: snapshot.dnsError,
114 ptrError: snapshot.ptrError 138 ptrError: snapshot.ptrError
115 ), 139 ),
116 web: WebResultSummary( 140 web: WebResultSummary(
117 tls: snapshot.sslInfo, 141 tls: snapshot.sslInfo,
118 tlsStatus: tlsStatus(from: snapshot), 142 tlsStatus: tlsStatus(from: snapshot),
143 tlsGrade: analysis.tlsAssessment.grade,
144 tlsHighlights: analysis.tlsAssessment.highlights,
119 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot), 145 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
120 hstsPreloaded: snapshot.hstsPreloaded, 146 hstsPreloaded: snapshot.hstsPreloaded,
121 headers: snapshot.httpHeaders, 147 headers: snapshot.httpHeaders,
@@ -133,7 +159,9 @@ struct DomainReportBuilder {
133 ), 159 ),
134 email: EmailSecuritySummary( 160 email: EmailSecuritySummary(
135 records: snapshot.emailSecurity, 161 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),
137 error: snapshot.emailSecurityError 165 error: snapshot.emailSecurityError
138 ), 166 ),
139 network: NetworkSummary( 167 network: NetworkSummary(
@@ -149,9 +177,10 @@ struct DomainReportBuilder {
149 portScanError: snapshot.portScanError 177 portScanError: snapshot.portScanError
150 ), 178 ),
151 subdomains: snapshot.subdomains.map(\.hostname), 179 subdomains: snapshot.subdomains.map(\.hostname),
152 changeSummary: snapshot.changeSummary ?? previousSnapshot.map { 180 subdomainGroups: analysis.subdomainGroups,
153 DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp) 181 riskAssessment: analysis.riskAssessment,
154 } 182 insights: analysis.insights,
183 changeSummary: changeSummary
155 ) 184 )
156 } 185 }
157 186
@@ -177,12 +206,13 @@ struct DomainReportBuilder {
177 return "unavailable" 206 return "unavailable"
178 } 207 }
179 208
180 private func emailSummary(from snapshot: LookupSnapshot) -> String { 209 private func emailSummary(from snapshot: LookupSnapshot, assessment: EmailSecurityAssessment?) -> String {
181 guard let emailSecurity = snapshot.emailSecurity else { 210 guard let emailSecurity = snapshot.emailSecurity else {
182 return snapshot.emailSecurityError ?? "Unavailable" 211 return snapshot.emailSecurityError ?? "Unavailable"
183 } 212 }
184 213
185 return [ 214 return [
215 "Grade \(assessment?.grade.rawValue ?? "?")",
186 "SPF \(emailSecurity.spf.found ? "Yes" : "No")", 216 "SPF \(emailSecurity.spf.found ? "Yes" : "No")",
187 "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")", 217 "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")",
188 "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")", 218 "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")",
DomainReportExporter.swift +75 −3
@@ -60,6 +60,7 @@ enum DomainReportExporter {
60 appendSection("Summary", to: &lines) { 60 appendSection("Summary", to: &lines) {
61 [ 61 [
62 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", 62 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
63 "Risk Score: \(report.riskAssessment.score) (\(report.riskAssessment.level.title))",
63 "TLS Status: \(report.web.tlsStatus)", 64 "TLS Status: \(report.web.tlsStatus)",
64 "HTTP: \(httpSummary(for: report))", 65 "HTTP: \(httpSummary(for: report))",
65 "Email: \(report.email.summary)", 66 "Email: \(report.email.summary)",
@@ -67,6 +68,26 @@ enum DomainReportExporter {
67 ] 68 ]
68 } 69 }
69 70
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
70 appendSection("Ownership", to: &lines) { 91 appendSection("Ownership", to: &lines) {
71 var ownershipLines = [ 92 var ownershipLines = [
72 "Registrar: \(report.ownership?.registrar ?? "Unavailable")", 93 "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
@@ -93,7 +114,8 @@ enum DomainReportExporter {
93 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))", 114 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
94 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")", 115 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
95 "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")", 116 "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")"
97 ] 119 ]
98 if let provenance = report.sectionProvenance[.dns] { 120 if let provenance = report.sectionProvenance[.dns] {
99 dnsLines.append("Provenance: \(provenanceLabel(provenance))") 121 dnsLines.append("Provenance: \(provenanceLabel(provenance))")
@@ -117,6 +139,8 @@ enum DomainReportExporter {
117 appendSection("Web", to: &lines) { 139 appendSection("Web", to: &lines) {
118 var webLines = [ 140 var webLines = [
119 "TLS Status: \(report.web.tlsStatus)", 141 "TLS Status: \(report.web.tlsStatus)",
142 "TLS Grade: \(report.web.tlsGrade.rawValue)",
143 "TLS Highlights: \(report.web.tlsHighlights.joined(separator: " | "))",
120 "Certificate Warning: \(report.web.certificateWarningLevel.title)", 144 "Certificate Warning: \(report.web.certificateWarningLevel.title)",
121 "Security Grade: \(report.web.securityGrade ?? "Unavailable")", 145 "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
122 "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")", 146 "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
@@ -161,6 +185,12 @@ enum DomainReportExporter {
161 185
162 appendSection("Email", to: &lines) { 186 appendSection("Email", to: &lines) {
163 var emailLines = [report.email.summary] 187 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 }
164 emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")") 194 emailLines.append("Confidence: \(report.emailConfidence?.title ?? "N/A")")
165 if let provenance = report.sectionProvenance[.emailSecurity] { 195 if let provenance = report.sectionProvenance[.emailSecurity] {
166 emailLines.append("Provenance: \(provenanceLabel(provenance))") 196 emailLines.append("Provenance: \(provenanceLabel(provenance))")
@@ -217,6 +247,9 @@ enum DomainReportExporter {
217 values.append("None") 247 values.append("None")
218 return values 248 return values
219 } 249 }
250 if !report.subdomainGroups.isEmpty {
251 values.append("Groups: \(report.subdomainGroups.map { "\($0.label): \($0.subdomains.count)" }.joined(separator: " | "))")
252 }
220 values.append(contentsOf: report.subdomains.map { "- \($0)" }) 253 values.append(contentsOf: report.subdomains.map { "- \($0)" })
221 return values 254 return values
222 } 255 }
@@ -229,9 +262,16 @@ enum DomainReportExporter {
229 var values = [ 262 var values = [
230 "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")", 263 "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
231 "Severity: \(changeSummary.severity.title)", 264 "Severity: \(changeSummary.severity.title)",
265 "Impact: \(changeSummary.impactClassification.title)",
232 "Inferred Summary: \(changeSummary.message)", 266 "Inferred Summary: \(changeSummary.message)",
233 "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))" 267 "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
234 ] 268 ]
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 }
235 if !changeSummary.observedFacts.isEmpty { 275 if !changeSummary.observedFacts.isEmpty {
236 values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))") 276 values.append("Observed: \(changeSummary.observedFacts.joined(separator: " | "))")
237 } 277 }
@@ -262,6 +302,11 @@ enum DomainReportExporter {
262 } 302 }
263 303
264 static func csv(for reports: [DomainReport]) -> String { 304 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: " | ")
265 let headers = [ 310 let headers = [
266 "domain", 311 "domain",
267 "timestamp", 312 "timestamp",
@@ -269,6 +314,10 @@ enum DomainReportExporter {
269 "result_source", 314 "result_source",
270 "resolver", 315 "resolver",
271 "availability", 316 "availability",
317 "risk_score",
318 "risk_level",
319 "risk_factors",
320 "insights",
272 "availability_confidence", 321 "availability_confidence",
273 "registrar", 322 "registrar",
274 "ownership_confidence", 323 "ownership_confidence",
@@ -277,15 +326,20 @@ enum DomainReportExporter {
277 "primary_ip", 326 "primary_ip",
278 "ptr_record", 327 "ptr_record",
279 "dnssec_signed", 328 "dnssec_signed",
329 "dns_patterns",
280 "tls_status", 330 "tls_status",
331 "tls_grade",
332 "tls_highlights",
281 "certificate_warning_level", 333 "certificate_warning_level",
282 "hsts_preloaded", 334 "hsts_preloaded",
283 "http_status", 335 "http_status",
284 "http_security_grade", 336 "http_security_grade",
285 "final_url", 337 "final_url",
286 "email_summary", 338 "email_summary",
339 "email_grade",
287 "email_confidence", 340 "email_confidence",
288 "subdomain_count", 341 "subdomain_count",
342 "subdomain_groups",
289 "subdomain_confidence", 343 "subdomain_confidence",
290 "subdomains", 344 "subdomains",
291 "open_ports", 345 "open_ports",
@@ -295,7 +349,9 @@ enum DomainReportExporter {
295 "data_sources", 349 "data_sources",
296 "audit_note", 350 "audit_note",
297 "partial_snapshot", 351 "partial_snapshot",
298 "change_summary" 352 "change_summary",
353 "change_impact",
354 "workflow_insights"
299 ] 355 ]
300 356
301 let rows = reports.map { report in 357 let rows = reports.map { report in
@@ -307,6 +363,11 @@ enum DomainReportExporter {
307 let subdomainCount = String(report.subdomains.count) 363 let subdomainCount = String(report.subdomains.count)
308 let subdomains = report.subdomains.joined(separator: " | ") 364 let subdomains = report.subdomains.joined(separator: " | ")
309 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ") 365 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: " | ")
310 371
311 return [ 372 return [
312 report.domain, 373 report.domain,
@@ -315,6 +376,10 @@ enum DomainReportExporter {
315 report.resultSource.rawValue, 376 report.resultSource.rawValue,
316 report.resolverDisplayName, 377 report.resolverDisplayName,
317 availabilityLabel(report.availability), 378 availabilityLabel(report.availability),
379 "\(report.riskAssessment.score)",
380 report.riskAssessment.level.rawValue,
381 riskFactors,
382 insights,
318 report.availabilityConfidence?.rawValue ?? "", 383 report.availabilityConfidence?.rawValue ?? "",
319 report.ownership?.registrar ?? "", 384 report.ownership?.registrar ?? "",
320 report.ownershipConfidence?.rawValue ?? "", 385 report.ownershipConfidence?.rawValue ?? "",
@@ -323,15 +388,20 @@ enum DomainReportExporter {
323 report.dns.primaryIP ?? "", 388 report.dns.primaryIP ?? "",
324 report.dns.ptrRecord ?? "", 389 report.dns.ptrRecord ?? "",
325 dnssecSigned, 390 dnssecSigned,
391 dnsPatterns,
326 report.web.tlsStatus, 392 report.web.tlsStatus,
393 report.web.tlsGrade.rawValue,
394 tlsHighlights,
327 report.web.certificateWarningLevel.rawValue, 395 report.web.certificateWarningLevel.rawValue,
328 hstsPreloaded, 396 hstsPreloaded,
329 httpStatus, 397 httpStatus,
330 report.web.securityGrade ?? "", 398 report.web.securityGrade ?? "",
331 report.web.finalURL ?? "", 399 report.web.finalURL ?? "",
332 report.email.summary, 400 report.email.summary,
401 report.email.grade?.rawValue ?? "",
333 report.emailConfidence?.rawValue ?? "", 402 report.emailConfidence?.rawValue ?? "",
334 subdomainCount, 403 subdomainCount,
404 subdomainGroups,
335 report.subdomainConfidence?.rawValue ?? "", 405 report.subdomainConfidence?.rawValue ?? "",
336 subdomains, 406 subdomains,
337 openPorts, 407 openPorts,
@@ -341,7 +411,9 @@ enum DomainReportExporter {
341 report.dataSources.joined(separator: " | "), 411 report.dataSources.joined(separator: " | "),
342 report.auditNote ?? "", 412 report.auditNote ?? "",
343 report.isPartialSnapshot ? "true" : "false", 413 report.isPartialSnapshot ? "true" : "false",
344 report.changeSummary?.message ?? "" 414 report.changeSummary?.message ?? "",
415 report.changeSummary?.impactClassification.rawValue ?? "",
416 workflowInsightSummary
345 ] 417 ]
346 } 418 }
347 419