From 2478e4f7b3fe39823c2f418ceb355aa51db987f6 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 22 Oct 2026 15:26:37 +0100 Subject: [PATCH] Updates to scanner with CVE saving and auto update --- PHPGuard/AuditStore.swift | 13 +- PHPGuard/ContentView.swift | 146 +-- .../ContentView.swift.bak-20261022-151212 | 877 +++++++++++++++++ .../ContentView.swift.bak-20261022-151317 | 879 ++++++++++++++++++ PHPGuard/Models.swift | 15 +- PHPGuard/SSHScanner.swift | 41 +- PHPGuard/SSHScanner.swift.bak-20261022-152239 | 496 ++++++++++ PHPGuard/VulnerabilityService.swift | 277 ++++++ .../AuditStore.swift | 244 +++++ .../ContentView.swift | 869 +++++++++++++++++ .../VulnerabilityService.swift | 180 ++++ .../AuditStore.swift | 244 +++++ .../ContentView.swift | 869 +++++++++++++++++ .../ubuntu-cve-20261022-152152/Models.swift | 129 +++ .../SSHScanner.swift | 496 ++++++++++ .../VulnerabilityService.swift | 180 ++++ 16 files changed, 5892 insertions(+), 63 deletions(-) create mode 100644 PHPGuard/ContentView.swift.bak-20261022-151212 create mode 100644 PHPGuard/ContentView.swift.bak-20261022-151317 create mode 100644 PHPGuard/SSHScanner.swift.bak-20261022-152239 create mode 100644 backups/ubuntu-advisory-phase-20261022-152509/AuditStore.swift create mode 100644 backups/ubuntu-advisory-phase-20261022-152509/ContentView.swift create mode 100644 backups/ubuntu-advisory-phase-20261022-152509/VulnerabilityService.swift create mode 100644 backups/ubuntu-cve-20261022-152152/AuditStore.swift create mode 100644 backups/ubuntu-cve-20261022-152152/ContentView.swift create mode 100644 backups/ubuntu-cve-20261022-152152/Models.swift create mode 100644 backups/ubuntu-cve-20261022-152152/SSHScanner.swift create mode 100644 backups/ubuntu-cve-20261022-152152/VulnerabilityService.swift diff --git a/PHPGuard/AuditStore.swift b/PHPGuard/AuditStore.swift index c5e244b..a4cd827 100644 --- a/PHPGuard/AuditStore.swift +++ b/PHPGuard/AuditStore.swift @@ -166,9 +166,20 @@ import SwiftUI ) } + scanStage = "Checking Ubuntu advisories" + scanDetail = "Comparing CVEs with Ubuntu package advisories" + scanFraction = 0.95 + + if report.ubuntuReleaseID == "ubuntu" { + all = await VulnerabilityService.assessUbuntu( + all, + report: report + ) + } + report.cveAssessments = all.sorted { $0.cveID > $1.cveID } report.cveAssessmentStatus = failures.isEmpty - ? "Upstream CPE assessment complete; distribution fixes unverified" + ? "NVD assessment complete; Ubuntu advisories checked where available" : "Partial/unknown: " + failures.joined(separator: "; ") report.cveAssessedAt = Date() } diff --git a/PHPGuard/ContentView.swift b/PHPGuard/ContentView.swift index 52b0530..7136237 100644 --- a/PHPGuard/ContentView.swift +++ b/PHPGuard/ContentView.swift @@ -164,7 +164,7 @@ struct ContentView: View { 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.") + Text("NVD identifies upstream candidates. Ubuntu advisory status is checked separately. A published fix does not prove that the installed package is patched.") .font(.caption).foregroundStyle(.secondary) if result.cveAssessments.isEmpty { Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.") @@ -180,7 +180,41 @@ struct ContentView: View { Text(item.status).foregroundStyle(.orange) } Text(item.runtime + " — " + item.summary).lineLimit(3) - Text(item.evidence).font(.caption).foregroundStyle(.secondary) + Text(item.evidence) + .font(.caption) + .foregroundStyle(.secondary) + + if let vendorStatus = item.vendorStatus { + + HStack(spacing: 8) { + + Image( + systemName: + vendorStatus == "Not affected" + ? "checkmark.shield.fill" + : "shield.lefthalf.filled" + ) + + Text("Ubuntu: " + vendorStatus) + .font(.subheadline.bold()) + } + .foregroundStyle( + vendorStatus == "Not affected" + ? Color.green + : vendorStatus == "Ubuntu fix needed" + ? Color.red + : Color.orange + ) + .padding(.top, 5) + } + + if let vendorEvidence = item.vendorEvidence { + + Text(vendorEvidence) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } }.padding(10) .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10)) } @@ -479,56 +513,65 @@ struct ContentView: View { private func packageInventoryCard(_ values: [String]) -> some View { DisclosureGroup(isExpanded: $showInstalledPackages) { inventoryCard( - title: "Installed Packages", - subtitle: "PHP package inventory", - icon: "shippingbox.fill", - tint: .orange, - count: values.count - ) { - if values.isEmpty { - inventoryEmptyState("No PHP packages detected") - } else { - VStack(spacing: 0) { - ForEach(Array(values.enumerated()), id: \.offset) { - index, line in + 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 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" - 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)) - 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) - 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() + if index < values.count - 1 { + Divider() + } } } } } + } label: { + HStack { + Label("Installed Packages", systemImage: "shippingbox.fill") + .font(.headline) + + Spacer() + + Text("\(values.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) } private func inventoryEmptyState(_ message: String) -> some View { @@ -628,23 +671,6 @@ struct ContentView: View { } - } label: { - HStack { - Label("Installed Packages", systemImage: "shippingbox.fill") - .font(.headline) - - Spacer() - - Text("\(values.count)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - .padding(16) - .background( - .quaternary.opacity(0.15), - in: RoundedRectangle(cornerRadius: 14) - ) } struct ServerEditor: View { diff --git a/PHPGuard/ContentView.swift.bak-20261022-151212 b/PHPGuard/ContentView.swift.bak-20261022-151212 new file mode 100644 index 0000000..52b0530 --- /dev/null +++ b/PHPGuard/ContentView.swift.bak-20261022-151212 @@ -0,0 +1,877 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @State private var showInstalledPackages = false + @State private var showAPTOrigins = false + @State private var selection: UUID? + @State private var showingEditor = false + @State private var editing: Server? + @State private var showClearHistoryConfirmation = false + @AppStorage("phpguard.confirmClear") private var confirmClearHistory = true + private func addServer() { editing = nil; showingEditor = true } + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("Servers") { + ForEach(store.servers) { server in + Label { + HStack { + Text(server.name) + Spacer() + if store.scanning.contains(server.id) { ProgressView().controlSize(.small) } + else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) } + } + } icon: { Image(systemName: "server.rack") } + .tag(server.id) + .contextMenu { + Button("Scan") { store.scan(server) } + Button("Edit") { editing = server; showingEditor = true } + Divider() + Button("Remove", role: .destructive) { + store.servers.removeAll { $0.id == server.id } + if selection == server.id { selection = nil } + } + } + } + } + Section("Navigation") { + Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) + Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + } + } + .navigationTitle("PHPGuard") + .safeAreaInset(edge: .bottom) { + Button(action: addServer) { + Label("Add Server", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(12) + .background(.bar) + } + } detail: { + if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") { + cveView + } else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") { + historyView + } else if let server = store.servers.first(where: { $0.id == selection }) { + serverView(server) + } else { dashboard } + } + .sheet(isPresented: $store.scanProgressVisible) { + ScanProgressView(store: store) + .interactiveDismissDisabled(!store.scanning.isEmpty) + } + .sheet(isPresented: $showingEditor) { + ServerEditor(existing: editing) { server in + if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server } + else { store.servers.append(server) } + selection = server.id + } + } + .alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) { + Button("OK") { store.lastError = nil } + } message: { Text(store.lastError ?? "") } + } + private var dashboard: some View { + VStack(alignment: .leading, spacing: 24) { + Text("Security overview").font(.largeTitle.bold()) + HStack(spacing: 16) { + stat("Servers", "\(store.servers.count)", "server.rack") + stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") + stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") + } + Text("Read-only PHP inventory, APT checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.") + .foregroundStyle(.secondary) + HStack { + Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } + .buttonStyle(.borderedProminent) + Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) + Button("Import JSON scan") { store.importReport() } + } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private func stat(_ title: String, _ value: String, _ icon: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon).foregroundStyle(.secondary) + Text(value).font(.system(size: 36, weight: .bold)) + }.padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14)) + } + private func serverView(_ server: Server) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading) { + Text(server.name).font(.largeTitle.bold()) + Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary) + } + Spacer() + Button("Edit") { editing = server; showingEditor = true } + Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent) + .disabled(store.scanning.contains(server.id)) + } + if let result = store.latest(for: server) { + if let error = result.error { + Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red) + } else { + HStack(spacing: 12) { + Label("Scan completed", systemImage: "checkmark.shield.fill") + .foregroundStyle(.green) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + Spacer() + Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") } + .buttonStyle(.borderedProminent) + Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") } + } + .padding(16) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + Text("OS: \(result.os) • Host: \(result.hostname)") + inventoryCards( + php: result.phpVersions, + fpm: result.fpmServices, + nginx: result.nginxUpstreams, + packages: result.packageVersions + ) + dataSection("Available PHP package upgrades", result.availableUpdates) + DisclosureGroup( + isExpanded: $showAPTOrigins + ) { + dataSection( + "APT package origins and candidate revisions", + result.packageOrigins + ) + } label: { + Label( + "APT package origins and candidate revisions", + systemImage: "shippingbox" + ) + .font(.headline) + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) + Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) + dataSection("PHP detected in running Docker containers", result.dockerPHP) + Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) + dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage) + dataSection("Security triage — follow-up actions", result.securityTriage) + VStack(alignment: .leading, spacing: 10) { + Text("CVE assessment").font(.title2.bold()) + Text(result.cveAssessmentStatus).foregroundStyle(.secondary) + Text("NVD 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() + Text("\(result.findings.count) total").foregroundStyle(.secondary) + } + ForEach(result.findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill") + Text(finding.severity.uppercased()).font(.caption.bold()) + Text(finding.title).font(.headline) + } + .foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue)) + Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + + } + } else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) } + }.padding(28).frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - PHP Inventory Dashboard + + private func inventoryCards( + php: [String], + fpm: [String], + nginx: [String], + packages: [String] + ) -> some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16, alignment: .top), + GridItem(.flexible(), spacing: 16, alignment: .top) + ], + alignment: .leading, + spacing: 16 + ) { + phpInventoryCard(php) + fpmInventoryCard(fpm) + nginxInventoryCard(nginx) + packageInventoryCard(packages) + } + .frame(maxWidth: .infinity) + } + + private func inventoryCard( + title: String, + subtitle: String, + icon: String, + tint: Color, + count: Int, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 40, height: 40) + .background( + tint.opacity(0.12), + in: RoundedRectangle(cornerRadius: 11) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Text("\(count)") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Color.secondary.opacity(0.10), + in: Capsule() + ) + } + + Rectangle() + .fill(Color.primary.opacity(0.07)) + .frame(height: 1) + + content() + + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading) + .background( + Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 18) + ) + .overlay { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + + private func inventoryBadge( + _ text: String, + color: Color + ) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + + Text(text) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.10), in: Capsule()) + } + + private func phpInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP Installations", + subtitle: "Detected PHP runtimes", + icon: "chevron.left.forwardslash.chevron.right", + tint: .blue, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP runtime detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + separator: " ", + omittingEmptySubsequences: true + ) + + let version = parts.last.map(String.init) ?? "Unknown" + let executable = parts.first.map(String.init) ?? line + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("PHP \(version)") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Spacer() + + inventoryBadge("Detected", color: .green) + } + + Label(executable, systemImage: "terminal") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + } + } + } + } + + private func fpmInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP-FPM Services", + subtitle: "FastCGI process managers", + icon: "gearshape.2.fill", + tint: .purple, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP-FPM services detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + whereSeparator: { $0.isWhitespace } + ) + + let service = parts.first.map(String.init) ?? line + + let running = line.localizedCaseInsensitiveContains( + "active running" + ) || ( + line.contains("active") && + line.contains("running") + ) + + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 10) { + Text(service) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + .lineLimit(2) + + Spacer(minLength: 6) + + inventoryBadge( + running ? "Running" : "Check status", + color: running ? .green : .orange + ) + } + + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + } + } + } + } + + private func nginxInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Nginx FastCGI", + subtitle: "PHP routing configuration", + icon: "network", + tint: .cyan, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No FastCGI routes detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let route = line + .replacingOccurrences( + of: "fastcgi_pass", + with: "" + ) + .trimmingCharacters( + in: .whitespacesAndNewlines + ) + .trimmingCharacters( + in: CharacterSet(charactersIn: ";") + ) + + let isSocket = route.hasPrefix("unix:") + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text( + isSocket + ? "Unix socket" + : "FastCGI endpoint" + ) + .font(.subheadline.weight(.semibold)) + + Spacer() + + inventoryBadge( + "Configured", + color: .blue + ) + } + + Text( + isSocket + ? String(route.dropFirst(5)) + : route + ) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + } + } + } + } + } + + private func packageInventoryCard(_ values: [String]) -> some View { + DisclosureGroup(isExpanded: $showInstalledPackages) { + inventoryCard( + title: "Installed Packages", + subtitle: "PHP package inventory", + icon: "shippingbox.fill", + tint: .orange, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP packages detected") + } else { + VStack(spacing: 0) { + ForEach(Array(values.enumerated()), id: \.offset) { + index, line in + + let parts = line.split( + separator: " ", + maxSplits: 1, + omittingEmptySubsequences: true + ) + + let name = parts.first.map(String.init) ?? line + let version = parts.count > 1 + ? String(parts[1]) + : "Version not reported" + + VStack(alignment: .leading, spacing: 5) { + Text(name) + .font(.subheadline.weight(.semibold)) + + Text(version) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + .frame( + maxWidth: .infinity, + alignment: .leading + ) + .padding(.vertical, 9) + + if index < values.count - 1 { + Divider() + } + } + } + } + } + } + + 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.") } + } + + + } label: { + HStack { + Label("Installed Packages", systemImage: "shippingbox.fill") + .font(.headline) + + Spacer() + + Text("\(values.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) +} + +struct ServerEditor: View { + @Environment(\.dismiss) private var dismiss + let existing: Server? + let onSave: (Server) -> Void + @State private var server: Server + init(existing: Server?, onSave: @escaping (Server) -> Void) { + self.existing = existing + self.onSave = onSave + _server = State(initialValue: existing ?? Server(name: "", host: "")) + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack") + .font(.title.bold()) + Text("Connect using an existing SSH key. No agent installation required.") + .foregroundStyle(.secondary) + Form { + Section("Connection") { + TextField("Server name (e.g. Pegasus)", text: $server.name) + TextField("Hostname or IP address", text: $server.host) + .textContentType(.URL) + TextField("SSH username", text: $server.user) + TextField("SSH port", value: $server.port, format: .number) + } + Section("Advanced (optional)") { + HStack { + TextField("SSH private key path", text: $server.identityFile) + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + if panel.runModal() == .OK, let url = panel.url { + server.identityFile = url.path + } + } + } + Text("Leave blank to use your existing SSH configuration and keys.") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + if !isValid { + Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle") + .foregroundStyle(.secondary).font(.caption) + } + Text("Host key verification is strict. Connect in Terminal first if this server is new.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + Button(existing == nil ? "Add Server" : "Save Changes") { + server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines) + server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(server) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + }.padding(24).frame(width: 580) + } + private var isValid: Bool { + let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !host.isEmpty && !host.hasPrefix("-") + && host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil + && user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil + && (1...65535).contains(server.port) + } +} + +// MARK: - Security Assessment Progress + +struct ScanProgressView: View { + @ObservedObject var store: AuditStore + + @State private var pulse = false + + private var elapsed: String { + let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.09)) + .frame(width: 108, height: 108) + .scaleEffect(pulse ? 1.12 : 0.94) + + Circle() + .stroke(Color.accentColor.opacity(0.18), lineWidth: 2) + .frame(width: 94, height: 94) + + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(Color.accentColor) + .symbolRenderingMode(.hierarchical) + } + .padding(.top, 34) + + Text("Security Assessment") + .font(.system(size: 25, weight: .bold)) + .padding(.top, 22) + + Text("PHPGuard is inspecting your server") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 15) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(store.scanServerName) + .font(.headline) + + Text(store.scanServerHost) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "checkmark.shield") + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(store.scanStage) + .font(.subheadline.weight(.semibold)) + + Text(store.scanDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView(value: store.scanFraction, total: 1.0) + .progressViewStyle(.linear) + .tint(Color.accentColor) + + HStack { + Text("\(Int(store.scanFraction * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + if store.scanTotalChecks > 0 { + Text( + "CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)" + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + HStack { + Label( + "\(store.scanning.count) active", + systemImage: "network" + ) + + Spacer() + + TimelineView(.periodic(from: .now, by: 1)) { context in + let seconds = max( + 0, + Int(context.date.timeIntervalSince(store.scanStartedAt)) + ) + + Text( + String( + format: "%02d:%02d", + seconds / 60, + seconds % 60 + ) + ) + .monospacedDigit() + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(22) + .background( + Color.primary.opacity(0.035), + in: RoundedRectangle(cornerRadius: 16) + ) + .padding(.horizontal, 30) + .padding(.top, 26) + + Text("The assessment runs remotely over SSH. No server changes are made.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 35) + .padding(.top, 22) + .padding(.bottom, 30) + } + .frame(width: 540) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + pulse = true + } + .animation( + .easeInOut(duration: 1.7).repeatForever(autoreverses: true), + value: pulse + ) + } +} diff --git a/PHPGuard/ContentView.swift.bak-20261022-151317 b/PHPGuard/ContentView.swift.bak-20261022-151317 new file mode 100644 index 0000000..3981aab --- /dev/null +++ b/PHPGuard/ContentView.swift.bak-20261022-151317 @@ -0,0 +1,879 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @State private var showInstalledPackages = false + @State private var showAPTOrigins = false + @State private var selection: UUID? + @State private var showingEditor = false + @State private var editing: Server? + @State private var showClearHistoryConfirmation = false + @AppStorage("phpguard.confirmClear") private var confirmClearHistory = true + private func addServer() { editing = nil; showingEditor = true } + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("Servers") { + ForEach(store.servers) { server in + Label { + HStack { + Text(server.name) + Spacer() + if store.scanning.contains(server.id) { ProgressView().controlSize(.small) } + else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) } + } + } icon: { Image(systemName: "server.rack") } + .tag(server.id) + .contextMenu { + Button("Scan") { store.scan(server) } + Button("Edit") { editing = server; showingEditor = true } + Divider() + Button("Remove", role: .destructive) { + store.servers.removeAll { $0.id == server.id } + if selection == server.id { selection = nil } + } + } + } + } + Section("Navigation") { + Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) + Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + } + } + .navigationTitle("PHPGuard") + .safeAreaInset(edge: .bottom) { + Button(action: addServer) { + Label("Add Server", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(12) + .background(.bar) + } + } detail: { + if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") { + cveView + } else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") { + historyView + } else if let server = store.servers.first(where: { $0.id == selection }) { + serverView(server) + } else { dashboard } + } + .sheet(isPresented: $store.scanProgressVisible) { + ScanProgressView(store: store) + .interactiveDismissDisabled(!store.scanning.isEmpty) + } + .sheet(isPresented: $showingEditor) { + ServerEditor(existing: editing) { server in + if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server } + else { store.servers.append(server) } + selection = server.id + } + } + .alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) { + Button("OK") { store.lastError = nil } + } message: { Text(store.lastError ?? "") } + } + private var dashboard: some View { + VStack(alignment: .leading, spacing: 24) { + Text("Security overview").font(.largeTitle.bold()) + HStack(spacing: 16) { + stat("Servers", "\(store.servers.count)", "server.rack") + stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") + stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") + } + Text("Read-only PHP inventory, APT checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.") + .foregroundStyle(.secondary) + HStack { + Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } + .buttonStyle(.borderedProminent) + Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) + Button("Import JSON scan") { store.importReport() } + } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private func stat(_ title: String, _ value: String, _ icon: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon).foregroundStyle(.secondary) + Text(value).font(.system(size: 36, weight: .bold)) + }.padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14)) + } + private func serverView(_ server: Server) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading) { + Text(server.name).font(.largeTitle.bold()) + Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary) + } + Spacer() + Button("Edit") { editing = server; showingEditor = true } + Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent) + .disabled(store.scanning.contains(server.id)) + } + if let result = store.latest(for: server) { + if let error = result.error { + Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red) + } else { + HStack(spacing: 12) { + Label("Scan completed", systemImage: "checkmark.shield.fill") + .foregroundStyle(.green) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + Spacer() + Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") } + .buttonStyle(.borderedProminent) + Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") } + } + .padding(16) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + Text("OS: \(result.os) • Host: \(result.hostname)") + inventoryCards( + php: result.phpVersions, + fpm: result.fpmServices, + nginx: result.nginxUpstreams, + packages: result.packageVersions + ) + dataSection("Available PHP package upgrades", result.availableUpdates) + DisclosureGroup( + isExpanded: $showAPTOrigins + ) { + dataSection( + "APT package origins and candidate revisions", + result.packageOrigins + ) + } label: { + Label( + "APT package origins and candidate revisions", + systemImage: "shippingbox" + ) + .font(.headline) + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) + Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) + dataSection("PHP detected in running Docker containers", result.dockerPHP) + Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) + dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage) + dataSection("Security triage — follow-up actions", result.securityTriage) + VStack(alignment: .leading, spacing: 10) { + Text("CVE assessment").font(.title2.bold()) + Text(result.cveAssessmentStatus).foregroundStyle(.secondary) + Text("NVD 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() + Text("\(result.findings.count) total").foregroundStyle(.secondary) + } + ForEach(result.findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill") + Text(finding.severity.uppercased()).font(.caption.bold()) + Text(finding.title).font(.headline) + } + .foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue)) + Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + + } + } else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) } + }.padding(28).frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - PHP Inventory Dashboard + + private func inventoryCards( + php: [String], + fpm: [String], + nginx: [String], + packages: [String] + ) -> some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16, alignment: .top), + GridItem(.flexible(), spacing: 16, alignment: .top) + ], + alignment: .leading, + spacing: 16 + ) { + phpInventoryCard(php) + fpmInventoryCard(fpm) + nginxInventoryCard(nginx) + packageInventoryCard(packages) + } + .frame(maxWidth: .infinity) + } + + private func inventoryCard( + title: String, + subtitle: String, + icon: String, + tint: Color, + count: Int, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 40, height: 40) + .background( + tint.opacity(0.12), + in: RoundedRectangle(cornerRadius: 11) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Text("\(count)") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Color.secondary.opacity(0.10), + in: Capsule() + ) + } + + Rectangle() + .fill(Color.primary.opacity(0.07)) + .frame(height: 1) + + content() + + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading) + .background( + Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 18) + ) + .overlay { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + + private func inventoryBadge( + _ text: String, + color: Color + ) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + + Text(text) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.10), in: Capsule()) + } + + private func phpInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP Installations", + subtitle: "Detected PHP runtimes", + icon: "chevron.left.forwardslash.chevron.right", + tint: .blue, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP runtime detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + separator: " ", + omittingEmptySubsequences: true + ) + + let version = parts.last.map(String.init) ?? "Unknown" + let executable = parts.first.map(String.init) ?? line + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("PHP \(version)") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Spacer() + + inventoryBadge("Detected", color: .green) + } + + Label(executable, systemImage: "terminal") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + } + } + } + } + + private func fpmInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP-FPM Services", + subtitle: "FastCGI process managers", + icon: "gearshape.2.fill", + tint: .purple, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP-FPM services detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + whereSeparator: { $0.isWhitespace } + ) + + let service = parts.first.map(String.init) ?? line + + let running = line.localizedCaseInsensitiveContains( + "active running" + ) || ( + line.contains("active") && + line.contains("running") + ) + + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 10) { + Text(service) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + .lineLimit(2) + + Spacer(minLength: 6) + + inventoryBadge( + running ? "Running" : "Check status", + color: running ? .green : .orange + ) + } + + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + } + } + } + } + + private func nginxInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Nginx FastCGI", + subtitle: "PHP routing configuration", + icon: "network", + tint: .cyan, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No FastCGI routes detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let route = line + .replacingOccurrences( + of: "fastcgi_pass", + with: "" + ) + .trimmingCharacters( + in: .whitespacesAndNewlines + ) + .trimmingCharacters( + in: CharacterSet(charactersIn: ";") + ) + + let isSocket = route.hasPrefix("unix:") + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text( + isSocket + ? "Unix socket" + : "FastCGI endpoint" + ) + .font(.subheadline.weight(.semibold)) + + Spacer() + + inventoryBadge( + "Configured", + color: .blue + ) + } + + Text( + isSocket + ? String(route.dropFirst(5)) + : route + ) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + } + } + } + } + } + + private func packageInventoryCard(_ values: [String]) -> some View { + DisclosureGroup(isExpanded: $showInstalledPackages) { + inventoryCard( + title: "Installed Packages", + subtitle: "PHP package inventory", + icon: "shippingbox.fill", + tint: .orange, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP packages detected") + } else { + VStack(spacing: 0) { + ForEach(Array(values.enumerated()), id: \.offset) { + index, line in + + let parts = line.split( + separator: " ", + maxSplits: 1, + omittingEmptySubsequences: true + ) + + let name = parts.first.map(String.init) ?? line + let version = parts.count > 1 + ? String(parts[1]) + : "Version not reported" + + VStack(alignment: .leading, spacing: 5) { + Text(name) + .font(.subheadline.weight(.semibold)) + + Text(version) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + .frame( + maxWidth: .infinity, + alignment: .leading + ) + .padding(.vertical, 9) + + if index < values.count - 1 { + Divider() + } + } + } + } + } + } + + } + + 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.") } + } + + + } label: { + HStack { + Label("Installed Packages", systemImage: "shippingbox.fill") + .font(.headline) + + Spacer() + + Text("\(values.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) +} + +struct ServerEditor: View { + @Environment(\.dismiss) private var dismiss + let existing: Server? + let onSave: (Server) -> Void + @State private var server: Server + init(existing: Server?, onSave: @escaping (Server) -> Void) { + self.existing = existing + self.onSave = onSave + _server = State(initialValue: existing ?? Server(name: "", host: "")) + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack") + .font(.title.bold()) + Text("Connect using an existing SSH key. No agent installation required.") + .foregroundStyle(.secondary) + Form { + Section("Connection") { + TextField("Server name (e.g. Pegasus)", text: $server.name) + TextField("Hostname or IP address", text: $server.host) + .textContentType(.URL) + TextField("SSH username", text: $server.user) + TextField("SSH port", value: $server.port, format: .number) + } + Section("Advanced (optional)") { + HStack { + TextField("SSH private key path", text: $server.identityFile) + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + if panel.runModal() == .OK, let url = panel.url { + server.identityFile = url.path + } + } + } + Text("Leave blank to use your existing SSH configuration and keys.") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + if !isValid { + Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle") + .foregroundStyle(.secondary).font(.caption) + } + Text("Host key verification is strict. Connect in Terminal first if this server is new.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + Button(existing == nil ? "Add Server" : "Save Changes") { + server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines) + server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(server) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + }.padding(24).frame(width: 580) + } + private var isValid: Bool { + let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !host.isEmpty && !host.hasPrefix("-") + && host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil + && user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil + && (1...65535).contains(server.port) + } +} + +// MARK: - Security Assessment Progress + +struct ScanProgressView: View { + @ObservedObject var store: AuditStore + + @State private var pulse = false + + private var elapsed: String { + let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.09)) + .frame(width: 108, height: 108) + .scaleEffect(pulse ? 1.12 : 0.94) + + Circle() + .stroke(Color.accentColor.opacity(0.18), lineWidth: 2) + .frame(width: 94, height: 94) + + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(Color.accentColor) + .symbolRenderingMode(.hierarchical) + } + .padding(.top, 34) + + Text("Security Assessment") + .font(.system(size: 25, weight: .bold)) + .padding(.top, 22) + + Text("PHPGuard is inspecting your server") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 15) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(store.scanServerName) + .font(.headline) + + Text(store.scanServerHost) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "checkmark.shield") + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(store.scanStage) + .font(.subheadline.weight(.semibold)) + + Text(store.scanDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView(value: store.scanFraction, total: 1.0) + .progressViewStyle(.linear) + .tint(Color.accentColor) + + HStack { + Text("\(Int(store.scanFraction * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + if store.scanTotalChecks > 0 { + Text( + "CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)" + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + HStack { + Label( + "\(store.scanning.count) active", + systemImage: "network" + ) + + Spacer() + + TimelineView(.periodic(from: .now, by: 1)) { context in + let seconds = max( + 0, + Int(context.date.timeIntervalSince(store.scanStartedAt)) + ) + + Text( + String( + format: "%02d:%02d", + seconds / 60, + seconds % 60 + ) + ) + .monospacedDigit() + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(22) + .background( + Color.primary.opacity(0.035), + in: RoundedRectangle(cornerRadius: 16) + ) + .padding(.horizontal, 30) + .padding(.top, 26) + + Text("The assessment runs remotely over SSH. No server changes are made.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 35) + .padding(.top, 22) + .padding(.bottom, 30) + } + .frame(width: 540) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + pulse = true + } + .animation( + .easeInOut(duration: 1.7).repeatForever(autoreverses: true), + value: pulse + ) + } +} diff --git a/PHPGuard/Models.swift b/PHPGuard/Models.swift index 6098e72..dcfdced 100644 --- a/PHPGuard/Models.swift +++ b/PHPGuard/Models.swift @@ -27,6 +27,9 @@ struct AuditResult: Codable, Identifiable { var fpmServices: [String] = [] var nginxUpstreams: [String] = [] var packageVersions: [String] = [] + var packageSources: [String] = [] + var ubuntuCodename = "" + var ubuntuReleaseID = "" var availableUpdates: [String] = [] var packageOrigins: [String] = [] var dockerPHP: [String] = [] @@ -44,7 +47,7 @@ struct AuditResult: Codable, Identifiable { 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, cveAssessments, cveAssessmentStatus, cveAssessedAt, rawOutput, error, id } + enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, packageSources, ubuntuCodename, ubuntuReleaseID, 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) @@ -57,6 +60,9 @@ struct AuditResult: Codable, Identifiable { fpmServices = try container.decodeIfPresent([String].self, forKey: .fpmServices) ?? [] nginxUpstreams = try container.decodeIfPresent([String].self, forKey: .nginxUpstreams) ?? [] packageVersions = try container.decodeIfPresent([String].self, forKey: .packageVersions) ?? [] + packageSources = try container.decodeIfPresent([String].self, forKey: .packageSources) ?? [] + ubuntuCodename = try container.decodeIfPresent(String.self, forKey: .ubuntuCodename) ?? "" + ubuntuReleaseID = try container.decodeIfPresent(String.self, forKey: .ubuntuReleaseID) ?? "" availableUpdates = try container.decodeIfPresent([String].self, forKey: .availableUpdates) ?? [] packageOrigins = try container.decodeIfPresent([String].self, forKey: .packageOrigins) ?? [] dockerPHP = try container.decodeIfPresent([String].self, forKey: .dockerPHP) ?? [] @@ -86,6 +92,9 @@ struct AuditResult: Codable, Identifiable { try container.encode(fpmServices, forKey: .fpmServices) try container.encode(nginxUpstreams, forKey: .nginxUpstreams) try container.encode(packageVersions, forKey: .packageVersions) + try container.encode(packageSources, forKey: .packageSources) + try container.encode(ubuntuCodename, forKey: .ubuntuCodename) + try container.encode(ubuntuReleaseID, forKey: .ubuntuReleaseID) try container.encode(availableUpdates, forKey: .availableUpdates) try container.encode(packageOrigins, forKey: .packageOrigins) try container.encode(dockerPHP, forKey: .dockerPHP) @@ -125,5 +134,9 @@ struct CVEServerAssessment: Codable, Identifiable { var summary: String var status: String var evidence: String + var vendorStatus: String? = nil + var vendorEvidence: String? = nil + var vendorFixedVersion: String? = nil + var vendorInstalledVersion: String? = nil var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + cveID } } diff --git a/PHPGuard/SSHScanner.swift b/PHPGuard/SSHScanner.swift index 0c172aa..56760a0 100644 --- a/PHPGuard/SSHScanner.swift +++ b/PHPGuard/SSHScanner.swift @@ -15,13 +15,41 @@ enum SSHScanner { static let script = #""" printf '===HOST===\n'; hostname printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s + printf '===OS_RELEASE===\n' + if [ -r /etc/os-release ]; then + . /etc/os-release + printf 'ID=%s\n' "${ID:-unknown}" + printf 'VERSION_ID=%s\n' "${VERSION_ID:-unknown}" + printf 'VERSION_CODENAME=%s\n' "${VERSION_CODENAME:-unknown}" + fi 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*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)? ' | sort -u || true + printf '===PACKAGES===\n' + dpkg-query -W \ + -f='${db:Status-Abbrev} ${binary:Package} ${Version}\n' \ + 'php*' 2>/dev/null | + awk '$1 ~ /^ii/ && $2 ~ /^php/ { print $2, $3 }' | + sort -u || true + + printf '===PACKAGE_SOURCES===\n' + dpkg-query -W \ + -f='${db:Status-Abbrev}|${binary:Package}|${Version}|${source:Package}|${source:Version}\n' \ + 'php*' 2>/dev/null | + while IFS='|' read -r state binary version src srcversion; do + case "$state" in ii*) ;; *) continue ;; esac + case "$binary" in php*) ;; *) continue ;; esac + + [ -n "$version" ] || continue + [ -n "$src" ] || src="$binary" + [ -n "$srcversion" ] || srcversion="$version" + + printf '%s|%s|%s|%s\n' \ + "$binary" "$version" "$src" "$srcversion" + done printf '===UPDATES===\n' if command -v apt >/dev/null 2>&1; then apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true @@ -293,6 +321,17 @@ enum SSHScanner { result.fpmServices = fields["FPM"] ?? [] result.nginxUpstreams = fields["NGINX"] ?? [] result.packageVersions = fields["PACKAGES"] ?? [] + result.packageSources = fields["PACKAGE_SOURCES"] ?? [] + + let osRelease = fields["OS_RELEASE"] ?? [] + + result.ubuntuReleaseID = osRelease + .first(where: { $0.hasPrefix("ID=") }) + .map { String($0.dropFirst(3)) } ?? "" + + result.ubuntuCodename = osRelease + .first(where: { $0.hasPrefix("VERSION_CODENAME=") }) + .map { String($0.dropFirst("VERSION_CODENAME=".count)) } ?? "" result.availableUpdates = fields["UPDATES"] ?? [] result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? [] result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" } diff --git a/PHPGuard/SSHScanner.swift.bak-20261022-152239 b/PHPGuard/SSHScanner.swift.bak-20261022-152239 new file mode 100644 index 0000000..0c172aa --- /dev/null +++ b/PHPGuard/SSHScanner.swift.bak-20261022-152239 @@ -0,0 +1,496 @@ +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*' 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 + 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*' 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) + 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 { + 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/VulnerabilityService.swift b/PHPGuard/VulnerabilityService.swift index 42fb211..a656420 100644 --- a/PHPGuard/VulnerabilityService.swift +++ b/PHPGuard/VulnerabilityService.swift @@ -178,3 +178,280 @@ extension VulnerabilityService { private struct VersionMetric: Decodable { let cvssData: VersionCVSS } private struct VersionCVSS: Decodable { let baseScore: Double?; let baseSeverity: String? } } + + +// MARK: - Ubuntu Security Advisory Verification + +extension VulnerabilityService { + + private struct UbuntuAdvisory: Decodable { + let packages: [UbuntuPackage]? + } + + private struct UbuntuPackage: Decodable { + let name: String + let statuses: [UbuntuStatus]? + } + + private struct UbuntuStatus: Decodable { + let release_codename: String + let status: String + let description: String? + let pocket: String? + } + + private struct InstalledPackage { + let binary: String + let version: String + let source: String + let sourceVersion: String + } + + private static func installedPackages( + _ lines: [String] + ) -> [InstalledPackage] { + + lines.compactMap { line in + + let fields = line.split( + separator: "|", + omittingEmptySubsequences: false + ).map(String.init) + + guard fields.count >= 4 else { + return nil + } + + return InstalledPackage( + binary: fields[0], + version: fields[1], + source: fields[2], + sourceVersion: fields[3] + ) + } + } + + static func assessUbuntu( + _ assessments: [CVEServerAssessment], + report: AuditResult + ) async -> [CVEServerAssessment] { + + guard report.ubuntuReleaseID == "ubuntu", + !report.ubuntuCodename.isEmpty else { + + return assessments.map { original in + var item = original + item.vendorStatus = "Unverified" + item.vendorEvidence = + "Ubuntu release could not be established." + return item + } + } + + let installed = installedPackages(report.packageSources) + + guard !installed.isEmpty else { + + return assessments.map { original in + var item = original + item.vendorStatus = "Unverified" + item.vendorEvidence = + "No installed PHP source packages were collected." + return item + } + } + + var output: [CVEServerAssessment] = [] + + // Cache repeated CVEs across different PHP runtimes. + var cache: [String: UbuntuAdvisory] = [:] + + for original in assessments { + + var item = original + + item.vendorStatus = "Unverified" + + // Ubuntu host packages must never be used to certify + // a Docker runtime as patched. + let runtimeVersion = original.runtime + .replacingOccurrences(of: "PHP ", with: "") + + let hostHasVersion = report.phpVersions.contains { + $0.contains(runtimeVersion) + } + + guard hostHasVersion else { + item.vendorEvidence = + "Runtime not confirmed as a host PHP installation. " + + "Docker image security must be checked separately." + output.append(item) + continue + } + + guard original.cveID.range( + of: #"^CVE-[0-9]{4}-[0-9]{4,}$"#, + options: .regularExpression + ) != nil else { + + item.vendorEvidence = "Invalid CVE identifier." + output.append(item) + continue + } + + do { + + let advisory: UbuntuAdvisory + + if let existing = cache[original.cveID] { + advisory = existing + } else { + + let address = + "https://ubuntu.com/security/cves/" + + original.cveID + ".json" + + guard let url = URL(string: address) else { + throw ServiceError.invalidResponse + } + + var request = URLRequest(url: url) + + request.timeoutInterval = 20 + + request.setValue( + "application/json", + forHTTPHeaderField: "Accept" + ) + + let (data, response) = + try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + + throw ServiceError.invalidResponse + } + + advisory = try JSONDecoder().decode( + UbuntuAdvisory.self, + from: data + ) + + cache[original.cveID] = advisory + } + + var matches: [(String, String)] = [] + + for package in installed { + + guard let advisoryPackage = + advisory.packages?.first(where: { + $0.name == package.source + }) else { + continue + } + + let statuses = ( + advisoryPackage.statuses ?? [] + ).filter { + $0.release_codename == + report.ubuntuCodename + } + + for status in statuses { + + let state = status.status.lowercased() + + let description = + status.description ?? "" + + let evidence = + "\(package.binary) \(package.version), " + + "source \(package.source) " + + "\(package.sourceVersion), " + + "Ubuntu \(report.ubuntuCodename): " + + "\(status.status)" + + ( + description.isEmpty + ? "" + : " (\(description))" + ) + + switch state { + + case "not-affected": + matches.append(( + "Not affected", + evidence + )) + + case "released", "fixed": + matches.append(( + "Ubuntu fix published", + evidence + + ". Installed revision has not " + + "been compared with the fixed revision." + )) + + case "needed": + matches.append(( + "Ubuntu fix needed", + evidence + )) + + case "dne": + matches.append(( + "Package not in Ubuntu release", + evidence + + ". Check third-party repositories." + )) + + default: + matches.append(( + "Unverified", + evidence + )) + } + } + } + + if matches.count == 1 { + + item.vendorStatus = matches[0].0 + item.vendorEvidence = matches[0].1 + + } else if matches.isEmpty { + + item.vendorStatus = "Unverified" + + item.vendorEvidence = + "No matching Ubuntu security advisory " + + "for installed PHP source packages." + + } else { + + // Different PHP packages can have different + // security states. Avoid a false patched verdict. + item.vendorStatus = "Multiple package states" + + item.vendorEvidence = matches + .map { $0.1 } + .joined(separator: "; ") + } + + } catch { + + item.vendorStatus = "Unverified" + + item.vendorEvidence = + "Ubuntu advisory lookup failed: " + + error.localizedDescription + } + + output.append(item) + } + + return output + } +} diff --git a/backups/ubuntu-advisory-phase-20261022-152509/AuditStore.swift b/backups/ubuntu-advisory-phase-20261022-152509/AuditStore.swift new file mode 100644 index 0000000..c5e244b --- /dev/null +++ b/backups/ubuntu-advisory-phase-20261022-152509/AuditStore.swift @@ -0,0 +1,244 @@ +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 scanStage = "Preparing scan" + @Published var scanDetail = "" + @Published var scanFraction = 0.0 + @Published var scanCompletedChecks = 0 + @Published var scanTotalChecks = 0 + + @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 + scanStage = "Connecting to server" + scanDetail = "Establishing SSH connection and collecting evidence" + scanFraction = 0.05 + scanCompletedChecks = 0 + scanTotalChecks = 0 + scanning.insert(server.id) + Task { + defer { + scanning.remove(server.id) + if scanning.isEmpty { + scanProgressVisible = false + } + } + do { + var report = try await SSHScanner.scan(server) + + scanStage = "SSH assessment complete" + scanDetail = "Analysing PHP runtimes and preparing vulnerability checks" + scanFraction = 0.60 + + 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 + } + scanTotalChecks = versions.count + scanCompletedChecks = 0 + + if versions.isEmpty { + scanStage = "No PHP versions detected" + scanDetail = "Skipping NVD vulnerability assessment" + scanFraction = 0.95 + + 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() { + scanStage = "Checking PHP vulnerabilities" + scanDetail = "Querying NVD for PHP " + version + + do { + let records = try await VulnerabilityService.assessPHPVersion( + version, runtime: versions[version] ?? version) + all.append(contentsOf: records) + } catch { + failures.append("PHP \(version): \(error.localizedDescription)") + } + + scanCompletedChecks += 1 + + scanFraction = 0.60 + ( + 0.35 * Double(scanCompletedChecks) / + Double(max(1, scanTotalChecks)) + ) + } + + 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() + } + scanStage = "Saving assessment" + scanDetail = "Writing the completed security report" + scanFraction = 0.98 + + results.insert(report, at: 0) + + scanStage = "Assessment complete" + scanDetail = "Security report saved" + scanFraction = 1.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/backups/ubuntu-advisory-phase-20261022-152509/ContentView.swift b/backups/ubuntu-advisory-phase-20261022-152509/ContentView.swift new file mode 100644 index 0000000..4bf6df9 --- /dev/null +++ b/backups/ubuntu-advisory-phase-20261022-152509/ContentView.swift @@ -0,0 +1,869 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @State private var showInstalledPackages = false + @State private var showAPTOrigins = false + @State private var selection: UUID? + @State private var showingEditor = false + @State private var editing: Server? + @State private var showClearHistoryConfirmation = false + @AppStorage("phpguard.confirmClear") private var confirmClearHistory = true + private func addServer() { editing = nil; showingEditor = true } + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("Servers") { + ForEach(store.servers) { server in + Label { + HStack { + Text(server.name) + Spacer() + if store.scanning.contains(server.id) { ProgressView().controlSize(.small) } + else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) } + } + } icon: { Image(systemName: "server.rack") } + .tag(server.id) + .contextMenu { + Button("Scan") { store.scan(server) } + Button("Edit") { editing = server; showingEditor = true } + Divider() + Button("Remove", role: .destructive) { + store.servers.removeAll { $0.id == server.id } + if selection == server.id { selection = nil } + } + } + } + } + Section("Navigation") { + Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) + Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + } + } + .navigationTitle("PHPGuard") + .safeAreaInset(edge: .bottom) { + Button(action: addServer) { + Label("Add Server", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(12) + .background(.bar) + } + } detail: { + if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") { + cveView + } else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") { + historyView + } else if let server = store.servers.first(where: { $0.id == selection }) { + serverView(server) + } else { dashboard } + } + .sheet(isPresented: $store.scanProgressVisible) { + ScanProgressView(store: store) + .interactiveDismissDisabled(!store.scanning.isEmpty) + } + .sheet(isPresented: $showingEditor) { + ServerEditor(existing: editing) { server in + if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server } + else { store.servers.append(server) } + selection = server.id + } + } + .alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) { + Button("OK") { store.lastError = nil } + } message: { Text(store.lastError ?? "") } + } + private var dashboard: some View { + VStack(alignment: .leading, spacing: 24) { + Text("Security overview").font(.largeTitle.bold()) + HStack(spacing: 16) { + stat("Servers", "\(store.servers.count)", "server.rack") + stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") + stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") + } + Text("Read-only PHP inventory, APT checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.") + .foregroundStyle(.secondary) + HStack { + Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } + .buttonStyle(.borderedProminent) + Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) + Button("Import JSON scan") { store.importReport() } + } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private func stat(_ title: String, _ value: String, _ icon: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon).foregroundStyle(.secondary) + Text(value).font(.system(size: 36, weight: .bold)) + }.padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14)) + } + private func serverView(_ server: Server) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading) { + Text(server.name).font(.largeTitle.bold()) + Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary) + } + Spacer() + Button("Edit") { editing = server; showingEditor = true } + Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent) + .disabled(store.scanning.contains(server.id)) + } + if let result = store.latest(for: server) { + if let error = result.error { + Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red) + } else { + HStack(spacing: 12) { + Label("Scan completed", systemImage: "checkmark.shield.fill") + .foregroundStyle(.green) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + Spacer() + Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") } + .buttonStyle(.borderedProminent) + Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") } + } + .padding(16) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + Text("OS: \(result.os) • Host: \(result.hostname)") + inventoryCards( + php: result.phpVersions, + fpm: result.fpmServices, + nginx: result.nginxUpstreams, + packages: result.packageVersions + ) + dataSection("Available PHP package upgrades", result.availableUpdates) + DisclosureGroup( + isExpanded: $showAPTOrigins + ) { + dataSection( + "APT package origins and candidate revisions", + result.packageOrigins + ) + } label: { + Label( + "APT package origins and candidate revisions", + systemImage: "shippingbox" + ) + .font(.headline) + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) + Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) + dataSection("PHP detected in running Docker containers", result.dockerPHP) + Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) + dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage) + dataSection("Security triage — follow-up actions", result.securityTriage) + VStack(alignment: .leading, spacing: 10) { + Text("CVE assessment").font(.title2.bold()) + Text(result.cveAssessmentStatus).foregroundStyle(.secondary) + Text("NVD 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() + Text("\(result.findings.count) total").foregroundStyle(.secondary) + } + ForEach(result.findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill") + Text(finding.severity.uppercased()).font(.caption.bold()) + Text(finding.title).font(.headline) + } + .foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue)) + Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + + } + } else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) } + }.padding(28).frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - PHP Inventory Dashboard + + private func inventoryCards( + php: [String], + fpm: [String], + nginx: [String], + packages: [String] + ) -> some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16, alignment: .top), + GridItem(.flexible(), spacing: 16, alignment: .top) + ], + alignment: .leading, + spacing: 16 + ) { + phpInventoryCard(php) + fpmInventoryCard(fpm) + nginxInventoryCard(nginx) + packageInventoryCard(packages) + } + .frame(maxWidth: .infinity) + } + + private func inventoryCard( + title: String, + subtitle: String, + icon: String, + tint: Color, + count: Int, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 40, height: 40) + .background( + tint.opacity(0.12), + in: RoundedRectangle(cornerRadius: 11) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Text("\(count)") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Color.secondary.opacity(0.10), + in: Capsule() + ) + } + + Rectangle() + .fill(Color.primary.opacity(0.07)) + .frame(height: 1) + + content() + + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading) + .background( + Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 18) + ) + .overlay { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + + private func inventoryBadge( + _ text: String, + color: Color + ) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + + Text(text) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.10), in: Capsule()) + } + + private func phpInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP Installations", + subtitle: "Detected PHP runtimes", + icon: "chevron.left.forwardslash.chevron.right", + tint: .blue, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP runtime detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + separator: " ", + omittingEmptySubsequences: true + ) + + let version = parts.last.map(String.init) ?? "Unknown" + let executable = parts.first.map(String.init) ?? line + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("PHP \(version)") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Spacer() + + inventoryBadge("Detected", color: .green) + } + + Label(executable, systemImage: "terminal") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + } + } + } + } + + private func fpmInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP-FPM Services", + subtitle: "FastCGI process managers", + icon: "gearshape.2.fill", + tint: .purple, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP-FPM services detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + whereSeparator: { $0.isWhitespace } + ) + + let service = parts.first.map(String.init) ?? line + + let running = line.localizedCaseInsensitiveContains( + "active running" + ) || ( + line.contains("active") && + line.contains("running") + ) + + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 10) { + Text(service) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + .lineLimit(2) + + Spacer(minLength: 6) + + inventoryBadge( + running ? "Running" : "Check status", + color: running ? .green : .orange + ) + } + + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + } + } + } + } + + private func nginxInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Nginx FastCGI", + subtitle: "PHP routing configuration", + icon: "network", + tint: .cyan, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No FastCGI routes detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let route = line + .replacingOccurrences( + of: "fastcgi_pass", + with: "" + ) + .trimmingCharacters( + in: .whitespacesAndNewlines + ) + .trimmingCharacters( + in: CharacterSet(charactersIn: ";") + ) + + let isSocket = route.hasPrefix("unix:") + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text( + isSocket + ? "Unix socket" + : "FastCGI endpoint" + ) + .font(.subheadline.weight(.semibold)) + + Spacer() + + inventoryBadge( + "Configured", + color: .blue + ) + } + + Text( + isSocket + ? String(route.dropFirst(5)) + : route + ) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + } + } + } + } + } + + private func packageInventoryCard(_ values: [String]) -> some View { + DisclosureGroup(isExpanded: $showInstalledPackages) { + inventoryCard( + title: "Installed Packages", + subtitle: "PHP package inventory", + icon: "shippingbox.fill", + tint: .orange, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP packages detected") + } else { + VStack(spacing: 0) { + ForEach(Array(values.enumerated()), id: \.offset) { index, line in + let parts = line.split( + separator: " ", + maxSplits: 1, + omittingEmptySubsequences: true + ) + + let name = parts.first.map(String.init) ?? line + let version = parts.count > 1 + ? String(parts[1]) + : "Version not reported" + + VStack(alignment: .leading, spacing: 5) { + Text(name) + .font(.subheadline.weight(.semibold)) + + Text(version) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 9) + + if index < values.count - 1 { + Divider() + } + } + } + } + } + } label: { + HStack { + Label("Installed Packages", systemImage: "shippingbox.fill") + .font(.headline) + + Spacer() + + Text("\(values.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) + } + + private func inventoryEmptyState(_ message: String) -> some View { + HStack(spacing: 9) { + Image(systemName: "minus.circle") + .foregroundStyle(.tertiary) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 12) + } + + private func dataSection(_ title: String, _ values: [String]) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22) + Text(title).font(.headline) + Spacer() + Text("\(values.count)").font(.caption).foregroundStyle(.secondary) + } + if values.isEmpty { Text("None detected").foregroundStyle(.secondary) } + else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14)) + } + private var cveView: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("PHP CVE research").font(.largeTitle.bold()) + Spacer() + if store.fetchingCVEs { ProgressView() } + Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs) + } + Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.") + .foregroundStyle(.secondary) + if let date = store.cveLastChecked { + Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch") + .foregroundStyle(.secondary) + } else { + Text("No cached CVE research yet · Automatically fetched at launch") + .foregroundStyle(.secondary) + } + if let error = store.cveError { Text(error).foregroundStyle(.red) } + List(store.discoveredCVEs) { cve in + VStack(alignment: .leading, spacing: 6) { + HStack { + Link(cve.id, destination: URL(string: cve.referenceURL)!) + .font(.headline) + Spacer() + Text(cve.severity + (cve.score.map { " (\($0))" } ?? "")) + Text(String(cve.published.prefix(10))).foregroundStyle(.secondary) + } + Text(cve.description).lineLimit(4).textSelection(.enabled) + }.padding(.vertical, 5) + } + }.padding(24) + } + private var historyView: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Scan history").font(.largeTitle.bold()) + Text("\(store.results.count) saved scans").foregroundStyle(.secondary) + } + Spacer() + Button("Clear History", role: .destructive) { + if confirmClearHistory { showClearHistoryConfirmation = true } + else { store.clearHistory() } + }.disabled(store.results.isEmpty) + }.padding(20) + List { + ForEach(store.results) { result in + HStack(spacing: 12) { + Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill") + .foregroundStyle(result.error == nil ? .green : .red) + VStack(alignment: .leading, spacing: 4) { + Text(result.serverName).font(.headline) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) } + else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations") + .font(.caption).foregroundStyle(.secondary) } + } + Spacer() + Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil) + Button("JSON") { store.export(result) }.disabled(result.error != nil) + }.padding(.vertical, 5) + }.onDelete(perform: store.removeHistory) + } + } + .confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) { + Button("Clear History", role: .destructive) { store.clearHistory() } + } message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") } + } + + +} + +struct ServerEditor: View { + @Environment(\.dismiss) private var dismiss + let existing: Server? + let onSave: (Server) -> Void + @State private var server: Server + init(existing: Server?, onSave: @escaping (Server) -> Void) { + self.existing = existing + self.onSave = onSave + _server = State(initialValue: existing ?? Server(name: "", host: "")) + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack") + .font(.title.bold()) + Text("Connect using an existing SSH key. No agent installation required.") + .foregroundStyle(.secondary) + Form { + Section("Connection") { + TextField("Server name (e.g. Pegasus)", text: $server.name) + TextField("Hostname or IP address", text: $server.host) + .textContentType(.URL) + TextField("SSH username", text: $server.user) + TextField("SSH port", value: $server.port, format: .number) + } + Section("Advanced (optional)") { + HStack { + TextField("SSH private key path", text: $server.identityFile) + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + if panel.runModal() == .OK, let url = panel.url { + server.identityFile = url.path + } + } + } + Text("Leave blank to use your existing SSH configuration and keys.") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + if !isValid { + Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle") + .foregroundStyle(.secondary).font(.caption) + } + Text("Host key verification is strict. Connect in Terminal first if this server is new.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + Button(existing == nil ? "Add Server" : "Save Changes") { + server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines) + server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(server) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + }.padding(24).frame(width: 580) + } + private var isValid: Bool { + let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !host.isEmpty && !host.hasPrefix("-") + && host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil + && user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil + && (1...65535).contains(server.port) + } +} + +// MARK: - Security Assessment Progress + +struct ScanProgressView: View { + @ObservedObject var store: AuditStore + + @State private var pulse = false + + private var elapsed: String { + let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.09)) + .frame(width: 108, height: 108) + .scaleEffect(pulse ? 1.12 : 0.94) + + Circle() + .stroke(Color.accentColor.opacity(0.18), lineWidth: 2) + .frame(width: 94, height: 94) + + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(Color.accentColor) + .symbolRenderingMode(.hierarchical) + } + .padding(.top, 34) + + Text("Security Assessment") + .font(.system(size: 25, weight: .bold)) + .padding(.top, 22) + + Text("PHPGuard is inspecting your server") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 15) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(store.scanServerName) + .font(.headline) + + Text(store.scanServerHost) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "checkmark.shield") + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(store.scanStage) + .font(.subheadline.weight(.semibold)) + + Text(store.scanDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView(value: store.scanFraction, total: 1.0) + .progressViewStyle(.linear) + .tint(Color.accentColor) + + HStack { + Text("\(Int(store.scanFraction * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + if store.scanTotalChecks > 0 { + Text( + "CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)" + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + HStack { + Label( + "\(store.scanning.count) active", + systemImage: "network" + ) + + Spacer() + + TimelineView(.periodic(from: .now, by: 1)) { context in + let seconds = max( + 0, + Int(context.date.timeIntervalSince(store.scanStartedAt)) + ) + + Text( + String( + format: "%02d:%02d", + seconds / 60, + seconds % 60 + ) + ) + .monospacedDigit() + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(22) + .background( + Color.primary.opacity(0.035), + in: RoundedRectangle(cornerRadius: 16) + ) + .padding(.horizontal, 30) + .padding(.top, 26) + + Text("The assessment runs remotely over SSH. No server changes are made.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 35) + .padding(.top, 22) + .padding(.bottom, 30) + } + .frame(width: 540) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + pulse = true + } + .animation( + .easeInOut(duration: 1.7).repeatForever(autoreverses: true), + value: pulse + ) + } +} diff --git a/backups/ubuntu-advisory-phase-20261022-152509/VulnerabilityService.swift b/backups/ubuntu-advisory-phase-20261022-152509/VulnerabilityService.swift new file mode 100644 index 0000000..42fb211 --- /dev/null +++ b/backups/ubuntu-advisory-phase-20261022-152509/VulnerabilityService.swift @@ -0,0 +1,180 @@ +import Foundation + +enum VulnerabilityService { + + enum ServiceError: LocalizedError { + case invalidResponse + case httpError(Int) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid response from the vulnerability database." + case .httpError(let code): + return "Vulnerability database returned HTTP \(code)." + } + } + } + + private struct NVDResponse: Decodable { + let vulnerabilities: [Entry] + } + + private struct Entry: Decodable { + let cve: CVE + } + + private struct CVE: Decodable { + let id: String + let published: String + let descriptions: [Description] + let metrics: Metrics? + } + + private struct Description: Decodable { + let lang: String + let value: String + } + + private struct Metrics: Decodable { + let cvssMetricV31: [Metric]? + let cvssMetricV30: [Metric]? + let cvssMetricV2: [Metric]? + } + + private struct Metric: Decodable { + let cvssData: CVSSData + } + + private struct CVSSData: Decodable { + let baseScore: Double? + let baseSeverity: String? + } + + static func discover() async throws -> [CVERecord] { + + var components = URLComponents( + string: "https://services.nvd.nist.gov/rest/json/cves/2.0" + )! + + components.queryItems = [ + URLQueryItem(name: "keywordSearch", value: "PHP"), + URLQueryItem(name: "resultsPerPage", value: "100") + ] + + guard let url = components.url else { + throw ServiceError.invalidResponse + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.setValue( + "application/json", + forHTTPHeaderField: "Accept" + ) + request.setValue( + "PHPGuard/1.4", + forHTTPHeaderField: "User-Agent" + ) + + let (data, response) = try await URLSession.shared.data( + for: request + ) + + guard let http = response as? HTTPURLResponse else { + throw ServiceError.invalidResponse + } + + guard (200...299).contains(http.statusCode) else { + throw ServiceError.httpError(http.statusCode) + } + + let decoded = try JSONDecoder().decode( + NVDResponse.self, + from: data + ) + + return decoded.vulnerabilities.map { entry in + + let cve = entry.cve + + let description = cve.descriptions.first { + $0.lang == "en" + }?.value ?? "No description available." + + let metric = + cve.metrics?.cvssMetricV31?.first ?? + cve.metrics?.cvssMetricV30?.first ?? + cve.metrics?.cvssMetricV2?.first + + return CVERecord( + id: cve.id, + description: description, + published: cve.published, + severity: metric?.cvssData.baseSeverity ?? "UNKNOWN", + score: metric?.cvssData.baseScore + ) + } + .sorted { $0.published > $1.published } + } +} + + +extension VulnerabilityService { + // Returns upstream PHP CVE candidates, not confirmed Ubuntu/Debian exposures. + static func assessPHPVersion(_ version: String, runtime: String) async throws -> [CVEServerAssessment] { + let parts = version.split(separator: ".") + guard parts.count >= 3, + parts.prefix(3).allSatisfy({ Int($0) != nil }) else { return [] } + let canonical = parts.prefix(3).joined(separator: ".") + var components = URLComponents(string: "https://services.nvd.nist.gov/rest/json/cves/2.0")! + components.queryItems = [ + URLQueryItem(name: "cpeName", value: "cpe:2.3:a:php:php:\(canonical):*:*:*:*:*:*:*"), + URLQueryItem(name: "resultsPerPage", value: "2000") + ] + guard let url = components.url else { throw ServiceError.invalidResponse } + var request = URLRequest(url: url) + request.timeoutInterval = 45 + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("PHPGuard/1.5", forHTTPHeaderField: "User-Agent") + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } + guard (200...299).contains(http.statusCode) else { throw ServiceError.httpError(http.statusCode) } + let payload = try JSONDecoder().decode(VersionResponse.self, from: data) + // An incomplete NVD response must not be presented as full coverage. + guard payload.totalResults <= payload.vulnerabilities.count else { + throw ServiceError.invalidResponse + } + return payload.vulnerabilities.map { entry in + let cve = entry.cve + let metric = cve.metrics?.cvssMetricV31?.first ?? cve.metrics?.cvssMetricV30?.first ?? cve.metrics?.cvssMetricV2?.first + return CVEServerAssessment( + cveID: cve.id, runtime: runtime, + severity: metric?.cvssData.baseSeverity ?? "UNKNOWN", + score: metric?.cvssData.baseScore, + summary: cve.descriptions.first(where: { $0.lang == "en" })?.value ?? "No English description", + status: "Potentially affected", + evidence: "NVD PHP product CPE matches upstream PHP \(canonical). Distribution package revisions, backported patches, and container image fixes have NOT been verified." + ) + } + } + + private struct VersionResponse: Decodable { + let totalResults: Int + let vulnerabilities: [VersionEntry] + } + private struct VersionEntry: Decodable { let cve: VersionCVE } + private struct VersionCVE: Decodable { + let id: String + let descriptions: [VersionDescription] + let metrics: VersionMetrics? + } + private struct VersionDescription: Decodable { let lang: String; let value: String } + private struct VersionMetrics: Decodable { + let cvssMetricV31: [VersionMetric]? + let cvssMetricV30: [VersionMetric]? + let cvssMetricV2: [VersionMetric]? + } + private struct VersionMetric: Decodable { let cvssData: VersionCVSS } + private struct VersionCVSS: Decodable { let baseScore: Double?; let baseSeverity: String? } +} diff --git a/backups/ubuntu-cve-20261022-152152/AuditStore.swift b/backups/ubuntu-cve-20261022-152152/AuditStore.swift new file mode 100644 index 0000000..c5e244b --- /dev/null +++ b/backups/ubuntu-cve-20261022-152152/AuditStore.swift @@ -0,0 +1,244 @@ +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 scanStage = "Preparing scan" + @Published var scanDetail = "" + @Published var scanFraction = 0.0 + @Published var scanCompletedChecks = 0 + @Published var scanTotalChecks = 0 + + @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 + scanStage = "Connecting to server" + scanDetail = "Establishing SSH connection and collecting evidence" + scanFraction = 0.05 + scanCompletedChecks = 0 + scanTotalChecks = 0 + scanning.insert(server.id) + Task { + defer { + scanning.remove(server.id) + if scanning.isEmpty { + scanProgressVisible = false + } + } + do { + var report = try await SSHScanner.scan(server) + + scanStage = "SSH assessment complete" + scanDetail = "Analysing PHP runtimes and preparing vulnerability checks" + scanFraction = 0.60 + + 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 + } + scanTotalChecks = versions.count + scanCompletedChecks = 0 + + if versions.isEmpty { + scanStage = "No PHP versions detected" + scanDetail = "Skipping NVD vulnerability assessment" + scanFraction = 0.95 + + 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() { + scanStage = "Checking PHP vulnerabilities" + scanDetail = "Querying NVD for PHP " + version + + do { + let records = try await VulnerabilityService.assessPHPVersion( + version, runtime: versions[version] ?? version) + all.append(contentsOf: records) + } catch { + failures.append("PHP \(version): \(error.localizedDescription)") + } + + scanCompletedChecks += 1 + + scanFraction = 0.60 + ( + 0.35 * Double(scanCompletedChecks) / + Double(max(1, scanTotalChecks)) + ) + } + + 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() + } + scanStage = "Saving assessment" + scanDetail = "Writing the completed security report" + scanFraction = 0.98 + + results.insert(report, at: 0) + + scanStage = "Assessment complete" + scanDetail = "Security report saved" + scanFraction = 1.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/backups/ubuntu-cve-20261022-152152/ContentView.swift b/backups/ubuntu-cve-20261022-152152/ContentView.swift new file mode 100644 index 0000000..4bf6df9 --- /dev/null +++ b/backups/ubuntu-cve-20261022-152152/ContentView.swift @@ -0,0 +1,869 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @State private var showInstalledPackages = false + @State private var showAPTOrigins = false + @State private var selection: UUID? + @State private var showingEditor = false + @State private var editing: Server? + @State private var showClearHistoryConfirmation = false + @AppStorage("phpguard.confirmClear") private var confirmClearHistory = true + private func addServer() { editing = nil; showingEditor = true } + var body: some View { + NavigationSplitView { + List(selection: $selection) { + Section("Servers") { + ForEach(store.servers) { server in + Label { + HStack { + Text(server.name) + Spacer() + if store.scanning.contains(server.id) { ProgressView().controlSize(.small) } + else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) } + } + } icon: { Image(systemName: "server.rack") } + .tag(server.id) + .contextMenu { + Button("Scan") { store.scan(server) } + Button("Edit") { editing = server; showingEditor = true } + Divider() + Button("Remove", role: .destructive) { + store.servers.removeAll { $0.id == server.id } + if selection == server.id { selection = nil } + } + } + } + } + Section("Navigation") { + Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!) + Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + } + } + .navigationTitle("PHPGuard") + .safeAreaInset(edge: .bottom) { + Button(action: addServer) { + Label("Add Server", systemImage: "plus.circle.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(12) + .background(.bar) + } + } detail: { + if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") { + cveView + } else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") { + historyView + } else if let server = store.servers.first(where: { $0.id == selection }) { + serverView(server) + } else { dashboard } + } + .sheet(isPresented: $store.scanProgressVisible) { + ScanProgressView(store: store) + .interactiveDismissDisabled(!store.scanning.isEmpty) + } + .sheet(isPresented: $showingEditor) { + ServerEditor(existing: editing) { server in + if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server } + else { store.servers.append(server) } + selection = server.id + } + } + .alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) { + Button("OK") { store.lastError = nil } + } message: { Text(store.lastError ?? "") } + } + private var dashboard: some View { + VStack(alignment: .leading, spacing: 24) { + Text("Security overview").font(.largeTitle.bold()) + HStack(spacing: 16) { + stat("Servers", "\(store.servers.count)", "server.rack") + stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") + stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") + } + Text("Read-only PHP inventory, APT checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.") + .foregroundStyle(.secondary) + HStack { + Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } + .buttonStyle(.borderedProminent) + Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) + Button("Import JSON scan") { store.importReport() } + } + Spacer() + }.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + private func stat(_ title: String, _ value: String, _ icon: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: icon).foregroundStyle(.secondary) + Text(value).font(.system(size: 36, weight: .bold)) + }.padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14)) + } + private func serverView(_ server: Server) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading) { + Text(server.name).font(.largeTitle.bold()) + Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary) + } + Spacer() + Button("Edit") { editing = server; showingEditor = true } + Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent) + .disabled(store.scanning.contains(server.id)) + } + if let result = store.latest(for: server) { + if let error = result.error { + Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red) + } else { + HStack(spacing: 12) { + Label("Scan completed", systemImage: "checkmark.shield.fill") + .foregroundStyle(.green) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + Spacer() + Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") } + .buttonStyle(.borderedProminent) + Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") } + } + .padding(16) + .background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) + Text("OS: \(result.os) • Host: \(result.hostname)") + inventoryCards( + php: result.phpVersions, + fpm: result.fpmServices, + nginx: result.nginxUpstreams, + packages: result.packageVersions + ) + dataSection("Available PHP package upgrades", result.availableUpdates) + DisclosureGroup( + isExpanded: $showAPTOrigins + ) { + dataSection( + "APT package origins and candidate revisions", + result.packageOrigins + ) + } label: { + Label( + "APT package origins and candidate revisions", + systemImage: "shippingbox" + ) + .font(.headline) + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) + Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary) + dataSection("PHP detected in running Docker containers", result.dockerPHP) + Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary) + dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage) + dataSection("Security triage — follow-up actions", result.securityTriage) + VStack(alignment: .leading, spacing: 10) { + Text("CVE assessment").font(.title2.bold()) + Text(result.cveAssessmentStatus).foregroundStyle(.secondary) + Text("NVD 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() + Text("\(result.findings.count) total").foregroundStyle(.secondary) + } + ForEach(result.findings) { finding in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 10) { + Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill") + Text(finding.severity.uppercased()).font(.caption.bold()) + Text(finding.title).font(.headline) + } + .foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue)) + Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + + } + } else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) } + }.padding(28).frame(maxWidth: .infinity, alignment: .leading) + } + } + + // MARK: - PHP Inventory Dashboard + + private func inventoryCards( + php: [String], + fpm: [String], + nginx: [String], + packages: [String] + ) -> some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16, alignment: .top), + GridItem(.flexible(), spacing: 16, alignment: .top) + ], + alignment: .leading, + spacing: 16 + ) { + phpInventoryCard(php) + fpmInventoryCard(fpm) + nginxInventoryCard(nginx) + packageInventoryCard(packages) + } + .frame(maxWidth: .infinity) + } + + private func inventoryCard( + title: String, + subtitle: String, + icon: String, + tint: Color, + count: Int, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: icon) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 40, height: 40) + .background( + tint.opacity(0.12), + in: RoundedRectangle(cornerRadius: 11) + ) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.headline) + .foregroundStyle(.primary) + + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + Text("\(count)") + .font(.caption.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Color.secondary.opacity(0.10), + in: Capsule() + ) + } + + Rectangle() + .fill(Color.primary.opacity(0.07)) + .frame(height: 1) + + content() + + Spacer(minLength: 0) + } + .padding(20) + .frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading) + .background( + Color(nsColor: .controlBackgroundColor), + in: RoundedRectangle(cornerRadius: 18) + ) + .overlay { + RoundedRectangle(cornerRadius: 18) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1) + } + } + + private func inventoryBadge( + _ text: String, + color: Color + ) -> some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 7, height: 7) + + Text(text) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.10), in: Capsule()) + } + + private func phpInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP Installations", + subtitle: "Detected PHP runtimes", + icon: "chevron.left.forwardslash.chevron.right", + tint: .blue, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP runtime detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + separator: " ", + omittingEmptySubsequences: true + ) + + let version = parts.last.map(String.init) ?? "Unknown" + let executable = parts.first.map(String.init) ?? line + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 10) { + Text("PHP \(version)") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Spacer() + + inventoryBadge("Detected", color: .green) + } + + Label(executable, systemImage: "terminal") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + } + } + } + } + + private func fpmInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "PHP-FPM Services", + subtitle: "FastCGI process managers", + icon: "gearshape.2.fill", + tint: .purple, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP-FPM services detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let parts = line.split( + whereSeparator: { $0.isWhitespace } + ) + + let service = parts.first.map(String.init) ?? line + + let running = line.localizedCaseInsensitiveContains( + "active running" + ) || ( + line.contains("active") && + line.contains("running") + ) + + VStack(alignment: .leading, spacing: 9) { + HStack(alignment: .top, spacing: 10) { + Text(service) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + .lineLimit(2) + + Spacer(minLength: 6) + + inventoryBadge( + running ? "Running" : "Check status", + color: running ? .green : .orange + ) + } + + Text(line) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + } + } + } + } + } + } + + private func nginxInventoryCard(_ values: [String]) -> some View { + inventoryCard( + title: "Nginx FastCGI", + subtitle: "PHP routing configuration", + icon: "network", + tint: .cyan, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No FastCGI routes detected") + } else { + VStack(alignment: .leading, spacing: 14) { + ForEach(values, id: \.self) { line in + let route = line + .replacingOccurrences( + of: "fastcgi_pass", + with: "" + ) + .trimmingCharacters( + in: .whitespacesAndNewlines + ) + .trimmingCharacters( + in: CharacterSet(charactersIn: ";") + ) + + let isSocket = route.hasPrefix("unix:") + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text( + isSocket + ? "Unix socket" + : "FastCGI endpoint" + ) + .font(.subheadline.weight(.semibold)) + + Spacer() + + inventoryBadge( + "Configured", + color: .blue + ) + } + + Text( + isSocket + ? String(route.dropFirst(5)) + : route + ) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize( + horizontal: false, + vertical: true + ) + } + } + } + } + } + } + + private func packageInventoryCard(_ values: [String]) -> some View { + DisclosureGroup(isExpanded: $showInstalledPackages) { + inventoryCard( + title: "Installed Packages", + subtitle: "PHP package inventory", + icon: "shippingbox.fill", + tint: .orange, + count: values.count + ) { + if values.isEmpty { + inventoryEmptyState("No PHP packages detected") + } else { + VStack(spacing: 0) { + ForEach(Array(values.enumerated()), id: \.offset) { index, line in + let parts = line.split( + separator: " ", + maxSplits: 1, + omittingEmptySubsequences: true + ) + + let name = parts.first.map(String.init) ?? line + let version = parts.count > 1 + ? String(parts[1]) + : "Version not reported" + + VStack(alignment: .leading, spacing: 5) { + Text(name) + .font(.subheadline.weight(.semibold)) + + Text(version) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 9) + + if index < values.count - 1 { + Divider() + } + } + } + } + } + } label: { + HStack { + Label("Installed Packages", systemImage: "shippingbox.fill") + .font(.headline) + + Spacer() + + Text("\(values.count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(16) + .background( + .quaternary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 14) + ) + } + + private func inventoryEmptyState(_ message: String) -> some View { + HStack(spacing: 9) { + Image(systemName: "minus.circle") + .foregroundStyle(.tertiary) + + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 12) + } + + private func dataSection(_ title: String, _ values: [String]) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22) + Text(title).font(.headline) + Spacer() + Text("\(values.count)").font(.caption).foregroundStyle(.secondary) + } + if values.isEmpty { Text("None detected").foregroundStyle(.secondary) } + else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14)) + } + private var cveView: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("PHP CVE research").font(.largeTitle.bold()) + Spacer() + if store.fetchingCVEs { ProgressView() } + Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs) + } + Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.") + .foregroundStyle(.secondary) + if let date = store.cveLastChecked { + Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch") + .foregroundStyle(.secondary) + } else { + Text("No cached CVE research yet · Automatically fetched at launch") + .foregroundStyle(.secondary) + } + if let error = store.cveError { Text(error).foregroundStyle(.red) } + List(store.discoveredCVEs) { cve in + VStack(alignment: .leading, spacing: 6) { + HStack { + Link(cve.id, destination: URL(string: cve.referenceURL)!) + .font(.headline) + Spacer() + Text(cve.severity + (cve.score.map { " (\($0))" } ?? "")) + Text(String(cve.published.prefix(10))).foregroundStyle(.secondary) + } + Text(cve.description).lineLimit(4).textSelection(.enabled) + }.padding(.vertical, 5) + } + }.padding(24) + } + private var historyView: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Scan history").font(.largeTitle.bold()) + Text("\(store.results.count) saved scans").foregroundStyle(.secondary) + } + Spacer() + Button("Clear History", role: .destructive) { + if confirmClearHistory { showClearHistoryConfirmation = true } + else { store.clearHistory() } + }.disabled(store.results.isEmpty) + }.padding(20) + List { + ForEach(store.results) { result in + HStack(spacing: 12) { + Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill") + .foregroundStyle(result.error == nil ? .green : .red) + VStack(alignment: .leading, spacing: 4) { + Text(result.serverName).font(.headline) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) } + else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations") + .font(.caption).foregroundStyle(.secondary) } + } + Spacer() + Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil) + Button("JSON") { store.export(result) }.disabled(result.error != nil) + }.padding(.vertical, 5) + }.onDelete(perform: store.removeHistory) + } + } + .confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) { + Button("Clear History", role: .destructive) { store.clearHistory() } + } message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") } + } + + +} + +struct ServerEditor: View { + @Environment(\.dismiss) private var dismiss + let existing: Server? + let onSave: (Server) -> Void + @State private var server: Server + init(existing: Server?, onSave: @escaping (Server) -> Void) { + self.existing = existing + self.onSave = onSave + _server = State(initialValue: existing ?? Server(name: "", host: "")) + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack") + .font(.title.bold()) + Text("Connect using an existing SSH key. No agent installation required.") + .foregroundStyle(.secondary) + Form { + Section("Connection") { + TextField("Server name (e.g. Pegasus)", text: $server.name) + TextField("Hostname or IP address", text: $server.host) + .textContentType(.URL) + TextField("SSH username", text: $server.user) + TextField("SSH port", value: $server.port, format: .number) + } + Section("Advanced (optional)") { + HStack { + TextField("SSH private key path", text: $server.identityFile) + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + if panel.runModal() == .OK, let url = panel.url { + server.identityFile = url.path + } + } + } + Text("Leave blank to use your existing SSH configuration and keys.") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + if !isValid { + Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle") + .foregroundStyle(.secondary).font(.caption) + } + Text("Host key verification is strict. Connect in Terminal first if this server is new.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + Spacer() + Button(existing == nil ? "Add Server" : "Save Changes") { + server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines) + server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + onSave(server) + dismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!isValid) + } + }.padding(24).frame(width: 580) + } + private var isValid: Bool { + let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines) + let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines) + return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !host.isEmpty && !host.hasPrefix("-") + && host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil + && user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil + && (1...65535).contains(server.port) + } +} + +// MARK: - Security Assessment Progress + +struct ScanProgressView: View { + @ObservedObject var store: AuditStore + + @State private var pulse = false + + private var elapsed: String { + let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt))) + return String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + var body: some View { + VStack(spacing: 0) { + ZStack { + Circle() + .fill(Color.accentColor.opacity(0.09)) + .frame(width: 108, height: 108) + .scaleEffect(pulse ? 1.12 : 0.94) + + Circle() + .stroke(Color.accentColor.opacity(0.18), lineWidth: 2) + .frame(width: 94, height: 94) + + Image(systemName: "shield.lefthalf.filled") + .font(.system(size: 42, weight: .medium)) + .foregroundStyle(Color.accentColor) + .symbolRenderingMode(.hierarchical) + } + .padding(.top, 34) + + Text("Security Assessment") + .font(.system(size: 25, weight: .bold)) + .padding(.top, 22) + + Text("PHPGuard is inspecting your server") + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.top, 6) + + VStack(alignment: .leading, spacing: 15) { + HStack(spacing: 12) { + Image(systemName: "server.rack") + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 4) { + Text(store.scanServerName) + .font(.headline) + + Text(store.scanServerHost) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: "checkmark.shield") + .foregroundStyle(.secondary) + } + + Divider() + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(store.scanStage) + .font(.subheadline.weight(.semibold)) + + Text(store.scanDetail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer() + + ProgressView() + .controlSize(.regular) + } + + ProgressView(value: store.scanFraction, total: 1.0) + .progressViewStyle(.linear) + .tint(Color.accentColor) + + HStack { + Text("\(Int(store.scanFraction * 100))%") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + Spacer() + + if store.scanTotalChecks > 0 { + Text( + "CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)" + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + HStack { + Label( + "\(store.scanning.count) active", + systemImage: "network" + ) + + Spacer() + + TimelineView(.periodic(from: .now, by: 1)) { context in + let seconds = max( + 0, + Int(context.date.timeIntervalSince(store.scanStartedAt)) + ) + + Text( + String( + format: "%02d:%02d", + seconds / 60, + seconds % 60 + ) + ) + .monospacedDigit() + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(22) + .background( + Color.primary.opacity(0.035), + in: RoundedRectangle(cornerRadius: 16) + ) + .padding(.horizontal, 30) + .padding(.top, 26) + + Text("The assessment runs remotely over SSH. No server changes are made.") + .font(.caption) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 35) + .padding(.top, 22) + .padding(.bottom, 30) + } + .frame(width: 540) + .fixedSize(horizontal: false, vertical: true) + .onAppear { + pulse = true + } + .animation( + .easeInOut(duration: 1.7).repeatForever(autoreverses: true), + value: pulse + ) + } +} diff --git a/backups/ubuntu-cve-20261022-152152/Models.swift b/backups/ubuntu-cve-20261022-152152/Models.swift new file mode 100644 index 0000000..6098e72 --- /dev/null +++ b/backups/ubuntu-cve-20261022-152152/Models.swift @@ -0,0 +1,129 @@ +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 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, 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) + 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) ?? [] + 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" + 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(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) + 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 } +} + +// 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/backups/ubuntu-cve-20261022-152152/SSHScanner.swift b/backups/ubuntu-cve-20261022-152152/SSHScanner.swift new file mode 100644 index 0000000..0c172aa --- /dev/null +++ b/backups/ubuntu-cve-20261022-152152/SSHScanner.swift @@ -0,0 +1,496 @@ +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*' 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 + 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*' 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) + 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 { + 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/backups/ubuntu-cve-20261022-152152/VulnerabilityService.swift b/backups/ubuntu-cve-20261022-152152/VulnerabilityService.swift new file mode 100644 index 0000000..42fb211 --- /dev/null +++ b/backups/ubuntu-cve-20261022-152152/VulnerabilityService.swift @@ -0,0 +1,180 @@ +import Foundation + +enum VulnerabilityService { + + enum ServiceError: LocalizedError { + case invalidResponse + case httpError(Int) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid response from the vulnerability database." + case .httpError(let code): + return "Vulnerability database returned HTTP \(code)." + } + } + } + + private struct NVDResponse: Decodable { + let vulnerabilities: [Entry] + } + + private struct Entry: Decodable { + let cve: CVE + } + + private struct CVE: Decodable { + let id: String + let published: String + let descriptions: [Description] + let metrics: Metrics? + } + + private struct Description: Decodable { + let lang: String + let value: String + } + + private struct Metrics: Decodable { + let cvssMetricV31: [Metric]? + let cvssMetricV30: [Metric]? + let cvssMetricV2: [Metric]? + } + + private struct Metric: Decodable { + let cvssData: CVSSData + } + + private struct CVSSData: Decodable { + let baseScore: Double? + let baseSeverity: String? + } + + static func discover() async throws -> [CVERecord] { + + var components = URLComponents( + string: "https://services.nvd.nist.gov/rest/json/cves/2.0" + )! + + components.queryItems = [ + URLQueryItem(name: "keywordSearch", value: "PHP"), + URLQueryItem(name: "resultsPerPage", value: "100") + ] + + guard let url = components.url else { + throw ServiceError.invalidResponse + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.setValue( + "application/json", + forHTTPHeaderField: "Accept" + ) + request.setValue( + "PHPGuard/1.4", + forHTTPHeaderField: "User-Agent" + ) + + let (data, response) = try await URLSession.shared.data( + for: request + ) + + guard let http = response as? HTTPURLResponse else { + throw ServiceError.invalidResponse + } + + guard (200...299).contains(http.statusCode) else { + throw ServiceError.httpError(http.statusCode) + } + + let decoded = try JSONDecoder().decode( + NVDResponse.self, + from: data + ) + + return decoded.vulnerabilities.map { entry in + + let cve = entry.cve + + let description = cve.descriptions.first { + $0.lang == "en" + }?.value ?? "No description available." + + let metric = + cve.metrics?.cvssMetricV31?.first ?? + cve.metrics?.cvssMetricV30?.first ?? + cve.metrics?.cvssMetricV2?.first + + return CVERecord( + id: cve.id, + description: description, + published: cve.published, + severity: metric?.cvssData.baseSeverity ?? "UNKNOWN", + score: metric?.cvssData.baseScore + ) + } + .sorted { $0.published > $1.published } + } +} + + +extension VulnerabilityService { + // Returns upstream PHP CVE candidates, not confirmed Ubuntu/Debian exposures. + static func assessPHPVersion(_ version: String, runtime: String) async throws -> [CVEServerAssessment] { + let parts = version.split(separator: ".") + guard parts.count >= 3, + parts.prefix(3).allSatisfy({ Int($0) != nil }) else { return [] } + let canonical = parts.prefix(3).joined(separator: ".") + var components = URLComponents(string: "https://services.nvd.nist.gov/rest/json/cves/2.0")! + components.queryItems = [ + URLQueryItem(name: "cpeName", value: "cpe:2.3:a:php:php:\(canonical):*:*:*:*:*:*:*"), + URLQueryItem(name: "resultsPerPage", value: "2000") + ] + guard let url = components.url else { throw ServiceError.invalidResponse } + var request = URLRequest(url: url) + request.timeoutInterval = 45 + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("PHPGuard/1.5", forHTTPHeaderField: "User-Agent") + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse } + guard (200...299).contains(http.statusCode) else { throw ServiceError.httpError(http.statusCode) } + let payload = try JSONDecoder().decode(VersionResponse.self, from: data) + // An incomplete NVD response must not be presented as full coverage. + guard payload.totalResults <= payload.vulnerabilities.count else { + throw ServiceError.invalidResponse + } + return payload.vulnerabilities.map { entry in + let cve = entry.cve + let metric = cve.metrics?.cvssMetricV31?.first ?? cve.metrics?.cvssMetricV30?.first ?? cve.metrics?.cvssMetricV2?.first + return CVEServerAssessment( + cveID: cve.id, runtime: runtime, + severity: metric?.cvssData.baseSeverity ?? "UNKNOWN", + score: metric?.cvssData.baseScore, + summary: cve.descriptions.first(where: { $0.lang == "en" })?.value ?? "No English description", + status: "Potentially affected", + evidence: "NVD PHP product CPE matches upstream PHP \(canonical). Distribution package revisions, backported patches, and container image fixes have NOT been verified." + ) + } + } + + private struct VersionResponse: Decodable { + let totalResults: Int + let vulnerabilities: [VersionEntry] + } + private struct VersionEntry: Decodable { let cve: VersionCVE } + private struct VersionCVE: Decodable { + let id: String + let descriptions: [VersionDescription] + let metrics: VersionMetrics? + } + private struct VersionDescription: Decodable { let lang: String; let value: String } + private struct VersionMetrics: Decodable { + let cvssMetricV31: [VersionMetric]? + let cvssMetricV30: [VersionMetric]? + let cvssMetricV2: [VersionMetric]? + } + private struct VersionMetric: Decodable { let cvssData: VersionCVSS } + private struct VersionCVSS: Decodable { let baseScore: Double?; let baseSeverity: String? } +}