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