Updates to scanner with CVE saving and auto update

This commit is contained in:
Mark Gregory
2026-10-22 13:07:17 +01:00
parent 0c4a1625b0
commit 7aad3222d3
8 changed files with 152 additions and 35 deletions
+4 -4
View File
@@ -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;
+25 -3
View File
@@ -6,8 +6,8 @@ import SwiftUI
@Published var results: [AuditResult] = [] { didSet { saveResults() } }
@Published var scanning: Set<UUID> = []
@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 }
}
+75 -16
View File
@@ -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)
}
}
+7 -1
View File
@@ -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)
+5 -1
View File
@@ -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) }
}
+24 -1
View File
@@ -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))
+4
View File
@@ -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.
+8 -9
View File
@@ -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.