From 1da723486413cffe5349e895ca94cf5c1b1e9fc0 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 22 Oct 2026 14:33:12 +0100 Subject: [PATCH] Updates to scanner with CVE saving and auto update --- .../20261022-142736/AuditStore.swift | 165 ++++ .../20261022-142736/ContentView.swift | 797 ++++++++++++++++++ .../20261022-142736/Models.swift | 107 +++ .../20261022-142736/ReportPDF.swift | 376 +++++++++ .../20261022-142736/SSHScanner.swift | 501 +++++++++++ .../VulnerabilityService.swift | 120 +++ PHPGuard/AuditStore.swift | 39 +- PHPGuard/ContentView.swift | 370 +++++++- .../ContentView.swift.backup-20261022-135436 | 461 ++++++++++ PHPGuard/Models.swift | 24 +- PHPGuard/ReportPDF.swift | 18 + PHPGuard/SSHScanner.swift | 268 ++++-- .../SSHScanner.swift.backup-20261022-134944 | 412 +++++++++ .../SSHScanner.swift.backup-20261022-135123 | 512 +++++++++++ PHPGuard/VulnerabilityService.swift | 60 ++ 15 files changed, 4131 insertions(+), 99 deletions(-) create mode 100644 .phpguard-cve-backups/20261022-142736/AuditStore.swift create mode 100644 .phpguard-cve-backups/20261022-142736/ContentView.swift create mode 100644 .phpguard-cve-backups/20261022-142736/Models.swift create mode 100644 .phpguard-cve-backups/20261022-142736/ReportPDF.swift create mode 100644 .phpguard-cve-backups/20261022-142736/SSHScanner.swift create mode 100644 .phpguard-cve-backups/20261022-142736/VulnerabilityService.swift create mode 100644 PHPGuard/ContentView.swift.backup-20261022-135436 create mode 100644 PHPGuard/SSHScanner.swift.backup-20261022-134944 create mode 100644 PHPGuard/SSHScanner.swift.backup-20261022-135123 diff --git a/.phpguard-cve-backups/20261022-142736/AuditStore.swift b/.phpguard-cve-backups/20261022-142736/AuditStore.swift new file mode 100644 index 0000000..b5f13cd --- /dev/null +++ b/.phpguard-cve-backups/20261022-142736/AuditStore.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftUI + +@MainActor final class AuditStore: ObservableObject { + @Published var servers: [Server] = [] { didSet { saveServers() } } + @Published var results: [AuditResult] = [] { didSet { saveResults() } } + @Published var scanning: Set = [] + @Published var scanProgressVisible = false + @Published var scanStartedAt = Date() + @Published var scanServerName = "" + @Published var scanServerHost = "" + + @Published var lastError: String? + @Published var discoveredCVEs: [CVERecord] = [] { didSet { saveCVECache() } } + @Published var cveLastChecked: Date? { didSet { saveCVECache() } } + @Published var cveError: String? + @Published var fetchingCVEs = false + + private var folder: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let path = base.appendingPathComponent("PHPGuard", isDirectory: true) + try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) + return path + } + init() { + let defaults = UserDefaults.standard + autoRefreshCVEs = defaults.object(forKey: "phpguard.autoRefreshCVEs") as? Bool ?? true + showTechnicalDetails = defaults.bool(forKey: "phpguard.showTechnicalDetails") + confirmBeforeClearing = defaults.object(forKey: "phpguard.confirmClear") as? Bool ?? true + preferredReportAccent = defaults.string(forKey: "phpguard.reportAccent") ?? "Blue" + retainHistoryLimit = defaults.object(forKey: "phpguard.historyLimit") as? Int ?? 250 + servers = load("servers.json") ?? [] + results = (load("results.json") ?? []).map { Self.normalized($0) } + if let cache: CVECache = load("cve-cache.json") { + discoveredCVEs = cache.records + cveLastChecked = cache.fetchedAt + } + } + private static func normalized(_ source: AuditResult) -> AuditResult { + var result = source + result.dockerPHP = source.dockerPHP.filter { + !$0.contains("OCI runtime exec failed") && !$0.contains("executable file not found") + } + result.findings.removeAll { + $0.title == "PHP runtime in Docker" && + ($0.detail.contains("OCI runtime exec failed") || $0.detail.contains("executable file not found")) + } + if result.dockerPHP.count != source.dockerPHP.count { + result.dockerAuditStatus = "Legacy scan: missing PHP executables excluded; rescan for structured Docker coverage" + } + return result + } + func importReport() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let report = Self.normalized(try decoder.decode(AuditResult.self, from: Data(contentsOf: url))) + guard !results.contains(where: { $0.id == report.id }) else { + lastError = "This report has already been imported." + return + } + results.insert(report, at: 0) + } catch { lastError = "Could not import JSON report: \(error.localizedDescription)" } + } + private func load(_ name: String) -> T? { + guard let data = try? Data(contentsOf: folder.appendingPathComponent(name)) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + private func save(_ value: T, as name: String) { + guard let data = try? JSONEncoder().encode(value) else { return } + try? data.write(to: folder.appendingPathComponent(name), options: .atomic) + } + private struct CVECache: Codable { + let records: [CVERecord] + let fetchedAt: Date? + } + private func saveCVECache() { + // Do not overwrite a previously successful download with an empty response. + guard !discoveredCVEs.isEmpty else { return } + save(CVECache(records: discoveredCVEs, fetchedAt: cveLastChecked), as: "cve-cache.json") + } + private func saveServers() { save(servers, as: "servers.json") } + private func saveResults() { save(Array(results.prefix(max(1, retainHistoryLimit))), as: "results.json") } + func latest(for server: Server) -> AuditResult? { results.first { $0.serverID == server.id } } + func scan(_ server: Server) { + guard !scanning.contains(server.id) else { return } + if scanning.isEmpty { + scanStartedAt = Date() + } + scanServerName = server.name + scanServerHost = server.host + scanProgressVisible = true + scanning.insert(server.id) + Task { + defer { + scanning.remove(server.id) + if scanning.isEmpty { + scanProgressVisible = false + } + } + do { results.insert(try await SSHScanner.scan(server), at: 0) } + catch { + var failed = AuditResult(serverID: server.id, serverName: server.name) + failed.error = error.localizedDescription + results.insert(failed, at: 0) + lastError = "\(server.name): \(error.localizedDescription)" + } + } + } + func scanAll() { servers.forEach { scan($0) } } + func refreshCVEsOnLaunch() { + // Preserve the local cache while checking NVD for a newer result set. + if autoRefreshCVEs { refreshCVEs() } + } + func refreshCVEs() { + guard !fetchingCVEs else { return } + fetchingCVEs = true + cveError = nil + Task { + defer { fetchingCVEs = false } + do { + let downloaded = try await VulnerabilityService.discover() + guard !downloaded.isEmpty else { + cveError = "NVD returned no records; keeping the previous saved results." + return + } + discoveredCVEs = downloaded + cveLastChecked = Date() + } catch { cveError = error.localizedDescription } + } + } + @Published var showTechnicalDetails = false + @Published var confirmBeforeClearing = true + @Published var preferredReportAccent = "Blue" + @Published var autoRefreshCVEs = true + @Published var retainHistoryLimit = 250 + + func clearHistory() { results.removeAll() } + func removeHistory(_ offsets: IndexSet) { results.remove(atOffsets: offsets) } + func exportPDF(_ result: AuditResult) { + let panel = NSSavePanel() + panel.allowedContentTypes = [.pdf] + panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).pdf" + guard panel.runModal() == .OK, let url = panel.url else { return } + do { try ReportPDF.write(result, to: url, accent: preferredReportAccent) } + catch { lastError = "PDF export failed: \(error.localizedDescription)" } + } + func export(_ result: AuditResult) { + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).json" + if panel.runModal() == .OK, let url = panel.url { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(result).write(to: url) + } catch { lastError = error.localizedDescription } + } + } +} diff --git a/.phpguard-cve-backups/20261022-142736/ContentView.swift b/.phpguard-cve-backups/20261022-142736/ContentView.swift new file mode 100644 index 0000000..66c5e68 --- /dev/null +++ b/.phpguard-cve-backups/20261022-142736/ContentView.swift @@ -0,0 +1,797 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @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 upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.") + .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) + dataSection("APT package origins and candidate revisions", result.packageOrigins) + 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) + 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 { + 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() + } + } + } + } + } + } + + 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("Scanning") + .font(.subheadline.weight(.semibold)) + + Text("Collecting PHP, package, Docker and web-server evidence") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView() + .progressViewStyle(.linear) + .tint(Color.accentColor) + + 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/.phpguard-cve-backups/20261022-142736/Models.swift b/.phpguard-cve-backups/20261022-142736/Models.swift new file mode 100644 index 0000000..0f8b700 --- /dev/null +++ b/.phpguard-cve-backups/20261022-142736/Models.swift @@ -0,0 +1,107 @@ +import Foundation + +struct Server: Codable, Identifiable, Hashable { + var id = UUID() + var name: String + var host: String + var user: String = "mag" + var port: Int = 22 + var identityFile: String = "" +} + +struct Finding: Codable, Identifiable { + var id = UUID() + var severity: String + var title: String + var detail: String +} + +struct AuditResult: Codable, Identifiable { + var id = UUID() + var serverID: UUID + var serverName: String + var scannedAt = Date() + var hostname = "" + var os = "" + var phpVersions: [String] = [] + var fpmServices: [String] = [] + var nginxUpstreams: [String] = [] + var packageVersions: [String] = [] + var availableUpdates: [String] = [] + var packageOrigins: [String] = [] + var dockerPHP: [String] = [] + var dockerCoverage: [String] = [] + var securityTriage: [String] = [] + var packageAuditStatus = "Not checked" + var dockerAuditStatus = "Not checked" + var findings: [Finding] = [] + var recentPHPFiles: [String] = [] + var webErrorSignals: [String] = [] + var investigationStatus = "Not checked" + var rawOutput = "" + var error: String? = nil + + enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, availableUpdates, packageOrigins, dockerPHP, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, rawOutput, error, id } + init(serverID: UUID, serverName: String) { self.serverID = serverID; self.serverName = serverName } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + serverID = try container.decode(UUID.self, forKey: .serverID) + serverName = try container.decode(String.self, forKey: .serverName) + scannedAt = try container.decodeIfPresent(Date.self, forKey: .scannedAt) ?? Date() + hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? "" + os = try container.decodeIfPresent(String.self, forKey: .os) ?? "" + phpVersions = try container.decodeIfPresent([String].self, forKey: .phpVersions) ?? [] + fpmServices = try container.decodeIfPresent([String].self, forKey: .fpmServices) ?? [] + nginxUpstreams = try container.decodeIfPresent([String].self, forKey: .nginxUpstreams) ?? [] + packageVersions = try container.decodeIfPresent([String].self, forKey: .packageVersions) ?? [] + availableUpdates = try container.decodeIfPresent([String].self, forKey: .availableUpdates) ?? [] + packageOrigins = try container.decodeIfPresent([String].self, forKey: .packageOrigins) ?? [] + dockerPHP = try container.decodeIfPresent([String].self, forKey: .dockerPHP) ?? [] + dockerCoverage = try container.decodeIfPresent([String].self, forKey: .dockerCoverage) ?? [] + securityTriage = try container.decodeIfPresent([String].self, forKey: .securityTriage) ?? [] + packageAuditStatus = try container.decodeIfPresent(String.self, forKey: .packageAuditStatus) ?? "Not checked" + dockerAuditStatus = try container.decodeIfPresent(String.self, forKey: .dockerAuditStatus) ?? "Not checked" + findings = try container.decodeIfPresent([Finding].self, forKey: .findings) ?? [] + recentPHPFiles = try container.decodeIfPresent([String].self, forKey: .recentPHPFiles) ?? [] + webErrorSignals = try container.decodeIfPresent([String].self, forKey: .webErrorSignals) ?? [] + investigationStatus = try container.decodeIfPresent(String.self, forKey: .investigationStatus) ?? "Not checked" + rawOutput = try container.decodeIfPresent(String.self, forKey: .rawOutput) ?? "" + error = try container.decodeIfPresent(String.self, forKey: .error) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + } + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(serverID, forKey: .serverID) + try container.encode(serverName, forKey: .serverName) + try container.encode(scannedAt, forKey: .scannedAt) + try container.encode(hostname, forKey: .hostname) + try container.encode(os, forKey: .os) + try container.encode(phpVersions, forKey: .phpVersions) + try container.encode(fpmServices, forKey: .fpmServices) + try container.encode(nginxUpstreams, forKey: .nginxUpstreams) + try container.encode(packageVersions, forKey: .packageVersions) + try container.encode(availableUpdates, forKey: .availableUpdates) + try container.encode(packageOrigins, forKey: .packageOrigins) + try container.encode(dockerPHP, forKey: .dockerPHP) + try container.encode(dockerCoverage, forKey: .dockerCoverage) + try container.encode(securityTriage, forKey: .securityTriage) + try container.encode(packageAuditStatus, forKey: .packageAuditStatus) + try container.encode(dockerAuditStatus, forKey: .dockerAuditStatus) + try container.encode(findings, forKey: .findings) + try container.encode(recentPHPFiles, forKey: .recentPHPFiles) + try container.encode(webErrorSignals, forKey: .webErrorSignals) + try container.encode(investigationStatus, forKey: .investigationStatus) + try container.encode(rawOutput, forKey: .rawOutput) + try container.encodeIfPresent(error, forKey: .error) + try container.encode(id, forKey: .id) + } +} + +struct CVERecord: Codable, Identifiable { + var id: String + var description: String + var published: String + var severity: String + var score: Double? + var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + id } +} diff --git a/.phpguard-cve-backups/20261022-142736/ReportPDF.swift b/.phpguard-cve-backups/20261022-142736/ReportPDF.swift new file mode 100644 index 0000000..b063d6a --- /dev/null +++ b/.phpguard-cve-backups/20261022-142736/ReportPDF.swift @@ -0,0 +1,376 @@ +import AppKit +import CoreGraphics + +@MainActor +enum ReportPDF { + static func write(_ report: AuditResult, to url: URL, accent: String) throws { + try ProfessionalReport(report: report, accent: accent).write(to: url) + } +} + +@MainActor +private final class ProfessionalReport { + private let report: AuditResult + private let w: CGFloat = 595 + private let h: CGFloat = 842 + private let left: CGFloat = 46 + private let right: CGFloat = 549 + private let bottom: CGFloat = 784 + private let navy = NSColor(calibratedRed: 0.065, green: 0.105, blue: 0.175, alpha: 1) + private let ink = NSColor(calibratedRed: 0.13, green: 0.18, blue: 0.26, alpha: 1) + private let grey = NSColor(calibratedRed: 0.40, green: 0.45, blue: 0.52, alpha: 1) + private let light = NSColor(calibratedRed: 0.955, green: 0.966, blue: 0.98, alpha: 1) + private let accent: NSColor + private var context: CGContext! + private var y: CGFloat = 0 + private var page = 0 + private var isOpen = false + + init(report: AuditResult, accent: String) { + self.report = report + switch accent { + case "Teal": self.accent = NSColor(calibratedRed: 0.0, green: 0.58, blue: 0.56, alpha: 1) + case "Purple": self.accent = NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.77, alpha: 1) + default: self.accent = NSColor(calibratedRed: 0.16, green: 0.40, blue: 0.80, alpha: 1) + } + } + + private var genuineFindings: [Finding] { + report.findings.filter { finding in + let title = finding.title.lowercased() + let detail = finding.detail.lowercased() + // A failed attempt to execute PHP is not evidence that PHP is installed. + return !(title.contains("container php runtime detected") && + (detail.contains("executable file not found") || + detail.contains("oci runtime exec failed") || + detail.contains("php: not found"))) + } + } + + private var containerResults: [(name: String, state: String, detail: String)] { + report.dockerPHP.map { entry in + let lower = entry.lowercased() + let state: String + if lower.contains("executable file not found") || lower.contains("oci runtime exec failed") || lower.contains("php: not found") { + state = "PHP CLI not detected" + } else if lower.contains("php ") || lower.contains("php/") { + state = "PHP reported" + } else { + state = "Unverified" + } + let name = entry.components(separatedBy: " [").first ?? entry + return (name, state, entry) + } + } + + private func withCanvas(_ draw: () -> Void) { + context.saveGState() + NSGraphicsContext.saveGraphicsState() + context.translateBy(x: 0, y: h) + context.scaleBy(x: 1, y: -1) + NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) + draw() + NSGraphicsContext.restoreGraphicsState() + context.restoreGState() + } + + private func fill(_ rect: CGRect, _ color: NSColor, radius: CGFloat = 0) { + color.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + } + + private func attributed(_ string: String, size: CGFloat, bold: Bool = false, color: NSColor? = nil, breakMode: NSLineBreakMode = .byCharWrapping) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = 2.5 + paragraph.lineBreakMode = breakMode + return NSAttributedString(string: string, attributes: [ + .font: NSFont.systemFont(ofSize: size, weight: bold ? .semibold : .regular), + .foregroundColor: color ?? ink, + .paragraphStyle: paragraph + ]) + } + + private func height(_ string: String, width: CGFloat, size: CGFloat, bold: Bool = false) -> CGFloat { + let box = attributed(string, size: size, bold: bold).boundingRect( + with: CGSize(width: width, height: 20000), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ) + return max(size + 4, ceil(box.height) + 3) + } + + @discardableResult + private func text(_ string: String, x: CGFloat, y: CGFloat, width: CGFloat, size: CGFloat = 10, bold: Bool = false, color: NSColor? = nil) -> CGFloat { + let lineHeight = height(string, width: width, size: size, bold: bold) + attributed(string, size: size, bold: bold, color: color).draw( + with: CGRect(x: x, y: y, width: width, height: lineHeight + 1), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ) + return lineHeight + } + + private func begin(_ chapter: String) { + context.beginPDFPage(nil) + isOpen = true + page += 1 + y = 106 + withCanvas { + fill(CGRect(x: 0, y: 0, width: w, height: 7), accent) + text("PHPGUARD", x: left, y: 28, width: 190, size: 15, bold: true, color: navy) + text(chapter.uppercased(), x: 292, y: 33, width: 257, size: 8, bold: true, color: grey) + fill(CGRect(x: left, y: 72, width: right-left, height: 1), NSColor(calibratedWhite: 0.88, alpha: 1)) + } + } + + private func end() { + guard isOpen else { return } + withCanvas { + fill(CGRect(x: left, y: 790, width: right-left, height: 1), NSColor(calibratedWhite: 0.88, alpha: 1)) + text("PHPGuard | \(report.serverName) | Vendor verification required", x: left, y: 803, width: 395, size: 7.5, color: grey) + text("PAGE \(page)", x: 474, y: 803, width: 75, size: 8, bold: true, color: grey) + } + context.endPDFPage() + isOpen = false + } + + private func space(_ required: CGFloat, chapter: String) { + if y + required > bottom { + end() + begin(chapter) + } + } + + private func title(_ string: String, subtitle: String, chapter: String) { + space(85, chapter: chapter) + withCanvas { + fill(CGRect(x: left, y: y+3, width: 4, height: 26), accent, radius: 2) + text(string, x: left+15, y: y, width: right-left-15, size: 20, bold: true, color: navy) + text(subtitle, x: left+15, y: y+34, width: right-left-15, size: 9, color: grey) + } + y += 77 + } + + private func section(_ name: String, chapter: String) { + space(36, chapter: chapter) + withCanvas { text(name.uppercased(), x: left, y: y, width: right-left, size: 10, bold: true, color: accent) } + y += 26 + } + + private func paragraph(_ string: String, chapter: String, size: CGFloat = 9.5) { + let needed = height(string, width: right-left, size: size) + 11 + space(needed, chapter: chapter) + withCanvas { text(string, x: left, y: y, width: right-left, size: size) } + y += needed + } + + private func tableRow(_ label: String, _ value: String, chapter: String, alternate: Bool = false) { + let labelWidth: CGFloat = 145 + let valueWidth: CGFloat = right-left-labelWidth-32 + let rowHeight = max(height(label, width: labelWidth, size: 9, bold: true), height(value, width: valueWidth, size: 9)) + 19 + space(rowHeight+4, chapter: chapter) + withCanvas { + fill(CGRect(x: left, y: y, width: right-left, height: rowHeight), alternate ? light : NSColor.white, radius: 4) + text(label, x: left+12, y: y+9, width: labelWidth, size: 9, bold: true, color: grey) + text(value, x: left+labelWidth+17, y: y+9, width: valueWidth, size: 9) + } + y += rowHeight+4 + } + + private func items(_ values: [String], chapter: String, empty: String = "No entries recorded") { + if values.isEmpty { + paragraph(empty, chapter: chapter) + return + } + for (index, value) in values.enumerated() { + let needed = height(value, width: right-left-32, size: 8.7) + 18 + space(needed+3, chapter: chapter) + withCanvas { + if index.isMultiple(of: 2) { fill(CGRect(x: left, y: y, width: right-left, height: needed), light, radius: 4) } + fill(CGRect(x: left+10, y: y+12, width: 4, height: 4), accent, radius: 2) + text(value, x: left+24, y: y+8, width: right-left-35, size: 8.7) + } + y += needed+3 + } + y += 8 + } + + private func severityColor(_ value: String) -> NSColor { + switch value.lowercased() { + case "critical", "high": return .systemRed + case "medium", "warning": return .systemOrange + case "low": return NSColor(calibratedRed: 0.57, green: 0.43, blue: 0.07, alpha: 1) + default: return accent + } + } + + private func finding(_ value: Finding) { + let titleH = height(value.title, width: 350, size: 11, bold: true) + let detailH = height(value.detail, width: right-left-36, size: 9) + let cardH = titleH+detailH+53 + space(cardH+12, chapter: "Security findings") + withCanvas { + fill(CGRect(x: left, y: y, width: right-left, height: cardH), light, radius: 8) + fill(CGRect(x: left, y: y, width: 5, height: cardH), severityColor(value.severity), radius: 2) + text(value.title, x: left+18, y: y+15, width: 350, size: 11, bold: true, color: navy) + text(value.severity.uppercased(), x: right-106, y: y+17, width: 92, size: 9, bold: true, color: severityColor(value.severity)) + text(value.detail, x: left+18, y: y+25+titleH, width: right-left-36, size: 9) + } + y += cardH+12 + } + + private func cover() { + context.beginPDFPage(nil) + page += 1 + withCanvas { + fill(CGRect(x: 0, y: 0, width: w, height: h), navy) + fill(CGRect(x: 0, y: 0, width: w, height: 10), accent) + text("PHPGUARD", x: left, y: 60, width: 430, size: 25, bold: true, color: .white) + text("INFRASTRUCTURE SECURITY ASSESSMENT", x: left, y: 103, width: 470, size: 10, bold: true, color: accent) + fill(CGRect(x: left, y: 180, width: 65, height: 4), accent, radius: 2) + text("PHP SECURITY\nASSESSMENT", x: left, y: 209, width: 490, size: 38, bold: true, color: .white) + text(report.serverName, x: left, y: 338, width: 490, size: 25, bold: true, color: .white) + text(report.hostname, x: left, y: 379, width: 490, size: 12, color: NSColor(calibratedWhite: 0.75, alpha: 1)) + fill(CGRect(x: left, y: 430, width: right-left, height: 46), NSColor(calibratedRed: 0.16, green: 0.22, blue: 0.31, alpha: 1), radius: 7) + text("STATUS / VERIFICATION REQUIRED", x: left+17, y: 445, width: 460, size: 12, bold: true, color: .white) + let labels = [("\(report.phpVersions.count)", "HOST PHP RUNTIMES"), ("\(genuineFindings.count)", "REVIEW ITEMS"), ("\(report.availableUpdates.count)", "UPDATES RECORDED")] + let boxWidth: CGFloat = 160 + for (index, item) in labels.enumerated() { + let x = left + CGFloat(index)*171 + fill(CGRect(x: x, y: 505, width: boxWidth, height: 110), NSColor(calibratedRed: 0.13, green: 0.19, blue: 0.27, alpha: 1), radius: 8) + text(item.0, x: x+15, y: 526, width: 125, size: 29, bold: true, color: .white) + text(item.1, x: x+15, y: 580, width: 135, size: 8, bold: true, color: NSColor(calibratedWhite: 0.77, alpha: 1)) + } + text("OPERATING SYSTEM", x: left, y: 665, width: 230, size: 8, bold: true, color: accent) + text(report.os.isEmpty ? "Not reported" : report.os, x: left, y: 682, width: 490, size: 11, color: .white) + text("SCAN DATE", x: left, y: 722, width: 230, size: 8, bold: true, color: accent) + text(report.scannedAt.formatted(date: .abbreviated, time: .standard), x: left, y: 739, width: 490, size: 11, color: .white) + text("Evidence-based assessment. CVE exposure requires vendor advisory verification.", x: left, y: 797, width: 490, size: 8, color: NSColor(calibratedWhite: 0.75, alpha: 1)) + } + context.endPDFPage() + } + + private func summary() { + begin("Executive summary") + title("Executive Summary", subtitle: "Scope, key results and verification status", chapter: "Executive summary") + paragraph("PHPGuard collected runtime, package, service and investigation evidence from \(report.serverName). These observations describe the checks completed; they are not a certification that the server is secure.", chapter: "Executive summary") + section("Asset and assessment", chapter: "Executive summary") + for (index, row) in [ + ("Server", report.serverName), ("Hostname", report.hostname), ("Operating system", report.os), + ("Scan date", report.scannedAt.formatted(date: .abbreviated, time: .standard)), + ("Host PHP runtimes", "\(report.phpVersions.count)"), + ("Review items", "\(genuineFindings.count)"), + ("Updates recorded", "\(report.availableUpdates.count)") + ].enumerated() { tableRow(row.0, row.1.isEmpty ? "Not reported" : row.1, chapter: "Executive summary", alternate: index.isMultiple(of: 2)) } + section("Assessment position", chapter: "Executive summary") + paragraph("Verification required. Package version strings and available updates do not prove or disprove CVE exposure. Compare installed revisions and container images with applicable vendor advisories, including distribution backports.", chapter: "Executive summary") + section("Coverage limitations", chapter: "Executive summary") + tableRow("Package review", report.packageAuditStatus, chapter: "Executive summary", alternate: true) + tableRow("Docker review", report.dockerAuditStatus, chapter: "Executive summary") + tableRow("Investigation", report.investigationStatus, chapter: "Executive summary", alternate: true) + end() + } + + private func findingsPage() { + begin("Security findings") + title("Findings & Observations", subtitle: "Prioritised recorded results; no inferred vulnerabilities", chapter: "Security findings") + if genuineFindings.isEmpty { paragraph("No actionable findings were recorded by the completed checks. This does not establish an absence of vulnerabilities.", chapter: "Security findings") } + for item in genuineFindings { finding(item) } + section("Triage guidance", chapter: "Security findings") + items(report.securityTriage, chapter: "Security findings", empty: "No triage guidance recorded") + end() + } + + private func environment() { + begin("PHP environment") + title("PHP Environment", subtitle: "Host runtimes, PHP-FPM and web-server integration", chapter: "PHP environment") + section("Host PHP installations", chapter: "PHP environment") + items(report.phpVersions, chapter: "PHP environment") + section("PHP-FPM service observations", chapter: "PHP environment") + items(report.fpmServices, chapter: "PHP environment") + section("Nginx FastCGI upstreams", chapter: "PHP environment") + items(report.nginxUpstreams, chapter: "PHP environment") + section("Available package updates", chapter: "PHP environment") + items(report.availableUpdates, chapter: "PHP environment", empty: "No package updates recorded by this scan (not proof that every package is current).") + end() + } + + private func containers() { + begin("Container assessment") + title("Container Assessment", subtitle: "PHP detection is separated from container execution errors", chapter: "Container assessment") + paragraph("An OCI execution failure or missing PHP executable is not evidence of a PHP installation or a vulnerability. Container image security must be evaluated independently of host APT updates.", chapter: "Container assessment") + let detected = containerResults.filter { $0.state == "PHP reported" }.count + let notDetected = containerResults.filter { $0.state == "PHP CLI not detected" }.count + tableRow("PHP reported", "\(detected) container(s)", chapter: "Container assessment", alternate: true) + tableRow("PHP CLI not detected", "\(notDetected) container(s)", chapter: "Container assessment") + tableRow("Other / unverified", "\(containerResults.count-detected-notDetected) container(s)", chapter: "Container assessment", alternate: true) + section("Container inventory", chapter: "Container assessment") + if containerResults.isEmpty { paragraph("No container runtime entries recorded.", chapter: "Container assessment") } + for item in containerResults { tableRow(item.name, item.state, chapter: "Container assessment") } + section("Recorded container evidence", chapter: "Container assessment") + items(report.dockerPHP, chapter: "Container assessment") + section("Coverage notes", chapter: "Container assessment") + items(report.dockerCoverage, chapter: "Container assessment", empty: report.dockerAuditStatus) + end() + } + + private func actions() { + begin("Remediation and coverage") + title("Recommended Actions", subtitle: "Follow-up checks based on recorded assessment limitations", chapter: "Remediation and coverage") + let recommendations = [ + ("01 Verify advisories", "Match installed PHP package revisions to the applicable Ubuntu or third-party maintainer advisories. Check backported fixes before assigning CVEs."), + ("02 Review PHP containers", "Verify detected PHP versions against their image maintainers' fixed releases. Update and redeploy images separately from host packages."), + ("03 Resolve coverage gaps", "Review incomplete or inaccessible checks, including Nginx error logs, before describing the assessment as comprehensive."), + ("04 Confirm runtime routing", "Check that configured Nginx FastCGI upstreams point to the intended running PHP-FPM services."), + ("05 Validate change activity", "Compare recently modified PHP files with deployment logs, expected maintenance and application release activity.") + ] + for row in recommendations { + section(row.0, chapter: "Remediation and coverage") + paragraph(row.1, chapter: "Remediation and coverage") + } + section("Recorded investigation status", chapter: "Remediation and coverage") + tableRow("Package assessment", report.packageAuditStatus, chapter: "Remediation and coverage", alternate: true) + tableRow("Docker assessment", report.dockerAuditStatus, chapter: "Remediation and coverage") + tableRow("Investigation", report.investigationStatus, chapter: "Remediation and coverage", alternate: true) + end() + } + + private func appendix() { + begin("Technical evidence") + title("Technical Evidence", subtitle: "Collected source observations and package details", chapter: "Technical evidence") + let groups: [(String, [String], String)] = [ + ("Installed packages", report.packageVersions, "No packages recorded"), + ("APT package origins", report.packageOrigins, "No package origins recorded"), + ("Recent PHP file changes", report.recentPHPFiles, "No recent files recorded"), + ("Web error signals", report.webErrorSignals, "No web error signals recorded") + ] + for group in groups { + section(group.0, chapter: "Technical evidence") + items(group.1, chapter: "Technical evidence", empty: group.2) + } + if let error = report.error, !error.isEmpty { + section("Scan errors", chapter: "Technical evidence") + paragraph(error, chapter: "Technical evidence") + } + end() + } + + func write(to url: URL) throws { + let output = NSMutableData() + guard let consumer = CGDataConsumer(data: output as CFMutableData) else { + throw NSError(domain: "PHPGuardPDF", code: 1) + } + var mediaBox = CGRect(x: 0, y: 0, width: w, height: h) + guard let pdf = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { + throw NSError(domain: "PHPGuardPDF", code: 2) + } + context = pdf + cover() + summary() + findingsPage() + environment() + containers() + actions() + appendix() + context.closePDF() + try (output as Data).write(to: url, options: .atomic) + } +} diff --git a/.phpguard-cve-backups/20261022-142736/SSHScanner.swift b/.phpguard-cve-backups/20261022-142736/SSHScanner.swift new file mode 100644 index 0000000..da3f177 --- /dev/null +++ b/.phpguard-cve-backups/20261022-142736/SSHScanner.swift @@ -0,0 +1,501 @@ +import Foundation + +enum ScanError: LocalizedError { + case invalidServer, commandFailed(String) + var errorDescription: String? { + switch self { + case .invalidServer: return "Invalid SSH host, username, port or identity file." + case .commandFailed(let message): return message + } + } +} + +enum SSHScanner { + // Commands are fixed and read-only. User-supplied strings are passed as SSH arguments, never shell source. + static let script = #""" + printf '===HOST===\n'; hostname + printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s + printf '===PHP===\n' + for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do + if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi + done | sort -u + printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true + printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true + printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 2>/dev/null || true + printf '===UPDATES===\n' + if command -v apt >/dev/null 2>&1; then + apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true + else + printf 'Package manager not supported\n' + fi + printf '===PACKAGE_ORIGINS===\n' + if command -v apt-cache >/dev/null 2>&1; then + dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do + [ -n "$pkg" ] || continue + installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true) + candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1) + printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}" + apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /' + done + else + printf 'APT metadata unavailable\n' + fi + printf '===DOCKER===\n' + if ! command -v docker >/dev/null 2>&1; then + printf 'STATUS|not-installed\n' + elif ! docker info >/dev/null 2>&1; then + printf 'STATUS|permission-denied\n' + else + ids=$(docker ps -q 2>/dev/null) + if [ -z "$ids" ]; then printf 'STATUS|no-running-containers\n'; fi + for id in $ids; do + name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,') + image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null) + version=$(docker exec "$id" php -r 'echo PHP_VERSION;' 2>/dev/null) + exit_code=$? + + if [ "$exit_code" -eq 0 ] && + printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$'; then + printf 'PHP|%s|%s|%s\n' "$name" "$image" "$version" + else + printf 'NO_PHP_CLI|%s|%s\n' "$name" "$image" + fi + done + fi + printf '===RECENT_PHP_FILES===\n' + if [ -d /var/www ]; then + find /var/www -type f -name '*.php' -mtime -3 -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40 + else + printf 'Web root unavailable\n' + fi + printf '===WEB_ERROR_SIGNALS===\n' + + # Portable, read-only web-server investigation. + # Never require root access or changes to server permissions. + + webservers="" + if command -v nginx >/dev/null 2>&1 || + [ -d /etc/nginx ]; then + webservers="nginx" + fi + + if command -v apache2 >/dev/null 2>&1 || + command -v httpd >/dev/null 2>&1 || + [ -d /etc/apache2 ] || + [ -d /etc/httpd ]; then + webservers="$webservers apache" + fi + + webservers=$(printf '%s' "$webservers" | sed 's/^ *//') + + if [ -z "$webservers" ]; then + printf 'STATUS|not-applicable|No Nginx or Apache installation detected\n' + else + checked=0 + restricted=0 + missing=0 + total=0 + journal_checked=0 + + for webserver in $webservers; do + case "$webserver" in + nginx) + candidates="/var/log/nginx/error.log" + + # Prefer configured log locations when readable. + if command -v nginx >/dev/null 2>&1; then + config=$(nginx -T 2>/dev/null) + + if [ -z "$config" ]; then + config=$(sudo -n nginx -T 2>/dev/null) + fi + + configured=$(printf '%s\n' "$config" | + sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' | + grep '^/' | + sort -u) + + if [ -n "$configured" ]; then + candidates="$configured" + fi + fi + ;; + apache) + candidates="/var/log/apache2/error.log /var/log/httpd/error_log" + + # Include configured absolute ErrorLog paths. + configured=$( + grep -RhEi '^[[:space:]]*ErrorLog[[:space:]]+/' \ + /etc/apache2 /etc/httpd \ + 2>/dev/null | + awk '{print $2}' | + sort -u + ) + + if [ -n "$configured" ]; then + candidates="$configured" + fi + ;; + esac + + found_log=0 + + for log in $candidates; do + case "$log" in + /*) ;; + *) continue ;; + esac + + total=$((total + 1)) + + if [ ! -e "$log" ]; then + missing=$((missing + 1)) + continue + fi + + found_log=1 + log_data="" + read_ok=0 + + if [ -r "$log" ]; then + log_data=$(tail -n 1000 "$log" 2>/dev/null) + read_ok=$? + elif command -v sudo >/dev/null 2>&1; then + log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null) + read_ok=$? + else + read_ok=1 + fi + + if [ "$read_ok" -eq 0 ]; then + checked=$((checked + 1)) + + printf '%s\n' "$log_data" | + grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' | + tail -20 | + while IFS= read -r entry; do + [ -n "$entry" ] && + printf 'SIGNAL|%s|%s\n' "$log" "$entry" + done + else + restricted=$((restricted + 1)) + fi + done + + # Journal access may work even when log files are restricted. + if [ "$webserver" = "nginx" ]; then + units="nginx" + else + units="apache2 httpd" + fi + + for unit in $units; do + if ! command -v journalctl >/dev/null 2>&1; then + continue + fi + + journal_data=$(journalctl -u "$unit" -n 300 \ + --no-pager --output=cat 2>/dev/null) + journal_rc=$? + + if [ "$journal_rc" -ne 0 ]; then + journal_data=$(sudo -n journalctl -u "$unit" \ + -n 300 --no-pager --output=cat 2>/dev/null) + journal_rc=$? + fi + + if [ "$journal_rc" -eq 0 ] && + [ -n "$journal_data" ] && + [ "$journal_data" != "-- No entries --" ]; then + journal_checked=$((journal_checked + 1)) + + printf '%s\n' "$journal_data" | + grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' | + tail -20 | + while IFS= read -r entry; do + [ -n "$entry" ] && + printf 'SIGNAL|journal:%s|%s\n' "$unit" "$entry" + done + fi + done + done + + if [ "$restricted" -gt 0 ]; then + printf 'STATUS|limited-access|servers=%s|logs_checked=%s|restricted=%s|journal_sources=%s\n' \ + "$webservers" "$checked" "$restricted" "$journal_checked" + elif [ "$checked" -gt 0 ]; then + printf 'STATUS|complete|servers=%s|logs_checked=%s|journal_sources=%s\n' \ + "$webservers" "$checked" "$journal_checked" + elif [ "$journal_checked" -gt 0 ]; then + printf 'STATUS|journal-inspected|servers=%s|journal_sources=%s\n' \ + "$webservers" "$journal_checked" + else + printf 'STATUS|no-accessible-logs|servers=%s|logs_found=%s|missing=%s\n' \ + "$webservers" "$found_log" "$missing" + fi + fi + printf '===END===\n' + """# + + static func scan(_ server: Server) async throws -> AuditResult { + try await Task.detached(priority: .userInitiated) { try run(server) }.value + } + + private static func run(_ server: Server) throws -> AuditResult { + let validName = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/ + let validHost = /^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/ + guard server.user.wholeMatch(of: validName) != nil, + server.host.wholeMatch(of: validHost) != nil, + (1...65535).contains(server.port), + !server.identityFile.contains("\n") else { throw ScanError.invalidServer } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + var args = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(server.port)] + if !server.identityFile.isEmpty { + args += ["-i", (server.identityFile as NSString).expandingTildeInPath] + } + args += ["--", "\(server.user)@\(server.host)", "sh -s"] + process.arguments = args + let input = Pipe(), output = Pipe(), errors = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = errors + try process.run() + input.fileHandleForWriting.write(Data(script.utf8)) + try? input.fileHandleForWriting.close() + // Both pipes are drained concurrently to avoid deadlocks on large output. + let group = DispatchGroup() + var stdout = Data(), stderr = Data() + group.enter() + DispatchQueue.global().async { stdout = output.fileHandleForReading.readDataToEndOfFile(); group.leave() } + group.enter() + DispatchQueue.global().async { stderr = errors.fileHandleForReading.readDataToEndOfFile(); group.leave() } + process.waitUntilExit() + group.wait() + guard process.terminationStatus == 0 else { + throw ScanError.commandFailed(String(data: stderr, encoding: .utf8) ?? "SSH failed (\(process.terminationStatus))") + } + return parse(String(decoding: stdout, as: UTF8.self), server: server) + } + + static func parse(_ raw: String, server: Server) -> AuditResult { + var result = AuditResult(serverID: server.id, serverName: server.name) + result.rawOutput = raw + var section = "" + var fields: [String: [String]] = [:] + for line in raw.components(separatedBy: .newlines) { + if line.hasPrefix("===") && line.hasSuffix("===") { section = String(line.dropFirst(3).dropLast(3)); continue } + if !line.trimmingCharacters(in: .whitespaces).isEmpty { fields[section, default: []].append(line) } + } + result.hostname = fields["HOST"]?.first ?? "Unknown" + result.os = fields["OS"]?.first ?? "Unknown" + result.phpVersions = fields["PHP"] ?? [] + result.fpmServices = fields["FPM"] ?? [] + result.nginxUpstreams = fields["NGINX"] ?? [] + result.packageVersions = fields["PACKAGES"] ?? [] + result.availableUpdates = fields["UPDATES"] ?? [] + result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? [] + result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" } + let nginxLines = fields["WEB_ERROR_SIGNALS"] ?? [] + + result.webErrorSignals = nginxLines.compactMap { line in + guard line.hasPrefix("SIGNAL|") else { return nil } + return String(line.dropFirst("SIGNAL|".count)) + } + + if let status = nginxLines.first(where: { $0.hasPrefix("STATUS|") }) { + let parts = status.split(separator: "|").map(String.init) + let kind = parts.count > 1 ? parts[1] : "" + let details = parts.dropFirst(2).joined(separator: ", ") + + switch kind { + case "not-applicable": + result.investigationStatus = + "Not applicable: No Nginx or Apache installation detected" + case "limited-access": + result.investigationStatus = + "Limited access: Some web-server logs require additional permissions (" + details + ")" + case "no-accessible-logs": + result.investigationStatus = + "Limited coverage: No accessible web-server logs (" + details + ")" + case "complete": + result.investigationStatus = + "Complete: Web-server error logs inspected (" + details + ")" + case "journal-inspected": + result.investigationStatus = + "Complete: Web-server systemd journal inspected" + case "partial": + result.investigationStatus = + "Partial: Nginx logs inspected with limitations (" + details + ")" + case "unavailable": + result.investigationStatus = + "Partial: Nginx logging unavailable (" + details + ")" + default: + result.investigationStatus = + "Partial: Nginx inspection status unknown" + } + } else if nginxLines.contains(where: { + $0.hasPrefix("Nginx error log unavailable") + }) { + result.investigationStatus = + "Partial: Nginx error log inaccessible or absent" + } else { + result.investigationStatus = + "Partial: Nginx log inspection status not reported" + } + let dockerLines = fields["DOCKER"] ?? [] + result.dockerCoverage = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }.map { line in + let parts = line.split(separator: "|", omittingEmptySubsequences: false) + return parts.count == 3 ? "\(parts[1]) [\(parts[2])]: PHP CLI not detected (not proof PHP is absent)" : line + } + result.dockerPHP = dockerLines.filter { $0.hasPrefix("PHP|") }.map { line in + let fields = line.split(separator: "|", omittingEmptySubsequences: false) + return fields.count == 4 ? "\(fields[1]) [\(fields[2])] PHP \(fields[3])" : line + } + let noPHP = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") } + let dockerStatus = dockerLines.first(where: { $0.hasPrefix("STATUS|") }) + if let dockerStatus { + switch dockerStatus { + case "STATUS|permission-denied": result.dockerAuditStatus = "Docker inaccessible: permission or daemon error" + case "STATUS|not-installed": result.dockerAuditStatus = "Docker not installed" + case "STATUS|no-running-containers": result.dockerAuditStatus = "No running containers" + default: result.dockerAuditStatus = "Docker inspection incomplete" + } + } else { + result.dockerAuditStatus = "\(result.dockerPHP.count) PHP runtimes; \(noPHP.count) containers without PHP CLI (not a security finding)" + } + result.packageAuditStatus = result.packageOrigins.contains("APT metadata unavailable") + ? "APT package provenance unavailable" + : "Installed and candidate package versions collected; vendor CVE fix status requires advisory verification" + // Recommendations are derived from this server's collected evidence. + var actions: [String] = [] + + if !result.availableUpdates.isEmpty { + actions.append( + "PRIORITY: Review \\(result.availableUpdates.count) available PHP package update(s). " + + "Check the applicable vendor advisories before applying changes." + ) + } + + if result.phpVersions.isEmpty { + actions.append( + "COVERAGE: No host PHP CLI was detected in standard locations. " + + "Check custom installations and application-specific runtimes." + ) + } + + if !result.dockerPHP.isEmpty { + actions.append( + "CONTAINERS: Review \\(result.dockerPHP.count) detected PHP runtime(s) " + + "against their image maintainers' current security releases. " + + "Host package updates do not update container images." + ) + } + + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + actions.append( + "COVERAGE: Docker inspection failed. Verify container inventory access " + + "before treating this assessment as complete." + ) + } + + if result.packageAuditStatus.contains("unavailable") { + actions.append( + "COVERAGE: PHP package provenance could not be established. " + + "Verify package repositories and installed revisions." + ) + } + + + if !result.webErrorSignals.isEmpty { + let blocked = result.webErrorSignals.filter { + $0.localizedCaseInsensitiveContains("access forbidden by rule") + }.count + + let other = result.webErrorSignals.count - blocked + + if other > 0 { + actions.append( + "INVESTIGATE: Review \\(other) web-server error signal(s) " + + "for application failures, PHP errors or abnormal behaviour." + ) + } + + if blocked > 0 { + actions.append( + "INFORMATION: Nginx blocked \\(blocked) suspicious request(s). " + + "Confirm access-control rules remain effective. " + + "Blocked requests alone do not establish a compromise." + ) + } + } + + if !result.recentPHPFiles.isEmpty { + actions.append( + "VERIFY: \\(result.recentPHPFiles.count) recently modified PHP file(s) " + + "were identified. Compare them against expected deployments." + ) + } + + if actions.isEmpty { + actions.append( + "No immediate follow-up actions were identified by the collected indicators. " + + "Continue monitoring and verify PHP security advisories independently." + ) + } + + result.securityTriage = actions + + if result.phpVersions.isEmpty { + result.findings.append(Finding(severity: "Info", title: "No PHP CLI detected", detail: "No PHP binary found in standard paths; this does not exclude bundled runtimes or containers.")) + } + for line in result.phpVersions { + let parts = line.split(separator: " ") + guard let version = parts.last, let major = Int(version.split(separator: ".").first ?? "") else { continue } + if major < 8 { + result.findings.append(Finding(severity: "Warning", title: "Legacy PHP detected", detail: "\(line). Verify vendor support and distribution security maintenance.")) + } + } + for line in result.packageOrigins where line.contains("installed=") && line.contains("candidate=") { + let parts = line.split(separator: " ") + guard let name = parts.first, name.hasPrefix("php") else { continue } + let installed = parts.first(where: { $0.hasPrefix("installed=") }).map { String($0.dropFirst(10)) } ?? "" + let candidate = parts.first(where: { $0.hasPrefix("candidate=") }).map { String($0.dropFirst(10)) } ?? "" + if !installed.isEmpty && installed == candidate { + // Equal revisions indicate no available APT upgrade, not confirmed CVE remediation. + continue + } + if !installed.isEmpty && !candidate.isEmpty && candidate != "(none)" { + result.findings.append(Finding(severity: "Info", title: "Package revision review", detail: "\(name): installed \(installed), candidate \(candidate). Confirm with vendor advisory and dpkg version ordering.")) + } + } + for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") { + result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure.")) + } + if result.packageOrigins.contains("APT metadata unavailable") { + result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified.")) + } + for line in result.dockerPHP { + if line.contains("PHP 8.5.10") { + result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure.")) + } + } + for line in result.dockerPHP { + result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP.")) + } + if !result.dockerPHP.isEmpty { + result.findings.append(Finding(severity: "Info", title: "Container update workflow", detail: "PHP container images must be updated and redeployed separately. Review image digests, upstream release notes, and image-specific security fixes before changing production services.")) + } + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + result.findings.append(Finding(severity: "Warning", title: "Docker coverage unavailable", detail: result.dockerAuditStatus)) + } + if !result.webErrorSignals.isEmpty { + result.findings.append(Finding(severity: "Warning", title: "Web server error signals", detail: "\(result.webErrorSignals.count) potentially relevant log entries. Review context; errors alone do not establish an attack.")) + } + if !result.recentPHPFiles.isEmpty { + result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments.")) + } + result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan.")) + return result + } +} diff --git a/.phpguard-cve-backups/20261022-142736/VulnerabilityService.swift b/.phpguard-cve-backups/20261022-142736/VulnerabilityService.swift new file mode 100644 index 0000000..39dd078 --- /dev/null +++ b/.phpguard-cve-backups/20261022-142736/VulnerabilityService.swift @@ -0,0 +1,120 @@ +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 } + } +} diff --git a/PHPGuard/AuditStore.swift b/PHPGuard/AuditStore.swift index b5f13cd..2f78bb7 100644 --- a/PHPGuard/AuditStore.swift +++ b/PHPGuard/AuditStore.swift @@ -102,7 +102,44 @@ import SwiftUI scanProgressVisible = false } } - do { results.insert(try await SSHScanner.scan(server), at: 0) } + do { + var report = try await SSHScanner.scan(server) + report.cveAssessmentStatus = "Checking NVD PHP product applicability" + let runtimeLines = report.phpVersions + report.dockerPHP + var versions: [String: String] = [:] + for line in runtimeLines { + let tokens = line.split(whereSeparator: { $0.isWhitespace }) + guard let token = tokens.last else { continue } + let version = String(token) + let parts = version.split(separator: ".") + guard parts.count >= 3, + parts.prefix(3).allSatisfy({ Int($0) != nil }) else { continue } + let canonical = parts.prefix(3).joined(separator: ".") + versions[canonical] = "PHP " + canonical + } + if versions.isEmpty { + report.cveAssessmentStatus = "Not assessed: no parseable PHP runtime version" + } else { + var all: [CVEServerAssessment] = [] + var failures: [String] = [] + // Sequential requests respect unauthenticated NVD API rate limits. + for version in versions.keys.sorted() { + do { + let records = try await VulnerabilityService.assessPHPVersion( + version, runtime: versions[version] ?? version) + all.append(contentsOf: records) + } catch { + failures.append("PHP \(version): \(error.localizedDescription)") + } + } + report.cveAssessments = all.sorted { $0.cveID > $1.cveID } + report.cveAssessmentStatus = failures.isEmpty + ? "Upstream CPE assessment complete; distribution fixes unverified" + : "Partial/unknown: " + failures.joined(separator: "; ") + report.cveAssessedAt = Date() + } + results.insert(report, at: 0) + } catch { var failed = AuditResult(serverID: server.id, serverName: server.name) failed.error = error.localizedDescription diff --git a/PHPGuard/ContentView.swift b/PHPGuard/ContentView.swift index 164e934..b075591 100644 --- a/PHPGuard/ContentView.swift +++ b/PHPGuard/ContentView.swift @@ -81,7 +81,7 @@ struct ContentView: View { 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 upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.") + 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") } @@ -128,10 +128,12 @@ struct ContentView: View { .padding(16) .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) Text("OS: \(result.os) • Host: \(result.hostname)") - dataSection("PHP installations", result.phpVersions) - dataSection("PHP-FPM services", result.fpmServices) - dataSection("Nginx FastCGI routes", result.nginxUpstreams) - dataSection("Installed packages", result.packageVersions) + inventoryCards( + php: result.phpVersions, + fpm: result.fpmServices, + nginx: result.nginxUpstreams, + packages: result.packageVersions + ) dataSection("Available PHP package upgrades", result.availableUpdates) dataSection("APT package origins and candidate revisions", result.packageOrigins) Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) @@ -139,6 +141,30 @@ struct ContentView: View { 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 upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.") + .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) + }.padding(10) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10)) + } + } HStack { Text("Security findings").font(.title2.bold()) Spacer() @@ -162,6 +188,340 @@ struct ContentView: View { }.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 { + 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() + } + } + } + } + } + } + + 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 { diff --git a/PHPGuard/ContentView.swift.backup-20261022-135436 b/PHPGuard/ContentView.swift.backup-20261022-135436 new file mode 100644 index 0000000..164e934 --- /dev/null +++ b/PHPGuard/ContentView.swift.backup-20261022-135436 @@ -0,0 +1,461 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @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 upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.") + .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)") + dataSection("PHP installations", result.phpVersions) + dataSection("PHP-FPM services", result.fpmServices) + dataSection("Nginx FastCGI routes", result.nginxUpstreams) + dataSection("Installed packages", result.packageVersions) + dataSection("Available PHP package upgrades", result.availableUpdates) + dataSection("APT package origins and candidate revisions", result.packageOrigins) + 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) + 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) + } + } + 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("Scanning") + .font(.subheadline.weight(.semibold)) + + Text("Collecting PHP, package, Docker and web-server evidence") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView() + .progressViewStyle(.linear) + .tint(Color.accentColor) + + 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/PHPGuard/Models.swift b/PHPGuard/Models.swift index 0f8b700..6098e72 100644 --- a/PHPGuard/Models.swift +++ b/PHPGuard/Models.swift @@ -35,13 +35,16 @@ struct AuditResult: Codable, Identifiable { var packageAuditStatus = "Not checked" var dockerAuditStatus = "Not checked" var findings: [Finding] = [] + var cveAssessments: [CVEServerAssessment] = [] + var cveAssessmentStatus = "Not assessed" + var cveAssessedAt: Date? = nil var recentPHPFiles: [String] = [] var webErrorSignals: [String] = [] var investigationStatus = "Not checked" var rawOutput = "" var error: String? = nil - enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, availableUpdates, packageOrigins, dockerPHP, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, rawOutput, error, id } + enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, availableUpdates, packageOrigins, dockerPHP, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, cveAssessments, cveAssessmentStatus, cveAssessedAt, rawOutput, error, id } init(serverID: UUID, serverName: String) { self.serverID = serverID; self.serverName = serverName } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -62,6 +65,9 @@ struct AuditResult: Codable, Identifiable { packageAuditStatus = try container.decodeIfPresent(String.self, forKey: .packageAuditStatus) ?? "Not checked" dockerAuditStatus = try container.decodeIfPresent(String.self, forKey: .dockerAuditStatus) ?? "Not checked" findings = try container.decodeIfPresent([Finding].self, forKey: .findings) ?? [] + cveAssessments = try container.decodeIfPresent([CVEServerAssessment].self, forKey: .cveAssessments) ?? [] + cveAssessmentStatus = try container.decodeIfPresent(String.self, forKey: .cveAssessmentStatus) ?? "Not assessed" + cveAssessedAt = try container.decodeIfPresent(Date.self, forKey: .cveAssessedAt) recentPHPFiles = try container.decodeIfPresent([String].self, forKey: .recentPHPFiles) ?? [] webErrorSignals = try container.decodeIfPresent([String].self, forKey: .webErrorSignals) ?? [] investigationStatus = try container.decodeIfPresent(String.self, forKey: .investigationStatus) ?? "Not checked" @@ -88,6 +94,9 @@ struct AuditResult: Codable, Identifiable { try container.encode(packageAuditStatus, forKey: .packageAuditStatus) try container.encode(dockerAuditStatus, forKey: .dockerAuditStatus) try container.encode(findings, forKey: .findings) + try container.encode(cveAssessments, forKey: .cveAssessments) + try container.encode(cveAssessmentStatus, forKey: .cveAssessmentStatus) + try container.encodeIfPresent(cveAssessedAt, forKey: .cveAssessedAt) try container.encode(recentPHPFiles, forKey: .recentPHPFiles) try container.encode(webErrorSignals, forKey: .webErrorSignals) try container.encode(investigationStatus, forKey: .investigationStatus) @@ -105,3 +114,16 @@ struct CVERecord: Codable, Identifiable { var score: Double? var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + id } } + +// NVD upstream applicability is not proof of vulnerability in distribution-patched packages. +struct CVEServerAssessment: Codable, Identifiable { + var id: String { cveID + "|" + runtime } + var cveID: String + var runtime: String + var severity: String + var score: Double? + var summary: String + var status: String + var evidence: String + var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + cveID } +} diff --git a/PHPGuard/ReportPDF.swift b/PHPGuard/ReportPDF.swift index b063d6a..eb7d789 100644 --- a/PHPGuard/ReportPDF.swift +++ b/PHPGuard/ReportPDF.swift @@ -279,6 +279,23 @@ private final class ProfessionalReport { end() } + private func cveAssessmentPage() { + begin("CVE assessment") + title("Upstream CVE Assessment", subtitle: "NVD PHP product CPE matches; not verified server exposure", chapter: "CVE assessment") + paragraph(report.cveAssessmentStatus, chapter: "CVE assessment") + paragraph("Upstream version matching does not account for Ubuntu/Debian backported patches, third-party PHP builds, Composer dependencies, PHP extensions or container image security. Treat matches as potentially affected until verified against maintainer advisories.", chapter: "CVE assessment") + section("Potential upstream CVE candidates (\(report.cveAssessments.count))", chapter: "CVE assessment") + if report.cveAssessments.isEmpty { + paragraph("No CVE candidates recorded; scan coverage may be incomplete or unavailable. This is not proof of zero vulnerabilities.", chapter: "CVE assessment") + } + for item in report.cveAssessments { + tableRow(item.cveID + " (" + item.severity + ")", item.runtime + " — " + item.status, chapter: "CVE assessment") + paragraph(item.summary, chapter: "CVE assessment", size: 8.5) + paragraph(item.evidence, chapter: "CVE assessment", size: 8) + } + end() + } + private func environment() { begin("PHP environment") title("PHP Environment", subtitle: "Host runtimes, PHP-FPM and web-server integration", chapter: "PHP environment") @@ -366,6 +383,7 @@ private final class ProfessionalReport { cover() summary() findingsPage() + cveAssessmentPage() environment() containers() actions() diff --git a/PHPGuard/SSHScanner.swift b/PHPGuard/SSHScanner.swift index cff3289..0c172aa 100644 --- a/PHPGuard/SSHScanner.swift +++ b/PHPGuard/SSHScanner.swift @@ -21,7 +21,7 @@ enum SSHScanner { done | sort -u printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true - printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 2>/dev/null || true + printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)? ' | sort -u || true printf '===UPDATES===\n' if command -v apt >/dev/null 2>&1; then apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true @@ -30,7 +30,7 @@ enum SSHScanner { fi printf '===PACKAGE_ORIGINS===\n' if command -v apt-cache >/dev/null 2>&1; then - dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do + dpkg-query -W -f='${Package}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)?$' | sort -u | while IFS= read -r pkg; do [ -n "$pkg" ] || continue installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true) candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1) @@ -70,80 +70,169 @@ enum SSHScanner { fi printf '===WEB_ERROR_SIGNALS===\n' - nginx_logs=$( - if command -v nginx >/dev/null 2>&1; then - if sudo -n true >/dev/null 2>&1; then - sudo -n nginx -T 2>/dev/null - else - nginx -T 2>/dev/null - fi - fi | - sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' | - grep '^/' | - sort -u - ) + # Portable, read-only web-server investigation. + # Never require root access or changes to server permissions. - [ -n "$nginx_logs" ] || nginx_logs=/var/log/nginx/error.log + webservers="" + if command -v nginx >/dev/null 2>&1 || + [ -d /etc/nginx ]; then + webservers="nginx" + fi - checked=0 - missing=0 - inaccessible=0 - total=0 + if command -v apache2 >/dev/null 2>&1 || + command -v httpd >/dev/null 2>&1 || + [ -d /etc/apache2 ] || + [ -d /etc/httpd ]; then + webservers="$webservers apache" + fi - for log in $nginx_logs; do - total=$((total + 1)) + webservers=$(printf '%s' "$webservers" | sed 's/^ *//') - if [ ! -e "$log" ]; then - missing=$((missing + 1)) - continue - fi - - if [ -r "$log" ]; then - log_data=$(tail -n 1000 "$log" 2>/dev/null) - checked=$((checked + 1)) - elif sudo -n test -r "$log" >/dev/null 2>&1; then - log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null) - checked=$((checked + 1)) - else - inaccessible=$((inaccessible + 1)) - continue - fi - - printf '%s\n' "$log_data" | - grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule' | - tail -20 | - while IFS= read -r entry; do - [ -n "$entry" ] && - printf 'SIGNAL|%s|%s\n' "$log" "$entry" - done - done - - if [ "$checked" -eq 0 ]; then - if sudo -n true >/dev/null 2>&1; then - journal_data=$(sudo -n journalctl -u nginx -n 1000 --no-pager 2>/dev/null) - else - journal_data=$(journalctl -u nginx -n 1000 --no-pager 2>/dev/null) - fi - - if [ -n "$journal_data" ] && - [ "$journal_data" != "-- No entries --" ]; then - printf 'STATUS|journal-inspected\n' - printf '%s\n' "$journal_data" | - grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule' | - tail -30 | - while IFS= read -r entry; do - [ -n "$entry" ] && - printf 'SIGNAL|journal|%s\n' "$entry" - done - else - printf 'STATUS|unavailable|logs=%s|missing=%s|inaccessible=%s\n' \ - "$total" "$missing" "$inaccessible" - fi - elif [ "$missing" -gt 0 ] || [ "$inaccessible" -gt 0 ]; then - printf 'STATUS|partial|checked=%s|missing=%s|inaccessible=%s\n' \ - "$checked" "$missing" "$inaccessible" + if [ -z "$webservers" ]; then + printf 'STATUS|not-applicable|No Nginx or Apache installation detected\n' else - printf 'STATUS|complete|checked=%s\n' "$checked" + checked=0 + restricted=0 + missing=0 + total=0 + journal_checked=0 + + for webserver in $webservers; do + case "$webserver" in + nginx) + candidates="/var/log/nginx/error.log" + + # Prefer configured log locations when readable. + if command -v nginx >/dev/null 2>&1; then + config=$(nginx -T 2>/dev/null) + + if [ -z "$config" ]; then + config=$(sudo -n nginx -T 2>/dev/null) + fi + + configured=$(printf '%s\n' "$config" | + sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' | + grep '^/' | + sort -u) + + if [ -n "$configured" ]; then + candidates="$configured" + fi + fi + ;; + apache) + candidates="/var/log/apache2/error.log /var/log/httpd/error_log" + + # Include configured absolute ErrorLog paths. + configured=$( + grep -RhEi '^[[:space:]]*ErrorLog[[:space:]]+/' \ + /etc/apache2 /etc/httpd \ + 2>/dev/null | + awk '{print $2}' | + sort -u + ) + + if [ -n "$configured" ]; then + candidates="$configured" + fi + ;; + esac + + found_log=0 + + for log in $candidates; do + case "$log" in + /*) ;; + *) continue ;; + esac + + total=$((total + 1)) + + if [ ! -e "$log" ]; then + missing=$((missing + 1)) + continue + fi + + found_log=1 + log_data="" + read_ok=0 + + if [ -r "$log" ]; then + log_data=$(tail -n 1000 "$log" 2>/dev/null) + read_ok=$? + elif command -v sudo >/dev/null 2>&1; then + log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null) + read_ok=$? + else + read_ok=1 + fi + + if [ "$read_ok" -eq 0 ]; then + checked=$((checked + 1)) + + printf '%s\n' "$log_data" | + grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' | + tail -20 | + while IFS= read -r entry; do + [ -n "$entry" ] && + printf 'SIGNAL|%s|%s\n' "$log" "$entry" + done + else + restricted=$((restricted + 1)) + fi + done + + # Journal access may work even when log files are restricted. + if [ "$webserver" = "nginx" ]; then + units="nginx" + else + units="apache2 httpd" + fi + + for unit in $units; do + if ! command -v journalctl >/dev/null 2>&1; then + continue + fi + + journal_data=$(journalctl -u "$unit" -n 300 \ + --no-pager --output=cat 2>/dev/null) + journal_rc=$? + + if [ "$journal_rc" -ne 0 ]; then + journal_data=$(sudo -n journalctl -u "$unit" \ + -n 300 --no-pager --output=cat 2>/dev/null) + journal_rc=$? + fi + + if [ "$journal_rc" -eq 0 ] && + [ -n "$journal_data" ] && + [ "$journal_data" != "-- No entries --" ]; then + journal_checked=$((journal_checked + 1)) + + printf '%s\n' "$journal_data" | + grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' | + tail -20 | + while IFS= read -r entry; do + [ -n "$entry" ] && + printf 'SIGNAL|journal:%s|%s\n' "$unit" "$entry" + done + fi + done + done + + if [ "$restricted" -gt 0 ]; then + printf 'STATUS|limited-access|servers=%s|logs_checked=%s|restricted=%s|journal_sources=%s\n' \ + "$webservers" "$checked" "$restricted" "$journal_checked" + elif [ "$checked" -gt 0 ]; then + printf 'STATUS|complete|servers=%s|logs_checked=%s|journal_sources=%s\n' \ + "$webservers" "$checked" "$journal_checked" + elif [ "$journal_checked" -gt 0 ]; then + printf 'STATUS|journal-inspected|servers=%s|journal_sources=%s\n' \ + "$webservers" "$journal_checked" + else + printf 'STATUS|no-accessible-logs|servers=%s|logs_found=%s|missing=%s\n' \ + "$webservers" "$found_log" "$missing" + fi fi printf '===END===\n' """# @@ -220,12 +309,21 @@ enum SSHScanner { let details = parts.dropFirst(2).joined(separator: ", ") switch kind { + case "not-applicable": + result.investigationStatus = + "Not applicable: No Nginx or Apache installation detected" + case "limited-access": + result.investigationStatus = + "Limited access: Some web-server logs require additional permissions (" + details + ")" + case "no-accessible-logs": + result.investigationStatus = + "Limited coverage: No accessible web-server logs (" + details + ")" case "complete": result.investigationStatus = - "Complete: Nginx error logs inspected (" + details + ")" + "Complete: Web-server error logs inspected (" + details + ")" case "journal-inspected": result.investigationStatus = - "Complete: Nginx systemd journal inspected" + "Complete: Web-server systemd journal inspected" case "partial": result.investigationStatus = "Partial: Nginx logs inspected with limitations (" + details + ")" @@ -274,7 +372,7 @@ enum SSHScanner { if !result.availableUpdates.isEmpty { actions.append( - "PRIORITY: Review \\(result.availableUpdates.count) available PHP package update(s). " + + "PRIORITY: Review \(result.availableUpdates.count) available PHP package update(s). " + "Check the applicable vendor advisories before applying changes." ) } @@ -288,7 +386,7 @@ enum SSHScanner { if !result.dockerPHP.isEmpty { actions.append( - "CONTAINERS: Review \\(result.dockerPHP.count) detected PHP runtime(s) " + + "CONTAINERS: Review \(result.dockerPHP.count) detected PHP runtime(s) " + "against their image maintainers' current security releases. " + "Host package updates do not update container images." ) @@ -308,12 +406,6 @@ enum SSHScanner { ) } - if result.investigationStatus.hasPrefix("Partial") { - actions.append( - "COVERAGE: Web-server investigation is incomplete. " + - result.investigationStatus - ) - } if !result.webErrorSignals.isEmpty { let blocked = result.webErrorSignals.filter { @@ -324,14 +416,14 @@ enum SSHScanner { if other > 0 { actions.append( - "INVESTIGATE: Review \\(other) web-server error signal(s) " + + "INVESTIGATE: Review \(other) web-server error signal(s) " + "for application failures, PHP errors or abnormal behaviour." ) } if blocked > 0 { actions.append( - "INFORMATION: Nginx blocked \\(blocked) suspicious request(s). " + + "INFORMATION: Nginx blocked \(blocked) suspicious request(s). " + "Confirm access-control rules remain effective. " + "Blocked requests alone do not establish a compromise." ) @@ -340,7 +432,7 @@ enum SSHScanner { if !result.recentPHPFiles.isEmpty { actions.append( - "VERIFY: \\(result.recentPHPFiles.count) recently modified PHP file(s) " + + "VERIFY: \(result.recentPHPFiles.count) recently modified PHP file(s) " + "were identified. Compare them against expected deployments." ) } @@ -383,11 +475,6 @@ enum SSHScanner { if result.packageOrigins.contains("APT metadata unavailable") { result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified.")) } - for line in result.dockerPHP { - if line.contains("PHP 8.5.10") { - result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure.")) - } - } for line in result.dockerPHP { result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP.")) } @@ -403,9 +490,6 @@ enum SSHScanner { if !result.recentPHPFiles.isEmpty { result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments.")) } - if result.investigationStatus.hasPrefix("Partial") { - result.findings.append(Finding(severity: "Info", title: "Investigation coverage limited", detail: result.investigationStatus)) - } result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan.")) return result } diff --git a/PHPGuard/SSHScanner.swift.backup-20261022-134944 b/PHPGuard/SSHScanner.swift.backup-20261022-134944 new file mode 100644 index 0000000..cff3289 --- /dev/null +++ b/PHPGuard/SSHScanner.swift.backup-20261022-134944 @@ -0,0 +1,412 @@ +import Foundation + +enum ScanError: LocalizedError { + case invalidServer, commandFailed(String) + var errorDescription: String? { + switch self { + case .invalidServer: return "Invalid SSH host, username, port or identity file." + case .commandFailed(let message): return message + } + } +} + +enum SSHScanner { + // Commands are fixed and read-only. User-supplied strings are passed as SSH arguments, never shell source. + static let script = #""" + printf '===HOST===\n'; hostname + printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s + printf '===PHP===\n' + for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do + if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi + done | sort -u + printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true + printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true + printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 2>/dev/null || true + printf '===UPDATES===\n' + if command -v apt >/dev/null 2>&1; then + apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true + else + printf 'Package manager not supported\n' + fi + printf '===PACKAGE_ORIGINS===\n' + if command -v apt-cache >/dev/null 2>&1; then + dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do + [ -n "$pkg" ] || continue + installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true) + candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1) + printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}" + apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /' + done + else + printf 'APT metadata unavailable\n' + fi + printf '===DOCKER===\n' + if ! command -v docker >/dev/null 2>&1; then + printf 'STATUS|not-installed\n' + elif ! docker info >/dev/null 2>&1; then + printf 'STATUS|permission-denied\n' + else + ids=$(docker ps -q 2>/dev/null) + if [ -z "$ids" ]; then printf 'STATUS|no-running-containers\n'; fi + for id in $ids; do + name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,') + image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null) + version=$(docker exec "$id" php -r 'echo PHP_VERSION;' 2>/dev/null) + exit_code=$? + + if [ "$exit_code" -eq 0 ] && + printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$'; then + printf 'PHP|%s|%s|%s\n' "$name" "$image" "$version" + else + printf 'NO_PHP_CLI|%s|%s\n' "$name" "$image" + fi + done + fi + printf '===RECENT_PHP_FILES===\n' + if [ -d /var/www ]; then + find /var/www -type f -name '*.php' -mtime -3 -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40 + else + printf 'Web root unavailable\n' + fi + printf '===WEB_ERROR_SIGNALS===\n' + + nginx_logs=$( + if command -v nginx >/dev/null 2>&1; then + if sudo -n true >/dev/null 2>&1; then + sudo -n nginx -T 2>/dev/null + else + nginx -T 2>/dev/null + fi + fi | + sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' | + grep '^/' | + sort -u + ) + + [ -n "$nginx_logs" ] || nginx_logs=/var/log/nginx/error.log + + checked=0 + missing=0 + inaccessible=0 + total=0 + + for log in $nginx_logs; do + total=$((total + 1)) + + if [ ! -e "$log" ]; then + missing=$((missing + 1)) + continue + fi + + if [ -r "$log" ]; then + log_data=$(tail -n 1000 "$log" 2>/dev/null) + checked=$((checked + 1)) + elif sudo -n test -r "$log" >/dev/null 2>&1; then + log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null) + checked=$((checked + 1)) + else + inaccessible=$((inaccessible + 1)) + continue + fi + + printf '%s\n' "$log_data" | + grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule' | + tail -20 | + while IFS= read -r entry; do + [ -n "$entry" ] && + printf 'SIGNAL|%s|%s\n' "$log" "$entry" + done + done + + if [ "$checked" -eq 0 ]; then + if sudo -n true >/dev/null 2>&1; then + journal_data=$(sudo -n journalctl -u nginx -n 1000 --no-pager 2>/dev/null) + else + journal_data=$(journalctl -u nginx -n 1000 --no-pager 2>/dev/null) + fi + + if [ -n "$journal_data" ] && + [ "$journal_data" != "-- No entries --" ]; then + printf 'STATUS|journal-inspected\n' + printf '%s\n' "$journal_data" | + grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule' | + tail -30 | + while IFS= read -r entry; do + [ -n "$entry" ] && + printf 'SIGNAL|journal|%s\n' "$entry" + done + else + printf 'STATUS|unavailable|logs=%s|missing=%s|inaccessible=%s\n' \ + "$total" "$missing" "$inaccessible" + fi + elif [ "$missing" -gt 0 ] || [ "$inaccessible" -gt 0 ]; then + printf 'STATUS|partial|checked=%s|missing=%s|inaccessible=%s\n' \ + "$checked" "$missing" "$inaccessible" + else + printf 'STATUS|complete|checked=%s\n' "$checked" + fi + printf '===END===\n' + """# + + static func scan(_ server: Server) async throws -> AuditResult { + try await Task.detached(priority: .userInitiated) { try run(server) }.value + } + + private static func run(_ server: Server) throws -> AuditResult { + let validName = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/ + let validHost = /^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/ + guard server.user.wholeMatch(of: validName) != nil, + server.host.wholeMatch(of: validHost) != nil, + (1...65535).contains(server.port), + !server.identityFile.contains("\n") else { throw ScanError.invalidServer } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + var args = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(server.port)] + if !server.identityFile.isEmpty { + args += ["-i", (server.identityFile as NSString).expandingTildeInPath] + } + args += ["--", "\(server.user)@\(server.host)", "sh -s"] + process.arguments = args + let input = Pipe(), output = Pipe(), errors = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = errors + try process.run() + input.fileHandleForWriting.write(Data(script.utf8)) + try? input.fileHandleForWriting.close() + // Both pipes are drained concurrently to avoid deadlocks on large output. + let group = DispatchGroup() + var stdout = Data(), stderr = Data() + group.enter() + DispatchQueue.global().async { stdout = output.fileHandleForReading.readDataToEndOfFile(); group.leave() } + group.enter() + DispatchQueue.global().async { stderr = errors.fileHandleForReading.readDataToEndOfFile(); group.leave() } + process.waitUntilExit() + group.wait() + guard process.terminationStatus == 0 else { + throw ScanError.commandFailed(String(data: stderr, encoding: .utf8) ?? "SSH failed (\(process.terminationStatus))") + } + return parse(String(decoding: stdout, as: UTF8.self), server: server) + } + + static func parse(_ raw: String, server: Server) -> AuditResult { + var result = AuditResult(serverID: server.id, serverName: server.name) + result.rawOutput = raw + var section = "" + var fields: [String: [String]] = [:] + for line in raw.components(separatedBy: .newlines) { + if line.hasPrefix("===") && line.hasSuffix("===") { section = String(line.dropFirst(3).dropLast(3)); continue } + if !line.trimmingCharacters(in: .whitespaces).isEmpty { fields[section, default: []].append(line) } + } + result.hostname = fields["HOST"]?.first ?? "Unknown" + result.os = fields["OS"]?.first ?? "Unknown" + result.phpVersions = fields["PHP"] ?? [] + result.fpmServices = fields["FPM"] ?? [] + result.nginxUpstreams = fields["NGINX"] ?? [] + result.packageVersions = fields["PACKAGES"] ?? [] + result.availableUpdates = fields["UPDATES"] ?? [] + result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? [] + result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" } + let nginxLines = fields["WEB_ERROR_SIGNALS"] ?? [] + + result.webErrorSignals = nginxLines.compactMap { line in + guard line.hasPrefix("SIGNAL|") else { return nil } + return String(line.dropFirst("SIGNAL|".count)) + } + + if let status = nginxLines.first(where: { $0.hasPrefix("STATUS|") }) { + let parts = status.split(separator: "|").map(String.init) + let kind = parts.count > 1 ? parts[1] : "" + let details = parts.dropFirst(2).joined(separator: ", ") + + switch kind { + case "complete": + result.investigationStatus = + "Complete: Nginx error logs inspected (" + details + ")" + case "journal-inspected": + result.investigationStatus = + "Complete: Nginx systemd journal inspected" + case "partial": + result.investigationStatus = + "Partial: Nginx logs inspected with limitations (" + details + ")" + case "unavailable": + result.investigationStatus = + "Partial: Nginx logging unavailable (" + details + ")" + default: + result.investigationStatus = + "Partial: Nginx inspection status unknown" + } + } else if nginxLines.contains(where: { + $0.hasPrefix("Nginx error log unavailable") + }) { + result.investigationStatus = + "Partial: Nginx error log inaccessible or absent" + } else { + result.investigationStatus = + "Partial: Nginx log inspection status not reported" + } + let dockerLines = fields["DOCKER"] ?? [] + result.dockerCoverage = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }.map { line in + let parts = line.split(separator: "|", omittingEmptySubsequences: false) + return parts.count == 3 ? "\(parts[1]) [\(parts[2])]: PHP CLI not detected (not proof PHP is absent)" : line + } + result.dockerPHP = dockerLines.filter { $0.hasPrefix("PHP|") }.map { line in + let fields = line.split(separator: "|", omittingEmptySubsequences: false) + return fields.count == 4 ? "\(fields[1]) [\(fields[2])] PHP \(fields[3])" : line + } + let noPHP = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") } + let dockerStatus = dockerLines.first(where: { $0.hasPrefix("STATUS|") }) + if let dockerStatus { + switch dockerStatus { + case "STATUS|permission-denied": result.dockerAuditStatus = "Docker inaccessible: permission or daemon error" + case "STATUS|not-installed": result.dockerAuditStatus = "Docker not installed" + case "STATUS|no-running-containers": result.dockerAuditStatus = "No running containers" + default: result.dockerAuditStatus = "Docker inspection incomplete" + } + } else { + result.dockerAuditStatus = "\(result.dockerPHP.count) PHP runtimes; \(noPHP.count) containers without PHP CLI (not a security finding)" + } + result.packageAuditStatus = result.packageOrigins.contains("APT metadata unavailable") + ? "APT package provenance unavailable" + : "Installed and candidate package versions collected; vendor CVE fix status requires advisory verification" + // Recommendations are derived from this server's collected evidence. + var actions: [String] = [] + + if !result.availableUpdates.isEmpty { + actions.append( + "PRIORITY: Review \\(result.availableUpdates.count) available PHP package update(s). " + + "Check the applicable vendor advisories before applying changes." + ) + } + + if result.phpVersions.isEmpty { + actions.append( + "COVERAGE: No host PHP CLI was detected in standard locations. " + + "Check custom installations and application-specific runtimes." + ) + } + + if !result.dockerPHP.isEmpty { + actions.append( + "CONTAINERS: Review \\(result.dockerPHP.count) detected PHP runtime(s) " + + "against their image maintainers' current security releases. " + + "Host package updates do not update container images." + ) + } + + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + actions.append( + "COVERAGE: Docker inspection failed. Verify container inventory access " + + "before treating this assessment as complete." + ) + } + + if result.packageAuditStatus.contains("unavailable") { + actions.append( + "COVERAGE: PHP package provenance could not be established. " + + "Verify package repositories and installed revisions." + ) + } + + if result.investigationStatus.hasPrefix("Partial") { + actions.append( + "COVERAGE: Web-server investigation is incomplete. " + + result.investigationStatus + ) + } + + if !result.webErrorSignals.isEmpty { + let blocked = result.webErrorSignals.filter { + $0.localizedCaseInsensitiveContains("access forbidden by rule") + }.count + + let other = result.webErrorSignals.count - blocked + + if other > 0 { + actions.append( + "INVESTIGATE: Review \\(other) web-server error signal(s) " + + "for application failures, PHP errors or abnormal behaviour." + ) + } + + if blocked > 0 { + actions.append( + "INFORMATION: Nginx blocked \\(blocked) suspicious request(s). " + + "Confirm access-control rules remain effective. " + + "Blocked requests alone do not establish a compromise." + ) + } + } + + if !result.recentPHPFiles.isEmpty { + actions.append( + "VERIFY: \\(result.recentPHPFiles.count) recently modified PHP file(s) " + + "were identified. Compare them against expected deployments." + ) + } + + if actions.isEmpty { + actions.append( + "No immediate follow-up actions were identified by the collected indicators. " + + "Continue monitoring and verify PHP security advisories independently." + ) + } + + result.securityTriage = actions + + if result.phpVersions.isEmpty { + result.findings.append(Finding(severity: "Info", title: "No PHP CLI detected", detail: "No PHP binary found in standard paths; this does not exclude bundled runtimes or containers.")) + } + for line in result.phpVersions { + let parts = line.split(separator: " ") + guard let version = parts.last, let major = Int(version.split(separator: ".").first ?? "") else { continue } + if major < 8 { + result.findings.append(Finding(severity: "Warning", title: "Legacy PHP detected", detail: "\(line). Verify vendor support and distribution security maintenance.")) + } + } + for line in result.packageOrigins where line.contains("installed=") && line.contains("candidate=") { + let parts = line.split(separator: " ") + guard let name = parts.first, name.hasPrefix("php") else { continue } + let installed = parts.first(where: { $0.hasPrefix("installed=") }).map { String($0.dropFirst(10)) } ?? "" + let candidate = parts.first(where: { $0.hasPrefix("candidate=") }).map { String($0.dropFirst(10)) } ?? "" + if !installed.isEmpty && installed == candidate { + // Equal revisions indicate no available APT upgrade, not confirmed CVE remediation. + continue + } + if !installed.isEmpty && !candidate.isEmpty && candidate != "(none)" { + result.findings.append(Finding(severity: "Info", title: "Package revision review", detail: "\(name): installed \(installed), candidate \(candidate). Confirm with vendor advisory and dpkg version ordering.")) + } + } + for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") { + result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure.")) + } + if result.packageOrigins.contains("APT metadata unavailable") { + result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified.")) + } + for line in result.dockerPHP { + if line.contains("PHP 8.5.10") { + result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure.")) + } + } + for line in result.dockerPHP { + result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP.")) + } + if !result.dockerPHP.isEmpty { + result.findings.append(Finding(severity: "Info", title: "Container update workflow", detail: "PHP container images must be updated and redeployed separately. Review image digests, upstream release notes, and image-specific security fixes before changing production services.")) + } + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + result.findings.append(Finding(severity: "Warning", title: "Docker coverage unavailable", detail: result.dockerAuditStatus)) + } + if !result.webErrorSignals.isEmpty { + result.findings.append(Finding(severity: "Warning", title: "Web server error signals", detail: "\(result.webErrorSignals.count) potentially relevant log entries. Review context; errors alone do not establish an attack.")) + } + if !result.recentPHPFiles.isEmpty { + result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments.")) + } + if result.investigationStatus.hasPrefix("Partial") { + result.findings.append(Finding(severity: "Info", title: "Investigation coverage limited", detail: result.investigationStatus)) + } + result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan.")) + return result + } +} diff --git a/PHPGuard/SSHScanner.swift.backup-20261022-135123 b/PHPGuard/SSHScanner.swift.backup-20261022-135123 new file mode 100644 index 0000000..77ee4a9 --- /dev/null +++ b/PHPGuard/SSHScanner.swift.backup-20261022-135123 @@ -0,0 +1,512 @@ +import Foundation + +enum ScanError: LocalizedError { + case invalidServer, commandFailed(String) + var errorDescription: String? { + switch self { + case .invalidServer: return "Invalid SSH host, username, port or identity file." + case .commandFailed(let message): return message + } + } +} + +enum SSHScanner { + // Commands are fixed and read-only. User-supplied strings are passed as SSH arguments, never shell source. + static let script = #""" + printf '===HOST===\n'; hostname + printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s + printf '===PHP===\n' + for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do + if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi + done | sort -u + printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true + printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true + printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 2>/dev/null || true + printf '===UPDATES===\n' + if command -v apt >/dev/null 2>&1; then + apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true + else + printf 'Package manager not supported\n' + fi + printf '===PACKAGE_ORIGINS===\n' + if command -v apt-cache >/dev/null 2>&1; then + dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do + [ -n "$pkg" ] || continue + installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true) + candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1) + printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}" + apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /' + done + else + printf 'APT metadata unavailable\n' + fi + printf '===DOCKER===\n' + if ! command -v docker >/dev/null 2>&1; then + printf 'STATUS|not-installed\n' + elif ! docker info >/dev/null 2>&1; then + printf 'STATUS|permission-denied\n' + else + ids=$(docker ps -q 2>/dev/null) + if [ -z "$ids" ]; then printf 'STATUS|no-running-containers\n'; fi + for id in $ids; do + name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,') + image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null) + version=$(docker exec "$id" php -r 'echo PHP_VERSION;' 2>/dev/null) + exit_code=$? + + if [ "$exit_code" -eq 0 ] && + printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$'; then + printf 'PHP|%s|%s|%s\n' "$name" "$image" "$version" + else + printf 'NO_PHP_CLI|%s|%s\n' "$name" "$image" + fi + done + fi + printf '===RECENT_PHP_FILES===\n' + if [ -d /var/www ]; then + find /var/www -type f -name '*.php' -mtime -3 -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40 + else + printf 'Web root unavailable\n' + fi + printf '===WEB_ERROR_SIGNALS===\n' + + # Portable, read-only web-server investigation. + # Never require root access or changes to server permissions. + + webservers="" + if command -v nginx >/dev/null 2>&1 || + [ -d /etc/nginx ]; then + webservers="nginx" + fi + + if command -v apache2 >/dev/null 2>&1 || + command -v httpd >/dev/null 2>&1 || + [ -d /etc/apache2 ] || + [ -d /etc/httpd ]; then + webservers="$webservers apache" + fi + + webservers=$(printf '%s' "$webservers" | sed 's/^ *//') + + if [ -z "$webservers" ]; then + printf 'STATUS|not-applicable|No Nginx or Apache installation detected\n' + else + checked=0 + restricted=0 + missing=0 + total=0 + journal_checked=0 + + for webserver in $webservers; do + case "$webserver" in + nginx) + candidates="/var/log/nginx/error.log" + + # Prefer configured log locations when readable. + if command -v nginx >/dev/null 2>&1; then + config=$(nginx -T 2>/dev/null) + + if [ -z "$config" ]; then + config=$(sudo -n nginx -T 2>/dev/null) + fi + + configured=$(printf '%s\n' "$config" | + sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' | + grep '^/' | + sort -u) + + if [ -n "$configured" ]; then + candidates="$configured" + fi + fi + ;; + apache) + candidates="/var/log/apache2/error.log /var/log/httpd/error_log" + + # Include configured absolute ErrorLog paths. + configured=$( + grep -RhEi '^[[:space:]]*ErrorLog[[:space:]]+/' \ + /etc/apache2 /etc/httpd \ + 2>/dev/null | + awk '{print $2}' | + sort -u + ) + + if [ -n "$configured" ]; then + candidates="$configured" + fi + ;; + esac + + found_log=0 + + for log in $candidates; do + case "$log" in + /*) ;; + *) continue ;; + esac + + total=$((total + 1)) + + if [ ! -e "$log" ]; then + missing=$((missing + 1)) + continue + fi + + found_log=1 + log_data="" + read_ok=0 + + if [ -r "$log" ]; then + log_data=$(tail -n 1000 "$log" 2>/dev/null) + read_ok=$? + elif command -v sudo >/dev/null 2>&1; then + log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null) + read_ok=$? + else + read_ok=1 + fi + + if [ "$read_ok" -eq 0 ]; then + checked=$((checked + 1)) + + printf '%s\n' "$log_data" | + grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' | + tail -20 | + while IFS= read -r entry; do + [ -n "$entry" ] && + printf 'SIGNAL|%s|%s\n' "$log" "$entry" + done + else + restricted=$((restricted + 1)) + fi + done + + # Journal access may work even when log files are restricted. + if [ "$webserver" = "nginx" ]; then + units="nginx" + else + units="apache2 httpd" + fi + + for unit in $units; do + if ! command -v journalctl >/dev/null 2>&1; then + continue + fi + + journal_data=$(journalctl -u "$unit" -n 300 \ + --no-pager --output=cat 2>/dev/null) + journal_rc=$? + + if [ "$journal_rc" -ne 0 ]; then + journal_data=$(sudo -n journalctl -u "$unit" \ + -n 300 --no-pager --output=cat 2>/dev/null) + journal_rc=$? + fi + + if [ "$journal_rc" -eq 0 ] && + [ -n "$journal_data" ] && + [ "$journal_data" != "-- No entries --" ]; then + journal_checked=$((journal_checked + 1)) + + printf '%s\n' "$journal_data" | + grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' | + tail -20 | + while IFS= read -r entry; do + [ -n "$entry" ] && + printf 'SIGNAL|journal:%s|%s\n' "$unit" "$entry" + done + fi + done + done + + if [ "$restricted" -gt 0 ]; then + printf 'STATUS|limited-access|servers=%s|logs_checked=%s|restricted=%s|journal_sources=%s\n' \ + "$webservers" "$checked" "$restricted" "$journal_checked" + elif [ "$checked" -gt 0 ]; then + printf 'STATUS|complete|servers=%s|logs_checked=%s|journal_sources=%s\n' \ + "$webservers" "$checked" "$journal_checked" + elif [ "$journal_checked" -gt 0 ]; then + printf 'STATUS|journal-inspected|servers=%s|journal_sources=%s\n' \ + "$webservers" "$journal_checked" + else + printf 'STATUS|no-accessible-logs|servers=%s|logs_found=%s|missing=%s\n' \ + "$webservers" "$found_log" "$missing" + fi + fi + printf '===END===\n' + """# + + static func scan(_ server: Server) async throws -> AuditResult { + try await Task.detached(priority: .userInitiated) { try run(server) }.value + } + + private static func run(_ server: Server) throws -> AuditResult { + let validName = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/ + let validHost = /^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/ + guard server.user.wholeMatch(of: validName) != nil, + server.host.wholeMatch(of: validHost) != nil, + (1...65535).contains(server.port), + !server.identityFile.contains("\n") else { throw ScanError.invalidServer } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + var args = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(server.port)] + if !server.identityFile.isEmpty { + args += ["-i", (server.identityFile as NSString).expandingTildeInPath] + } + args += ["--", "\(server.user)@\(server.host)", "sh -s"] + process.arguments = args + let input = Pipe(), output = Pipe(), errors = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = errors + try process.run() + input.fileHandleForWriting.write(Data(script.utf8)) + try? input.fileHandleForWriting.close() + // Both pipes are drained concurrently to avoid deadlocks on large output. + let group = DispatchGroup() + var stdout = Data(), stderr = Data() + group.enter() + DispatchQueue.global().async { stdout = output.fileHandleForReading.readDataToEndOfFile(); group.leave() } + group.enter() + DispatchQueue.global().async { stderr = errors.fileHandleForReading.readDataToEndOfFile(); group.leave() } + process.waitUntilExit() + group.wait() + guard process.terminationStatus == 0 else { + throw ScanError.commandFailed(String(data: stderr, encoding: .utf8) ?? "SSH failed (\(process.terminationStatus))") + } + return parse(String(decoding: stdout, as: UTF8.self), server: server) + } + + static func parse(_ raw: String, server: Server) -> AuditResult { + var result = AuditResult(serverID: server.id, serverName: server.name) + result.rawOutput = raw + var section = "" + var fields: [String: [String]] = [:] + for line in raw.components(separatedBy: .newlines) { + if line.hasPrefix("===") && line.hasSuffix("===") { section = String(line.dropFirst(3).dropLast(3)); continue } + if !line.trimmingCharacters(in: .whitespaces).isEmpty { fields[section, default: []].append(line) } + } + result.hostname = fields["HOST"]?.first ?? "Unknown" + result.os = fields["OS"]?.first ?? "Unknown" + result.phpVersions = fields["PHP"] ?? [] + result.fpmServices = fields["FPM"] ?? [] + result.nginxUpstreams = fields["NGINX"] ?? [] + result.packageVersions = fields["PACKAGES"] ?? [] + result.availableUpdates = fields["UPDATES"] ?? [] + result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? [] + result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" } + let nginxLines = fields["WEB_ERROR_SIGNALS"] ?? [] + + result.webErrorSignals = nginxLines.compactMap { line in + guard line.hasPrefix("SIGNAL|") else { return nil } + return String(line.dropFirst("SIGNAL|".count)) + } + + if let status = nginxLines.first(where: { $0.hasPrefix("STATUS|") }) { + let parts = status.split(separator: "|").map(String.init) + let kind = parts.count > 1 ? parts[1] : "" + let details = parts.dropFirst(2).joined(separator: ", ") + + switch kind { + case "not-applicable": + result.investigationStatus = + "Not applicable: No Nginx or Apache installation detected" + case "limited-access": + result.investigationStatus = + "Limited access: Some web-server logs require additional permissions (" + details + ")" + case "no-accessible-logs": + result.investigationStatus = + "Limited coverage: No accessible web-server logs (" + details + ")" + case "complete": + result.investigationStatus = + "Complete: Web-server error logs inspected (" + details + ")" + case "journal-inspected": + result.investigationStatus = + "Complete: Web-server systemd journal inspected" + case "partial": + result.investigationStatus = + "Partial: Nginx logs inspected with limitations (" + details + ")" + case "unavailable": + result.investigationStatus = + "Partial: Nginx logging unavailable (" + details + ")" + default: + result.investigationStatus = + "Partial: Nginx inspection status unknown" + } + } else if nginxLines.contains(where: { + $0.hasPrefix("Nginx error log unavailable") + }) { + result.investigationStatus = + "Partial: Nginx error log inaccessible or absent" + } else { + result.investigationStatus = + "Partial: Nginx log inspection status not reported" + } + let dockerLines = fields["DOCKER"] ?? [] + result.dockerCoverage = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }.map { line in + let parts = line.split(separator: "|", omittingEmptySubsequences: false) + return parts.count == 3 ? "\(parts[1]) [\(parts[2])]: PHP CLI not detected (not proof PHP is absent)" : line + } + result.dockerPHP = dockerLines.filter { $0.hasPrefix("PHP|") }.map { line in + let fields = line.split(separator: "|", omittingEmptySubsequences: false) + return fields.count == 4 ? "\(fields[1]) [\(fields[2])] PHP \(fields[3])" : line + } + let noPHP = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") } + let dockerStatus = dockerLines.first(where: { $0.hasPrefix("STATUS|") }) + if let dockerStatus { + switch dockerStatus { + case "STATUS|permission-denied": result.dockerAuditStatus = "Docker inaccessible: permission or daemon error" + case "STATUS|not-installed": result.dockerAuditStatus = "Docker not installed" + case "STATUS|no-running-containers": result.dockerAuditStatus = "No running containers" + default: result.dockerAuditStatus = "Docker inspection incomplete" + } + } else { + result.dockerAuditStatus = "\(result.dockerPHP.count) PHP runtimes; \(noPHP.count) containers without PHP CLI (not a security finding)" + } + result.packageAuditStatus = result.packageOrigins.contains("APT metadata unavailable") + ? "APT package provenance unavailable" + : "Installed and candidate package versions collected; vendor CVE fix status requires advisory verification" + // Recommendations are derived from this server's collected evidence. + var actions: [String] = [] + + if !result.availableUpdates.isEmpty { + actions.append( + "PRIORITY: Review \\(result.availableUpdates.count) available PHP package update(s). " + + "Check the applicable vendor advisories before applying changes." + ) + } + + if result.phpVersions.isEmpty { + actions.append( + "COVERAGE: No host PHP CLI was detected in standard locations. " + + "Check custom installations and application-specific runtimes." + ) + } + + if !result.dockerPHP.isEmpty { + actions.append( + "CONTAINERS: Review \\(result.dockerPHP.count) detected PHP runtime(s) " + + "against their image maintainers' current security releases. " + + "Host package updates do not update container images." + ) + } + + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + actions.append( + "COVERAGE: Docker inspection failed. Verify container inventory access " + + "before treating this assessment as complete." + ) + } + + if result.packageAuditStatus.contains("unavailable") { + actions.append( + "COVERAGE: PHP package provenance could not be established. " + + "Verify package repositories and installed revisions." + ) + } + + if result.investigationStatus.hasPrefix("Partial") || + result.investigationStatus.hasPrefix("Limited") { + actions.append( + "COVERAGE: Web-server log visibility is limited. " + + result.investigationStatus + ) + } + + if !result.webErrorSignals.isEmpty { + let blocked = result.webErrorSignals.filter { + $0.localizedCaseInsensitiveContains("access forbidden by rule") + }.count + + let other = result.webErrorSignals.count - blocked + + if other > 0 { + actions.append( + "INVESTIGATE: Review \\(other) web-server error signal(s) " + + "for application failures, PHP errors or abnormal behaviour." + ) + } + + if blocked > 0 { + actions.append( + "INFORMATION: Nginx blocked \\(blocked) suspicious request(s). " + + "Confirm access-control rules remain effective. " + + "Blocked requests alone do not establish a compromise." + ) + } + } + + if !result.recentPHPFiles.isEmpty { + actions.append( + "VERIFY: \\(result.recentPHPFiles.count) recently modified PHP file(s) " + + "were identified. Compare them against expected deployments." + ) + } + + if actions.isEmpty { + actions.append( + "No immediate follow-up actions were identified by the collected indicators. " + + "Continue monitoring and verify PHP security advisories independently." + ) + } + + result.securityTriage = actions + + if result.phpVersions.isEmpty { + result.findings.append(Finding(severity: "Info", title: "No PHP CLI detected", detail: "No PHP binary found in standard paths; this does not exclude bundled runtimes or containers.")) + } + for line in result.phpVersions { + let parts = line.split(separator: " ") + guard let version = parts.last, let major = Int(version.split(separator: ".").first ?? "") else { continue } + if major < 8 { + result.findings.append(Finding(severity: "Warning", title: "Legacy PHP detected", detail: "\(line). Verify vendor support and distribution security maintenance.")) + } + } + for line in result.packageOrigins where line.contains("installed=") && line.contains("candidate=") { + let parts = line.split(separator: " ") + guard let name = parts.first, name.hasPrefix("php") else { continue } + let installed = parts.first(where: { $0.hasPrefix("installed=") }).map { String($0.dropFirst(10)) } ?? "" + let candidate = parts.first(where: { $0.hasPrefix("candidate=") }).map { String($0.dropFirst(10)) } ?? "" + if !installed.isEmpty && installed == candidate { + // Equal revisions indicate no available APT upgrade, not confirmed CVE remediation. + continue + } + if !installed.isEmpty && !candidate.isEmpty && candidate != "(none)" { + result.findings.append(Finding(severity: "Info", title: "Package revision review", detail: "\(name): installed \(installed), candidate \(candidate). Confirm with vendor advisory and dpkg version ordering.")) + } + } + for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") { + result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure.")) + } + if result.packageOrigins.contains("APT metadata unavailable") { + result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified.")) + } + for line in result.dockerPHP { + if line.contains("PHP 8.5.10") { + result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure.")) + } + } + for line in result.dockerPHP { + result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP.")) + } + if !result.dockerPHP.isEmpty { + result.findings.append(Finding(severity: "Info", title: "Container update workflow", detail: "PHP container images must be updated and redeployed separately. Review image digests, upstream release notes, and image-specific security fixes before changing production services.")) + } + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + result.findings.append(Finding(severity: "Warning", title: "Docker coverage unavailable", detail: result.dockerAuditStatus)) + } + if !result.webErrorSignals.isEmpty { + result.findings.append(Finding(severity: "Warning", title: "Web server error signals", detail: "\(result.webErrorSignals.count) potentially relevant log entries. Review context; errors alone do not establish an attack.")) + } + if !result.recentPHPFiles.isEmpty { + result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments.")) + } + if result.investigationStatus.hasPrefix("Partial") || + result.investigationStatus.hasPrefix("Limited") { + result.findings.append(Finding(severity: "Info", title: "Investigation coverage limited", detail: result.investigationStatus)) + } + result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan.")) + return result + } +} diff --git a/PHPGuard/VulnerabilityService.swift b/PHPGuard/VulnerabilityService.swift index 39dd078..42fb211 100644 --- a/PHPGuard/VulnerabilityService.swift +++ b/PHPGuard/VulnerabilityService.swift @@ -118,3 +118,63 @@ enum VulnerabilityService { .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? } +}