From 7aad3222d3c8bbc25c6bdf83aec2dba35f942ed9 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 22 Oct 2026 13:07:17 +0100 Subject: [PATCH] Updates to scanner with CVE saving and auto update --- PHPGuard.xcodeproj/project.pbxproj | 8 +-- PHPGuard/AuditStore.swift | 28 ++++++++- PHPGuard/ContentView.swift | 91 ++++++++++++++++++++++++------ PHPGuard/Models.swift | 8 ++- PHPGuard/PHPGuardApp.swift | 6 +- PHPGuard/SSHScanner.swift | 25 +++++++- README.md | 4 ++ RELEASE_NOTES.md | 17 +++--- 8 files changed, 152 insertions(+), 35 deletions(-) diff --git a/PHPGuard.xcodeproj/project.pbxproj b/PHPGuard.xcodeproj/project.pbxproj index 43dd04f..ba0b93a 100644 --- a/PHPGuard.xcodeproj/project.pbxproj +++ b/PHPGuard.xcodeproj/project.pbxproj @@ -168,8 +168,8 @@ GENERATE_INFOPLIST_FILE = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; - MARKETING_VERSION = 1.5.0; - CURRENT_PROJECT_VERSION = 6; + MARKETING_VERSION = 1.8.0; + CURRENT_PROJECT_VERSION = 9; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PHPGuard; @@ -187,8 +187,8 @@ GENERATE_INFOPLIST_FILE = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; - MARKETING_VERSION = 1.5.0; - CURRENT_PROJECT_VERSION = 6; + MARKETING_VERSION = 1.8.0; + CURRENT_PROJECT_VERSION = 9; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; MACOSX_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PHPGuard; diff --git a/PHPGuard/AuditStore.swift b/PHPGuard/AuditStore.swift index 0102ba5..bb783f2 100644 --- a/PHPGuard/AuditStore.swift +++ b/PHPGuard/AuditStore.swift @@ -6,8 +6,8 @@ import SwiftUI @Published var results: [AuditResult] = [] { didSet { saveResults() } } @Published var scanning: Set = [] @Published var lastError: String? - @Published var discoveredCVEs: [CVERecord] = [] - @Published var cveLastChecked: Date? + @Published var discoveredCVEs: [CVERecord] = [] { didSet { saveCVECache() } } + @Published var cveLastChecked: Date? { didSet { saveCVECache() } } @Published var cveError: String? @Published var fetchingCVEs = false @@ -20,6 +20,10 @@ import SwiftUI init() { 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 @@ -59,6 +63,15 @@ import SwiftUI 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(250)), as: "results.json") } func latest(for server: Server) -> AuditResult? { results.first { $0.serverID == server.id } } @@ -77,6 +90,10 @@ import SwiftUI } } func scanAll() { servers.forEach { scan($0) } } + func refreshCVEsOnLaunch() { + // Preserve the local cache while checking NVD for a newer result set. + refreshCVEs() + } func refreshCVEs() { guard !fetchingCVEs else { return } fetchingCVEs = true @@ -84,7 +101,12 @@ import SwiftUI Task { defer { fetchingCVEs = false } do { - discoveredCVEs = try await VulnerabilityService.discover() + 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 } } diff --git a/PHPGuard/ContentView.swift b/PHPGuard/ContentView.swift index 9722ef0..174db4a 100644 --- a/PHPGuard/ContentView.swift +++ b/PHPGuard/ContentView.swift @@ -5,6 +5,7 @@ struct ContentView: View { @State private var selection: UUID? @State private var showingEditor = false @State private var editing: Server? + private func addServer() { editing = nil; showingEditor = true } var body: some View { NavigationSplitView { List(selection: $selection) { @@ -36,8 +37,15 @@ struct ContentView: View { } } .navigationTitle("PHPGuard") - .toolbar { - Button { editing = nil; showingEditor = true } label: { Image(systemName: "plus") }.help("Add server") + .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") { @@ -67,9 +75,11 @@ struct ContentView: View { stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield") stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle") } - Text("Read-only PHP inventory, APT upgrade checks and running Docker container discovery. CVE matching and vendor backport verification remain unimplemented; no server is certified secure.") + Text("Read-only PHP inventory, APT upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.") .foregroundStyle(.secondary) HStack { + Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") } + .buttonStyle(.borderedProminent) Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) Button("Import JSON scan") { store.importReport() } } @@ -111,6 +121,8 @@ struct ContentView: View { 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) Text("Findings").font(.title2.bold()) ForEach(result.findings) { finding in VStack(alignment: .leading, spacing: 4) { @@ -142,7 +154,13 @@ struct ContentView: View { } 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 fetched: \(date.formatted())").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) { @@ -184,24 +202,65 @@ struct ServerEditor: View { _server = State(initialValue: existing ?? Server(name: "", host: "")) } var body: some View { - VStack(alignment: .leading, spacing: 18) { - Text(existing == nil ? "Add server" : "Edit server").font(.title.bold()) + 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 { - TextField("Display name", text: $server.name) - TextField("Hostname / IP", text: $server.host) - TextField("SSH username", text: $server.user) - TextField("SSH port", value: $server.port, format: .number) - TextField("SSH private key path (optional)", text: $server.identityFile) + 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) - Text("SSH uses your existing keys and known_hosts. Password prompts are disabled; first connect using Terminal to trust the host key.") + 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("Cancel") { dismiss() } - Button("Save") { onSave(server); dismiss() }.buttonStyle(.borderedProminent) - .disabled(server.name.isEmpty || server.host.isEmpty || server.user.isEmpty) + 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: 520) + }.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) } } diff --git a/PHPGuard/Models.swift b/PHPGuard/Models.swift index 8f866ab..0f8b700 100644 --- a/PHPGuard/Models.swift +++ b/PHPGuard/Models.swift @@ -30,6 +30,8 @@ struct AuditResult: Codable, Identifiable { 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] = [] @@ -39,7 +41,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, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, rawOutput, error, id } + enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, availableUpdates, packageOrigins, dockerPHP, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, 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) @@ -55,6 +57,8 @@ struct AuditResult: Codable, Identifiable { 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) ?? [] @@ -79,6 +83,8 @@ struct AuditResult: Codable, Identifiable { 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) diff --git a/PHPGuard/PHPGuardApp.swift b/PHPGuard/PHPGuardApp.swift index f43117d..d869593 100644 --- a/PHPGuard/PHPGuardApp.swift +++ b/PHPGuard/PHPGuardApp.swift @@ -4,7 +4,11 @@ import SwiftUI struct PHPGuardApp: App { @StateObject private var store = AuditStore() var body: some Scene { - WindowGroup("PHPGuard") { ContentView().environmentObject(store) } + WindowGroup("PHPGuard") { + ContentView() + .environmentObject(store) + .task { store.refreshCVEsOnLaunch() } + } .defaultSize(width: 1120, height: 720) Settings { SettingsView().environmentObject(store) } } diff --git a/PHPGuard/SSHScanner.swift b/PHPGuard/SSHScanner.swift index 9472d8d..ef0d73e 100644 --- a/PHPGuard/SSHScanner.swift +++ b/PHPGuard/SSHScanner.swift @@ -137,6 +137,10 @@ enum SSHScanner { result.investigationStatus = (fields["WEB_ERROR_SIGNALS"] ?? []).contains(where: { $0.hasPrefix("Nginx error log unavailable") }) ? "Partial: Nginx error log inaccessible or absent" : "Read-only indicators collected (not proof of compromise)" 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 @@ -156,6 +160,17 @@ enum SSHScanner { 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" + result.securityTriage = [ + "Package revisions and available updates are inventory evidence, not verified CVE remediation.", + "Compare installed packages against applicable Ubuntu Security Notices or third-party maintainer advisories.", + "Check PHP containers independently: host package updates do not update container images." + ] + if !result.dockerPHP.isEmpty { + result.securityTriage.append("Review each detected container PHP version against its image maintainer's fixed releases; prioritize older container runtimes.") + } + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + result.securityTriage.append("Docker scan incomplete: grant appropriate read access or perform an independent container inventory.") + } 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.")) } @@ -186,7 +201,15 @@ enum SSHScanner { 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 + ". Verify the container image and upstream PHP security release independently.")) + if line.contains("PHP 8.5.10") { + result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure.")) + } + } + for line in result.dockerPHP { + result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP.")) + } + if !result.dockerPHP.isEmpty { + result.findings.append(Finding(severity: "Info", title: "Container update workflow", detail: "PHP container images must be updated and redeployed separately. Review image digests, upstream release notes, and image-specific security fixes before changing production services.")) } if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { result.findings.append(Finding(severity: "Warning", title: "Docker coverage unavailable", detail: result.dockerAuditStatus)) diff --git a/README.md b/README.md index 91ab851..f2948fd 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,7 @@ The app icon is compiled from `PHPGuard/Assets.xcassets/AppIcon.appiconset`. The ## Phase 1.5 Import JSON scans using the dashboard button. Re-scan servers to collect structured Docker evidence. A missing PHP binary inside a container is not a vulnerability. Package version comparisons do not verify vendor CVE fixes. + + +## 1.7 security triage +New scans include securityTriage and dockerCoverage fields in exported JSON. These are advisory inventory notes, not CVE confirmation. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0890394..6ae08b8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,11 +1,10 @@ -# PHPGuard 1.5.0 — Scan accuracy and report import +# PHPGuard 1.8.0 — Phase 9 -- Docker detection now distinguishes a PHP runtime from a container without a PHP CLI. Missing PHP is no longer reported as a PHP finding. -- Docker daemon access failures are reported as incomplete coverage. -- Container image identifiers and PHP versions are collected for identified PHP runtimes. -- Older scan history is normalized to suppress erroneous Docker findings while preserving the raw scan output. -- Import existing JSON scan reports, including Pegasus scans, with duplicate detection. -- Package installed/candidate revision differences are surfaced for review; no CVE exposure is inferred without vendor evidence. -- Read-only SSH collection; no remote changes or updates. +- Removed the redundant floating Add Server toolbar button; Add Server remains in the sidebar and dashboard. +- NVD research records are cached in Application Support/PHPGuard/cve-cache.json. +- Cached CVEs load immediately when the application opens. +- NVD research refreshes automatically on application launch and can be refreshed manually. +- Network/API failures preserve cached CVE records and show an error. +- Existing app icon, installer artwork, server scans, report history and release scripts retained. -Known limitation: CVE-to-Ubuntu-USN package revision verification is not yet implemented. NVD keyword results remain research only. macOS build and notarization must be run on a Mac. +CVE keyword results are research leads, not confirmed vulnerabilities for any scanned host.