diff --git a/PHPGuard/ContentView.swift b/PHPGuard/ContentView.swift index 7136237..6245043 100644 --- a/PHPGuard/ContentView.swift +++ b/PHPGuard/ContentView.swift @@ -190,8 +190,11 @@ struct ContentView: View { Image( systemName: - vendorStatus == "Not affected" + vendorStatus == "Not affected" || + vendorStatus == "Patched" ? "checkmark.shield.fill" + : vendorStatus == "Vulnerable" + ? "exclamationmark.shield.fill" : "shield.lefthalf.filled" ) @@ -199,9 +202,10 @@ struct ContentView: View { .font(.subheadline.bold()) } .foregroundStyle( - vendorStatus == "Not affected" + vendorStatus == "Not affected" || + vendorStatus == "Patched" ? Color.green - : vendorStatus == "Ubuntu fix needed" + : vendorStatus == "Vulnerable" ? Color.red : Color.orange ) diff --git a/PHPGuard/VulnerabilityService.swift b/PHPGuard/VulnerabilityService.swift index a656420..439566f 100644 --- a/PHPGuard/VulnerabilityService.swift +++ b/PHPGuard/VulnerabilityService.swift @@ -210,9 +210,7 @@ extension VulnerabilityService { private static func installedPackages( _ lines: [String] ) -> [InstalledPackage] { - lines.compactMap { line in - let fields = line.split( separator: "|", omittingEmptySubsequences: false @@ -231,6 +229,145 @@ extension VulnerabilityService { } } + // Debian package-version comparison. + // + // Handles epochs, upstream versions, Debian revisions, + // numeric segments and special '~' ordering. + private static func compareDebianVersions( + _ left: String, + _ right: String + ) -> Int { + + func components( + _ version: String + ) -> (Int, String, String) { + + var remaining = version + var epoch = 0 + + if let colon = remaining.firstIndex(of: ":") { + epoch = Int(remaining[.. Int { + guard let character else { return 0 } + + if character == 126 { return -1 } + + if (65...90).contains(character) || + (97...122).contains(character) { + return Int(character) + } + + return Int(character) + 256 + } + + func comparePart( + _ lhs: String, + _ rhs: String + ) -> Int { + + let a = Array(lhs.utf8) + let b = Array(rhs.utf8) + + var i = 0 + var j = 0 + + func isDigit(_ value: UInt8) -> Bool { + (48...57).contains(value) + } + + while i < a.count || j < b.count { + + while (i < a.count && !isDigit(a[i])) || + (j < b.count && !isDigit(b[j])) { + + let leftCharacter: UInt8? = + i < a.count && !isDigit(a[i]) + ? a[i] : nil + + let rightCharacter: UInt8? = + j < b.count && !isDigit(b[j]) + ? b[j] : nil + + let difference = + order(leftCharacter) - + order(rightCharacter) + + if difference != 0 { + return difference < 0 ? -1 : 1 + } + + if leftCharacter != nil { i += 1 } + if rightCharacter != nil { j += 1 } + } + + while i < a.count && a[i] == 48 { i += 1 } + while j < b.count && b[j] == 48 { j += 1 } + + let startI = i + let startJ = j + + while i < a.count && isDigit(a[i]) { + i += 1 + } + + while j < b.count && isDigit(b[j]) { + j += 1 + } + + let lengthI = i - startI + let lengthJ = j - startJ + + if lengthI != lengthJ { + return lengthI < lengthJ ? -1 : 1 + } + + for offset in 0..= 0 + + findings.append(( + sourceName, + patched + ? "Patched" + : "Vulnerable", + prefix + + "Ubuntu fixed revision: " + + fixedVersion + + (patched + ? ". Installed revision meets " + + "the fixed-version threshold." + : ". Installed revision is older " + + "than the fixed version."), + fixedVersion, + installedVersion + )) + + case "needed": + findings.append(( + sourceName, + "Vulnerable", + prefix + + "Ubuntu reports that a fix is needed.", + nil, + installedVersion + )) + + case "dne": + findings.append(( + sourceName, + "Unverified", + prefix + + "Ubuntu reports package not in release. " + + "Check repository provenance.", + nil, + installedVersion + )) + + default: + findings.append(( + sourceName, + "Unverified", + prefix + + "Ubuntu status: \(status.status).", + nil, + installedVersion + )) } } - if matches.count == 1 { - - item.vendorStatus = matches[0].0 - item.vendorEvidence = matches[0].1 - - } else if matches.isEmpty { + if findings.isEmpty { item.vendorStatus = "Unverified" - item.vendorEvidence = - "No matching Ubuntu security advisory " + - "for installed PHP source packages." + "No matching Ubuntu source-package advisory." } else { - // Different PHP packages can have different - // security states. Avoid a false patched verdict. - item.vendorStatus = "Multiple package states" + // An affected source takes precedence. + // Unknown sources prevent a clean verdict. + let statuses = findings.map(\.status) - item.vendorEvidence = matches - .map { $0.1 } - .joined(separator: "; ") + if statuses.contains("Vulnerable") { + item.vendorStatus = "Vulnerable" + } else if statuses.contains("Unverified") { + item.vendorStatus = "Unverified" + } else if statuses.allSatisfy({ + $0 == "Not affected" + }) { + item.vendorStatus = "Not affected" + } else { + item.vendorStatus = "Patched" + } + + item.vendorEvidence = findings + .map(\.evidence) + .joined(separator: "\n") + + if findings.count == 1, + let finding = findings.first { + item.vendorFixedVersion = finding.fixed + item.vendorInstalledVersion = + finding.installed + } } } catch { item.vendorStatus = "Unverified" - item.vendorEvidence = "Ubuntu advisory lookup failed: " + error.localizedDescription diff --git a/backups/ubuntu-version-verification-20261022-153012/ContentView.swift b/backups/ubuntu-version-verification-20261022-153012/ContentView.swift new file mode 100644 index 0000000..7136237 --- /dev/null +++ b/backups/ubuntu-version-verification-20261022-153012/ContentView.swift @@ -0,0 +1,903 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @State private var showInstalledPackages = false + @State private var showAPTOrigins = false + @State private var selection: UUID? + @State private var showingEditor = false + @State private var editing: Server? + @State private var showClearHistoryConfirmation = false + @AppStorage("phpguard.confirmClear") private var confirmClearHistory = true + private func addServer() { editing = nil; showingEditor = true } + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("Servers") { + ForEach(store.servers) { server in + Label { + HStack { + Text(server.name) + Spacer() + if store.scanning.contains(server.id) { ProgressView().controlSize(.small) } + else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) } + } + } icon: { Image(systemName: "server.rack") } + .tag(server.id) + .contextMenu { + Button("Scan") { store.scan(server) } + Button("Edit") { editing = server; showingEditor = true } + Divider() + Button("Remove", role: .destructive) { + store.servers.removeAll { $0.id == server.id } + if selection == server.id { selection = nil } + } + } + } + } + Section("Navigation") { + Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) + Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + } + } + .navigationTitle("PHPGuard") + .safeAreaInset(edge: .bottom) { + Button(action: addServer) { + Label("Add Server", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(12) + .background(.bar) + } + } detail: { + if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") { + cveView + } else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") { + historyView + } else if let server = store.servers.first(where: { $0.id == selection }) { + serverView(server) + } else { dashboard } + } + .sheet(isPresented: $store.scanProgressVisible) { + ScanProgressView(store: store) + .interactiveDismissDisabled(!store.scanning.isEmpty) + } + .sheet(isPresented: $showingEditor) { + ServerEditor(existing: editing) { server in + if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server } + else { store.servers.append(server) } + selection = server.id + } + } + .alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) { + Button("OK") { store.lastError = nil } + } message: { Text(store.lastError ?? "") } + } + private var dashboard: some View { + VStack(alignment: .leading, spacing: 24) { + Text("Security overview").font(.largeTitle.bold()) + HStack(spacing: 16) { + stat("Servers", "\(store.servers.count)", "server.rack") + stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") + stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") + } + Text("Read-only PHP inventory, APT checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.") + .foregroundStyle(.secondary) + HStack { + Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } + .buttonStyle(.borderedProminent) + Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) + Button("Import JSON scan") { store.importReport() } + } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private func stat(_ title: String, _ value: String, _ icon: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon).foregroundStyle(.secondary) + Text(value).font(.system(size: 36, weight: .bold)) + }.padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14)) + } + private func serverView(_ server: Server) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading) { + Text(server.name).font(.largeTitle.bold()) + Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary) + } + Spacer() + Button("Edit") { editing = server; showingEditor = true } + Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent) + .disabled(store.scanning.contains(server.id)) + } + if let result = store.latest(for: server) { + if let error = result.error { + Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red) + } else { + HStack(spacing: 12) { + Label("Scan completed", systemImage: "checkmark.shield.fill") + .foregroundStyle(.green) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + Spacer() + Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") } + .buttonStyle(.borderedProminent) + Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") } + } + .padding(16) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + Text("OS: \(result.os) • Host: \(result.hostname)") + inventoryCards( + php: result.phpVersions, + fpm: result.fpmServices, + nginx: result.nginxUpstreams, + packages: result.packageVersions + ) + dataSection("Available PHP package upgrades", result.availableUpdates) + DisclosureGroup( + isExpanded: $showAPTOrigins + ) { + dataSection( + "APT package origins and candidate revisions", + result.packageOrigins + ) + } label: { + Label( + "APT package origins and candidate revisions", + systemImage: "shippingbox" + ) + .font(.headline) + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) + Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) + dataSection("PHP detected in running Docker containers", result.dockerPHP) + Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) + dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage) + dataSection("Security triage — follow-up actions", result.securityTriage) + VStack(alignment: .leading, spacing: 10) { + Text("CVE assessment").font(.title2.bold()) + Text(result.cveAssessmentStatus).foregroundStyle(.secondary) + Text("NVD identifies upstream candidates. Ubuntu advisory status is checked separately. A published fix does not prove that the installed package is patched.") + .font(.caption).foregroundStyle(.secondary) + if result.cveAssessments.isEmpty { + Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.") + .foregroundStyle(.secondary) + } + ForEach(result.cveAssessments) { item in + VStack(alignment: .leading, spacing: 5) { + HStack { + Link(item.cveID, destination: URL(string: item.referenceURL)!) + .font(.headline) + Text(item.severity).font(.caption.bold()) + Spacer() + Text(item.status).foregroundStyle(.orange) + } + Text(item.runtime + " — " + item.summary).lineLimit(3) + Text(item.evidence) + .font(.caption) + .foregroundStyle(.secondary) + + if let vendorStatus = item.vendorStatus { + + HStack(spacing: 8) { + + Image( + systemName: + vendorStatus == "Not affected" + ? "checkmark.shield.fill" + : "shield.lefthalf.filled" + ) + + Text("Ubuntu: " + vendorStatus) + .font(.subheadline.bold()) + } + .foregroundStyle( + vendorStatus == "Not affected" + ? Color.green + : vendorStatus == "Ubuntu fix needed" + ? Color.red + : Color.orange + ) + .padding(.top, 5) + } + + if let vendorEvidence = item.vendorEvidence { + + Text(vendorEvidence) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + }.padding(10) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10)) + } + } + HStack { + Text("Security findings").font(.title2.bold()) + Spacer() + Text("\(result.findings.count) total").foregroundStyle(.secondary) + } + ForEach(result.findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill") + Text(finding.severity.uppercased()).font(.caption.bold()) + Text(finding.title).font(.headline) + } + .foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue)) + Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + + } + } else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) } + }.padding(28).frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - PHP Inventory Dashboard + + private func inventoryCards( + php: [String], + fpm: [String], + nginx: [String], + packages: [String] + ) -> some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16, alignment: .top), + GridItem(.flexible(), spacing: 16, alignment: .top) + ], + alignment: .leading, + spacing: 16 + ) { + phpInventoryCard(php) + fpmInventoryCard(fpm) + nginxInventoryCard(nginx) + packageInventoryCard(packages) + } + .frame(maxWidth: .infinity) + } + + private func inventoryCard( + title: String, + subtitle: String, + icon: String, + tint: Color, + count: Int, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 40, height: 40) + .background( + tint.opacity(0.12), + in: RoundedRectangle(cornerRadius: 11) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Text("\(count)") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Color.secondary.opacity(0.10), + in: Capsule() + ) + } + + Rectangle() + .fill(Color.primary.opacity(0.07)) + .frame(height: 1) + + content() + + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading) + .background( + Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 18) + ) + .overlay { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + + private func inventoryBadge( + _ text: String, + color: Color + ) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + + Text(text) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.10), in: Capsule()) + } + + private func phpInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP Installations", + subtitle: "Detected PHP runtimes", + icon: "chevron.left.forwardslash.chevron.right", + tint: .blue, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP runtime detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + separator: " ", + omittingEmptySubsequences: true + ) + + let version = parts.last.map(String.init) ?? "Unknown" + let executable = parts.first.map(String.init) ?? line + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("PHP \(version)") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Spacer() + + inventoryBadge("Detected", color: .green) + } + + Label(executable, systemImage: "terminal") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + } + } + } + } + + private func fpmInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP-FPM Services", + subtitle: "FastCGI process managers", + icon: "gearshape.2.fill", + tint: .purple, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP-FPM services detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + whereSeparator: { $0.isWhitespace } + ) + + let service = parts.first.map(String.init) ?? line + + let running = line.localizedCaseInsensitiveContains( + "active running" + ) || ( + line.contains("active") && + line.contains("running") + ) + + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 10) { + Text(service) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + .lineLimit(2) + + Spacer(minLength: 6) + + inventoryBadge( + running ? "Running" : "Check status", + color: running ? .green : .orange + ) + } + + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + } + } + } + } + + private func nginxInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Nginx FastCGI", + subtitle: "PHP routing configuration", + icon: "network", + tint: .cyan, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No FastCGI routes detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let route = line + .replacingOccurrences( + of: "fastcgi_pass", + with: "" + ) + .trimmingCharacters( + in: .whitespacesAndNewlines + ) + .trimmingCharacters( + in: CharacterSet(charactersIn: ";") + ) + + let isSocket = route.hasPrefix("unix:") + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text( + isSocket + ? "Unix socket" + : "FastCGI endpoint" + ) + .font(.subheadline.weight(.semibold)) + + Spacer() + + inventoryBadge( + "Configured", + color: .blue + ) + } + + Text( + isSocket + ? String(route.dropFirst(5)) + : route + ) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + } + } + } + } + } + + private func packageInventoryCard(_ values: [String]) -> some View { + DisclosureGroup(isExpanded: $showInstalledPackages) { + inventoryCard( + title: "Installed Packages", + subtitle: "PHP package inventory", + icon: "shippingbox.fill", + tint: .orange, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP packages detected") + } else { + VStack(spacing: 0) { + ForEach(Array(values.enumerated()), id: \.offset) { index, line in + let parts = line.split( + separator: " ", + maxSplits: 1, + omittingEmptySubsequences: true + ) + + let name = parts.first.map(String.init) ?? line + let version = parts.count > 1 + ? String(parts[1]) + : "Version not reported" + + VStack(alignment: .leading, spacing: 5) { + Text(name) + .font(.subheadline.weight(.semibold)) + + Text(version) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 9) + + if index < values.count - 1 { + Divider() + } + } + } + } + } + } label: { + HStack { + Label("Installed Packages", systemImage: "shippingbox.fill") + .font(.headline) + + Spacer() + + Text("\(values.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) + } + + private func inventoryEmptyState(_ message: String) -> some View { + HStack(spacing: 9) { + Image(systemName: "minus.circle") + .foregroundStyle(.tertiary) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 12) + } + + private func dataSection(_ title: String, _ values: [String]) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22) + Text(title).font(.headline) + Spacer() + Text("\(values.count)").font(.caption).foregroundStyle(.secondary) + } + if values.isEmpty { Text("None detected").foregroundStyle(.secondary) } + else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14)) + } + private var cveView: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("PHP CVE research").font(.largeTitle.bold()) + Spacer() + if store.fetchingCVEs { ProgressView() } + Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs) + } + Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.") + .foregroundStyle(.secondary) + if let date = store.cveLastChecked { + Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch") + .foregroundStyle(.secondary) + } else { + Text("No cached CVE research yet · Automatically fetched at launch") + .foregroundStyle(.secondary) + } + if let error = store.cveError { Text(error).foregroundStyle(.red) } + List(store.discoveredCVEs) { cve in + VStack(alignment: .leading, spacing: 6) { + HStack { + Link(cve.id, destination: URL(string: cve.referenceURL)!) + .font(.headline) + Spacer() + Text(cve.severity + (cve.score.map { " (\($0))" } ?? "")) + Text(String(cve.published.prefix(10))).foregroundStyle(.secondary) + } + Text(cve.description).lineLimit(4).textSelection(.enabled) + }.padding(.vertical, 5) + } + }.padding(24) + } + private var historyView: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Scan history").font(.largeTitle.bold()) + Text("\(store.results.count) saved scans").foregroundStyle(.secondary) + } + Spacer() + Button("Clear History", role: .destructive) { + if confirmClearHistory { showClearHistoryConfirmation = true } + else { store.clearHistory() } + }.disabled(store.results.isEmpty) + }.padding(20) + List { + ForEach(store.results) { result in + HStack(spacing: 12) { + Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill") + .foregroundStyle(result.error == nil ? .green : .red) + VStack(alignment: .leading, spacing: 4) { + Text(result.serverName).font(.headline) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) } + else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations") + .font(.caption).foregroundStyle(.secondary) } + } + Spacer() + Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil) + Button("JSON") { store.export(result) }.disabled(result.error != nil) + }.padding(.vertical, 5) + }.onDelete(perform: store.removeHistory) + } + } + .confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) { + Button("Clear History", role: .destructive) { store.clearHistory() } + } message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") } + } + + +} + +struct ServerEditor: View { + @Environment(\.dismiss) private var dismiss + let existing: Server? + let onSave: (Server) -> Void + @State private var server: Server + init(existing: Server?, onSave: @escaping (Server) -> Void) { + self.existing = existing + self.onSave = onSave + _server = State(initialValue: existing ?? Server(name: "", host: "")) + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack") + .font(.title.bold()) + Text("Connect using an existing SSH key. No agent installation required.") + .foregroundStyle(.secondary) + Form { + Section("Connection") { + TextField("Server name (e.g. Pegasus)", text: $server.name) + TextField("Hostname or IP address", text: $server.host) + .textContentType(.URL) + TextField("SSH username", text: $server.user) + TextField("SSH port", value: $server.port, format: .number) + } + Section("Advanced (optional)") { + HStack { + TextField("SSH private key path", text: $server.identityFile) + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + if panel.runModal() == .OK, let url = panel.url { + server.identityFile = url.path + } + } + } + Text("Leave blank to use your existing SSH configuration and keys.") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + if !isValid { + Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle") + .foregroundStyle(.secondary).font(.caption) + } + Text("Host key verification is strict. Connect in Terminal first if this server is new.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + Button(existing == nil ? "Add Server" : "Save Changes") { + server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines) + server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(server) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + }.padding(24).frame(width: 580) + } + private var isValid: Bool { + let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !host.isEmpty && !host.hasPrefix("-") + && host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil + && user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil + && (1...65535).contains(server.port) + } +} + +// MARK: - Security Assessment Progress + +struct ScanProgressView: View { + @ObservedObject var store: AuditStore + + @State private var pulse = false + + private var elapsed: String { + let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.09)) + .frame(width: 108, height: 108) + .scaleEffect(pulse ? 1.12 : 0.94) + + Circle() + .stroke(Color.accentColor.opacity(0.18), lineWidth: 2) + .frame(width: 94, height: 94) + + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(Color.accentColor) + .symbolRenderingMode(.hierarchical) + } + .padding(.top, 34) + + Text("Security Assessment") + .font(.system(size: 25, weight: .bold)) + .padding(.top, 22) + + Text("PHPGuard is inspecting your server") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 15) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(store.scanServerName) + .font(.headline) + + Text(store.scanServerHost) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "checkmark.shield") + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(store.scanStage) + .font(.subheadline.weight(.semibold)) + + Text(store.scanDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView(value: store.scanFraction, total: 1.0) + .progressViewStyle(.linear) + .tint(Color.accentColor) + + HStack { + Text("\(Int(store.scanFraction * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + if store.scanTotalChecks > 0 { + Text( + "CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)" + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + HStack { + Label( + "\(store.scanning.count) active", + systemImage: "network" + ) + + Spacer() + + TimelineView(.periodic(from: .now, by: 1)) { context in + let seconds = max( + 0, + Int(context.date.timeIntervalSince(store.scanStartedAt)) + ) + + Text( + String( + format: "%02d:%02d", + seconds / 60, + seconds % 60 + ) + ) + .monospacedDigit() + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(22) + .background( + Color.primary.opacity(0.035), + in: RoundedRectangle(cornerRadius: 16) + ) + .padding(.horizontal, 30) + .padding(.top, 26) + + Text("The assessment runs remotely over SSH. No server changes are made.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 35) + .padding(.top, 22) + .padding(.bottom, 30) + } + .frame(width: 540) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + pulse = true + } + .animation( + .easeInOut(duration: 1.7).repeatForever(autoreverses: true), + value: pulse + ) + } +} diff --git a/backups/ubuntu-version-verification-20261022-153012/VulnerabilityService.swift b/backups/ubuntu-version-verification-20261022-153012/VulnerabilityService.swift new file mode 100644 index 0000000..a656420 --- /dev/null +++ b/backups/ubuntu-version-verification-20261022-153012/VulnerabilityService.swift @@ -0,0 +1,457 @@ +import Foundation + +enum VulnerabilityService { + + enum ServiceError: LocalizedError { + case invalidResponse + case httpError(Int) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid response from the vulnerability database." + case .httpError(let code): + return "Vulnerability database returned HTTP \(code)." + } + } + } + + private struct NVDResponse: Decodable { + let vulnerabilities: [Entry] + } + + private struct Entry: Decodable { + let cve: CVE + } + + private struct CVE: Decodable { + let id: String + let published: String + let descriptions: [Description] + let metrics: Metrics? + } + + private struct Description: Decodable { + let lang: String + let value: String + } + + private struct Metrics: Decodable { + let cvssMetricV31: [Metric]? + let cvssMetricV30: [Metric]? + let cvssMetricV2: [Metric]? + } + + private struct Metric: Decodable { + let cvssData: CVSSData + } + + private struct CVSSData: Decodable { + let baseScore: Double? + let baseSeverity: String? + } + + static func discover() async throws -> [CVERecord] { + + var components = URLComponents( + string: "https://services.nvd.nist.gov/rest/json/cves/2.0" + )! + + components.queryItems = [ + URLQueryItem(name: "keywordSearch", value: "PHP"), + URLQueryItem(name: "resultsPerPage", value: "100") + ] + + guard let url = components.url else { + throw ServiceError.invalidResponse + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.setValue( + "application/json", + forHTTPHeaderField: "Accept" + ) + request.setValue( + "PHPGuard/1.4", + forHTTPHeaderField: "User-Agent" + ) + + let (data, response) = try await URLSession.shared.data( + for: request + ) + + guard let http = response as? HTTPURLResponse else { + throw ServiceError.invalidResponse + } + + guard (200...299).contains(http.statusCode) else { + throw ServiceError.httpError(http.statusCode) + } + + let decoded = try JSONDecoder().decode( + NVDResponse.self, + from: data + ) + + return decoded.vulnerabilities.map { entry in + + let cve = entry.cve + + let description = cve.descriptions.first { + $0.lang == "en" + }?.value ?? "No description available." + + let metric = + cve.metrics?.cvssMetricV31?.first ?? + cve.metrics?.cvssMetricV30?.first ?? + cve.metrics?.cvssMetricV2?.first + + return CVERecord( + id: cve.id, + description: description, + published: cve.published, + severity: metric?.cvssData.baseSeverity ?? "UNKNOWN", + score: metric?.cvssData.baseScore + ) + } + .sorted { $0.published > $1.published } + } +} + + +extension VulnerabilityService { + // Returns upstream PHP CVE candidates, not confirmed Ubuntu/Debian exposures. + static func assessPHPVersion(_ version: String, runtime: String) async throws -> [CVEServerAssessment] { + let parts = version.split(separator: ".") + guard parts.count >= 3, + parts.prefix(3).allSatisfy({ Int($0) != nil }) else { return [] } + let canonical = parts.prefix(3).joined(separator: ".") + var components = URLComponents(string: "https://services.nvd.nist.gov/rest/json/cves/2.0")! + components.queryItems = [ + URLQueryItem(name: "cpeName", value: "cpe:2.3:a:php:php:\(canonical):*:*:*:*:*:*:*"), + URLQueryItem(name: "resultsPerPage", value: "2000") + ] + guard let url = components.url else { throw ServiceError.invalidResponse } + var request = URLRequest(url: url) + request.timeoutInterval = 45 + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("PHPGuard/1.5", forHTTPHeaderField: "User-Agent") + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } + guard (200...299).contains(http.statusCode) else { throw ServiceError.httpError(http.statusCode) } + let payload = try JSONDecoder().decode(VersionResponse.self, from: data) + // An incomplete NVD response must not be presented as full coverage. + guard payload.totalResults <= payload.vulnerabilities.count else { + throw ServiceError.invalidResponse + } + return payload.vulnerabilities.map { entry in + let cve = entry.cve + let metric = cve.metrics?.cvssMetricV31?.first ?? cve.metrics?.cvssMetricV30?.first ?? cve.metrics?.cvssMetricV2?.first + return CVEServerAssessment( + cveID: cve.id, runtime: runtime, + severity: metric?.cvssData.baseSeverity ?? "UNKNOWN", + score: metric?.cvssData.baseScore, + summary: cve.descriptions.first(where: { $0.lang == "en" })?.value ?? "No English description", + status: "Potentially affected", + evidence: "NVD PHP product CPE matches upstream PHP \(canonical). Distribution package revisions, backported patches, and container image fixes have NOT been verified." + ) + } + } + + private struct VersionResponse: Decodable { + let totalResults: Int + let vulnerabilities: [VersionEntry] + } + private struct VersionEntry: Decodable { let cve: VersionCVE } + private struct VersionCVE: Decodable { + let id: String + let descriptions: [VersionDescription] + let metrics: VersionMetrics? + } + private struct VersionDescription: Decodable { let lang: String; let value: String } + private struct VersionMetrics: Decodable { + let cvssMetricV31: [VersionMetric]? + let cvssMetricV30: [VersionMetric]? + let cvssMetricV2: [VersionMetric]? + } + private struct VersionMetric: Decodable { let cvssData: VersionCVSS } + private struct VersionCVSS: Decodable { let baseScore: Double?; let baseSeverity: String? } +} + + +// MARK: - Ubuntu Security Advisory Verification + +extension VulnerabilityService { + + private struct UbuntuAdvisory: Decodable { + let packages: [UbuntuPackage]? + } + + private struct UbuntuPackage: Decodable { + let name: String + let statuses: [UbuntuStatus]? + } + + private struct UbuntuStatus: Decodable { + let release_codename: String + let status: String + let description: String? + let pocket: String? + } + + private struct InstalledPackage { + let binary: String + let version: String + let source: String + let sourceVersion: String + } + + private static func installedPackages( + _ lines: [String] + ) -> [InstalledPackage] { + + lines.compactMap { line in + + let fields = line.split( + separator: "|", + omittingEmptySubsequences: false + ).map(String.init) + + guard fields.count >= 4 else { + return nil + } + + return InstalledPackage( + binary: fields[0], + version: fields[1], + source: fields[2], + sourceVersion: fields[3] + ) + } + } + + static func assessUbuntu( + _ assessments: [CVEServerAssessment], + report: AuditResult + ) async -> [CVEServerAssessment] { + + guard report.ubuntuReleaseID == "ubuntu", + !report.ubuntuCodename.isEmpty else { + + return assessments.map { original in + var item = original + item.vendorStatus = "Unverified" + item.vendorEvidence = + "Ubuntu release could not be established." + return item + } + } + + let installed = installedPackages(report.packageSources) + + guard !installed.isEmpty else { + + return assessments.map { original in + var item = original + item.vendorStatus = "Unverified" + item.vendorEvidence = + "No installed PHP source packages were collected." + return item + } + } + + var output: [CVEServerAssessment] = [] + + // Cache repeated CVEs across different PHP runtimes. + var cache: [String: UbuntuAdvisory] = [:] + + for original in assessments { + + var item = original + + item.vendorStatus = "Unverified" + + // Ubuntu host packages must never be used to certify + // a Docker runtime as patched. + let runtimeVersion = original.runtime + .replacingOccurrences(of: "PHP ", with: "") + + let hostHasVersion = report.phpVersions.contains { + $0.contains(runtimeVersion) + } + + guard hostHasVersion else { + item.vendorEvidence = + "Runtime not confirmed as a host PHP installation. " + + "Docker image security must be checked separately." + output.append(item) + continue + } + + guard original.cveID.range( + of: #"^CVE-[0-9]{4}-[0-9]{4,}$"#, + options: .regularExpression + ) != nil else { + + item.vendorEvidence = "Invalid CVE identifier." + output.append(item) + continue + } + + do { + + let advisory: UbuntuAdvisory + + if let existing = cache[original.cveID] { + advisory = existing + } else { + + let address = + "https://ubuntu.com/security/cves/" + + original.cveID + ".json" + + guard let url = URL(string: address) else { + throw ServiceError.invalidResponse + } + + var request = URLRequest(url: url) + + request.timeoutInterval = 20 + + request.setValue( + "application/json", + forHTTPHeaderField: "Accept" + ) + + let (data, response) = + try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + + throw ServiceError.invalidResponse + } + + advisory = try JSONDecoder().decode( + UbuntuAdvisory.self, + from: data + ) + + cache[original.cveID] = advisory + } + + var matches: [(String, String)] = [] + + for package in installed { + + guard let advisoryPackage = + advisory.packages?.first(where: { + $0.name == package.source + }) else { + continue + } + + let statuses = ( + advisoryPackage.statuses ?? [] + ).filter { + $0.release_codename == + report.ubuntuCodename + } + + for status in statuses { + + let state = status.status.lowercased() + + let description = + status.description ?? "" + + let evidence = + "\(package.binary) \(package.version), " + + "source \(package.source) " + + "\(package.sourceVersion), " + + "Ubuntu \(report.ubuntuCodename): " + + "\(status.status)" + + ( + description.isEmpty + ? "" + : " (\(description))" + ) + + switch state { + + case "not-affected": + matches.append(( + "Not affected", + evidence + )) + + case "released", "fixed": + matches.append(( + "Ubuntu fix published", + evidence + + ". Installed revision has not " + + "been compared with the fixed revision." + )) + + case "needed": + matches.append(( + "Ubuntu fix needed", + evidence + )) + + case "dne": + matches.append(( + "Package not in Ubuntu release", + evidence + + ". Check third-party repositories." + )) + + default: + matches.append(( + "Unverified", + evidence + )) + } + } + } + + if matches.count == 1 { + + item.vendorStatus = matches[0].0 + item.vendorEvidence = matches[0].1 + + } else if matches.isEmpty { + + item.vendorStatus = "Unverified" + + item.vendorEvidence = + "No matching Ubuntu security advisory " + + "for installed PHP source packages." + + } else { + + // Different PHP packages can have different + // security states. Avoid a false patched verdict. + item.vendorStatus = "Multiple package states" + + item.vendorEvidence = matches + .map { $0.1 } + .joined(separator: "; ") + } + + } catch { + + item.vendorStatus = "Unverified" + + item.vendorEvidence = + "Ubuntu advisory lookup failed: " + + error.localizedDescription + } + + output.append(item) + } + + return output + } +}