Updates to scanner with CVE saving and auto update
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user