Updates to scanner with CVE saving and auto update

This commit is contained in:
Mark Gregory
2026-10-22 13:25:59 +01:00
parent be96da88f5
commit 3c35f49b52
3 changed files with 299 additions and 1474 deletions
@@ -1,330 +0,0 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var store: AuditStore
@State private var selection: UUID?
@State private var showingEditor = false
@State private var editing: Server?
@State private var showClearHistoryConfirmation = false
@AppStorage("phpguard.confirmClear") private var confirmClearHistory = true
private func addServer() { editing = nil; showingEditor = true }
var body: some View {
NavigationSplitView {
List(selection: $selection) {
Section("Servers") {
ForEach(store.servers) { server in
Label {
HStack {
Text(server.name)
Spacer()
if store.scanning.contains(server.id) { ProgressView().controlSize(.small) }
else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) }
}
} icon: { Image(systemName: "server.rack") }
.tag(server.id)
.contextMenu {
Button("Scan") { store.scan(server) }
Button("Edit") { editing = server; showingEditor = true }
Divider()
Button("Remove", role: .destructive) {
store.servers.removeAll { $0.id == server.id }
if selection == server.id { selection = nil }
}
}
}
}
Section("Navigation") {
Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!)
Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!)
}
}
.navigationTitle("PHPGuard")
.safeAreaInset(edge: .bottom) {
Button(action: addServer) {
Label("Add Server", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(12)
.background(.bar)
}
} detail: {
if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") {
cveView
} else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") {
historyView
} else if let server = store.servers.first(where: { $0.id == selection }) {
serverView(server)
} else { dashboard }
}
.sheet(isPresented: $showingEditor) {
ServerEditor(existing: editing) { server in
if let index = store.servers.firstIndex(where: { $0.id == server.id }) { store.servers[index] = server }
else { store.servers.append(server) }
selection = server.id
}
}
.alert("Scan error", isPresented: Binding(get: { store.lastError != nil }, set: { if !$0 { store.lastError = nil } })) {
Button("OK") { store.lastError = nil }
} message: { Text(store.lastError ?? "") }
}
private var dashboard: some View {
VStack(alignment: .leading, spacing: 24) {
Text("Security overview").font(.largeTitle.bold())
HStack(spacing: 16) {
stat("Servers", "\(store.servers.count)", "server.rack")
stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield")
stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle")
}
Text("Read-only PHP inventory, APT upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.")
.foregroundStyle(.secondary)
HStack {
Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") }
.buttonStyle(.borderedProminent)
Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty)
Button("Import JSON scan") { store.importReport() }
}
Spacer()
}.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func stat(_ title: String, _ value: String, _ icon: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
Label(title, systemImage: icon).foregroundStyle(.secondary)
Text(value).font(.system(size: 36, weight: .bold))
}.padding(20).frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14))
}
private func serverView(_ server: Server) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack {
VStack(alignment: .leading) {
Text(server.name).font(.largeTitle.bold())
Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary)
}
Spacer()
Button("Edit") { editing = server; showingEditor = true }
Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent)
.disabled(store.scanning.contains(server.id))
}
if let result = store.latest(for: server) {
if let error = result.error {
Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red)
} else {
HStack(spacing: 12) {
Label("Scan completed", systemImage: "checkmark.shield.fill")
.foregroundStyle(.green)
Text(result.scannedAt.formatted()).foregroundStyle(.secondary)
Spacer()
Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") }
.buttonStyle(.borderedProminent)
Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") }
}
.padding(16)
.background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14))
Text("OS: \(result.os) • Host: \(result.hostname)")
dataSection("PHP installations", result.phpVersions)
dataSection("PHP-FPM services", result.fpmServices)
dataSection("Nginx FastCGI routes", result.nginxUpstreams)
dataSection("Installed packages", result.packageVersions)
dataSection("Available PHP package upgrades", result.availableUpdates)
dataSection("APT package origins and candidate revisions", result.packageOrigins)
Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary)
dataSection("PHP detected in running Docker containers", result.dockerPHP)
Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary)
dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage)
dataSection("Security triage — follow-up actions", result.securityTriage)
HStack {
Text("Security findings").font(.title2.bold())
Spacer()
Text("\(result.findings.count) total").foregroundStyle(.secondary)
}
ForEach(result.findings) { finding in
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill")
Text(finding.severity.uppercased()).font(.caption.bold())
Text(finding.title).font(.headline)
}
.foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue))
Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled)
}.padding(12).frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10))
}
}
} else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) }
}.padding(28).frame(maxWidth: .infinity, alignment: .leading)
}
}
private func dataSection(_ title: String, _ values: [String]) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22)
Text(title).font(.headline)
Spacer()
Text("\(values.count)").font(.caption).foregroundStyle(.secondary)
}
if values.isEmpty { Text("None detected").foregroundStyle(.secondary) }
else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } }
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14))
}
private var cveView: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("PHP CVE research").font(.largeTitle.bold())
Spacer()
if store.fetchingCVEs { ProgressView() }
Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs)
}
Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.")
.foregroundStyle(.secondary)
if let date = store.cveLastChecked {
Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch")
.foregroundStyle(.secondary)
} else {
Text("No cached CVE research yet · Automatically fetched at launch")
.foregroundStyle(.secondary)
}
if let error = store.cveError { Text(error).foregroundStyle(.red) }
List(store.discoveredCVEs) { cve in
VStack(alignment: .leading, spacing: 6) {
HStack {
Link(cve.id, destination: URL(string: cve.referenceURL)!)
.font(.headline)
Spacer()
Text(cve.severity + (cve.score.map { " (\($0))" } ?? ""))
Text(String(cve.published.prefix(10))).foregroundStyle(.secondary)
}
Text(cve.description).lineLimit(4).textSelection(.enabled)
}.padding(.vertical, 5)
}
}.padding(24)
}
private var historyView: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Scan history").font(.largeTitle.bold())
Text("\(store.results.count) saved scans").foregroundStyle(.secondary)
}
Spacer()
Button("Clear History", role: .destructive) {
if confirmClearHistory { showClearHistoryConfirmation = true }
else { store.clearHistory() }
}.disabled(store.results.isEmpty)
}.padding(20)
List {
ForEach(store.results) { result in
HStack(spacing: 12) {
Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill")
.foregroundStyle(result.error == nil ? .green : .red)
VStack(alignment: .leading, spacing: 4) {
Text(result.serverName).font(.headline)
Text(result.scannedAt.formatted()).foregroundStyle(.secondary)
if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) }
else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations")
.font(.caption).foregroundStyle(.secondary) }
}
Spacer()
Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil)
Button("JSON") { store.export(result) }.disabled(result.error != nil)
}.padding(.vertical, 5)
}.onDelete(perform: store.removeHistory)
}
}
.confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) {
Button("Clear History", role: .destructive) { store.clearHistory() }
} message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") }
}
}
struct ServerEditor: View {
@Environment(\.dismiss) private var dismiss
let existing: Server?
let onSave: (Server) -> Void
@State private var server: Server
init(existing: Server?, onSave: @escaping (Server) -> Void) {
self.existing = existing
self.onSave = onSave
_server = State(initialValue: existing ?? Server(name: "", host: ""))
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack")
.font(.title.bold())
Text("Connect using an existing SSH key. No agent installation required.")
.foregroundStyle(.secondary)
Form {
Section("Connection") {
TextField("Server name (e.g. Pegasus)", text: $server.name)
TextField("Hostname or IP address", text: $server.host)
.textContentType(.URL)
TextField("SSH username", text: $server.user)
TextField("SSH port", value: $server.port, format: .number)
}
Section("Advanced (optional)") {
HStack {
TextField("SSH private key path", text: $server.identityFile)
Button("Choose…") {
let panel = NSOpenPanel()
panel.canChooseDirectories = false
panel.canChooseFiles = true
if panel.runModal() == .OK, let url = panel.url {
server.identityFile = url.path
}
}
}
Text("Leave blank to use your existing SSH configuration and keys.")
.font(.caption).foregroundStyle(.secondary)
}
}.formStyle(.grouped)
if !isValid {
Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle")
.foregroundStyle(.secondary).font(.caption)
}
Text("Host key verification is strict. Connect in Terminal first if this server is new.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
Spacer()
Button(existing == nil ? "Add Server" : "Save Changes") {
server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines)
server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines)
server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines)
onSave(server)
dismiss()
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(!isValid)
}
}.padding(24).frame(width: 580)
}
private var isValid: Bool {
let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines)
let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines)
return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !host.isEmpty && !host.hasPrefix("-")
&& host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil
&& user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil
&& (1...65535).contains(server.port)
}
}
struct SettingsView: View {
var body: some View {
Form {
LabeledContent("SSH", value: "System OpenSSH, key-only")
LabeledContent("Host verification", value: "Strict")
LabeledContent("Scan mode", value: "Read-only")
Text("Server inventory and reports are stored locally in Application Support/PHPGuard.")
.foregroundStyle(.secondary)
}.padding(24).frame(width: 500, height: 220)
}
}
+299 -1080
View File
File diff suppressed because it is too large Load Diff
@@ -1,64 +0,0 @@
import AppKit
import CoreGraphics
@MainActor enum ReportPDF {
static func write(_ report: AuditResult, to url: URL, accent: String) throws {
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data as CFMutableData) else { throw NSError(domain: "PHPGuardPDF", code: 1) }
var box = CGRect(x: 0, y: 0, width: 595, height: 842)
guard let ctx = CGContext(consumer: consumer, mediaBox: &box, nil) else { throw NSError(domain: "PHPGuardPDF", code: 2) }
let primary: NSColor = accent == "Teal" ? .systemTeal : accent == "Purple" ? .systemPurple : .systemBlue
let margin: CGFloat = 44
var y: CGFloat = 0
var page = 0
func startPage() {
ctx.beginPDFPage(nil); page += 1; y = 48
ctx.setFillColor(primary.cgColor)
ctx.fill(CGRect(x: 0, y: 0, width: 595, height: 13))
let heading = NSAttributedString(string: "PHPGUARD / SECURITY ASSESSMENT", attributes: [.font: NSFont.boldSystemFont(ofSize: 10), .foregroundColor: primary])
ctx.saveGState(); NSGraphicsContext.saveGraphicsState(); NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true); ctx.translateBy(x: 0, y: 842); ctx.scaleBy(x: 1, y: -1)
heading.draw(at: CGPoint(x: margin, y: 25))
NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState()
}
func endPage() {
let footer = NSAttributedString(string: "PHPGuard • Research findings require vendor advisory verification Page \(page)", attributes: [.font: NSFont.systemFont(ofSize: 8), .foregroundColor: NSColor.secondaryLabelColor])
ctx.saveGState(); NSGraphicsContext.saveGraphicsState(); NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true); ctx.translateBy(x: 0, y: 842); ctx.scaleBy(x: 1, y: -1)
footer.draw(at: CGPoint(x: margin, y: 808))
NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); ctx.endPDFPage()
}
func line(_ text: String, size: CGFloat = 10, bold: Bool = false, color: NSColor = .labelColor) {
let attrs: [NSAttributedString.Key: Any] = [.font: bold ? NSFont.boldSystemFont(ofSize: size) : NSFont.systemFont(ofSize: size), .foregroundColor: color]
let value = NSAttributedString(string: text, attributes: attrs)
let bound = value.boundingRect(with: CGSize(width: 507, height: 10000), options: [.usesLineFragmentOrigin, .usesFontLeading])
let height = max(size + 5, ceil(bound.height) + 5)
if y + height > 790 { endPage(); startPage() }
ctx.saveGState(); NSGraphicsContext.saveGraphicsState(); NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: true); ctx.translateBy(x: 0, y: 842); ctx.scaleBy(x: 1, y: -1)
value.draw(with: CGRect(x: margin, y: y, width: 507, height: height), options: [.usesLineFragmentOrigin, .usesFontLeading])
NSGraphicsContext.restoreGraphicsState(); ctx.restoreGState(); y += height
}
func section(_ title: String, _ rows: [String]) {
y += 14; line(title.uppercased(), size: 12, bold: true, color: primary)
if rows.isEmpty { line("No entries recorded", color: .secondaryLabelColor) }
else { for row in rows { line("• " + row) } }
}
startPage()
y += 18; line(report.serverName, size: 25, bold: true)
line("Host: \(report.hostname) OS: \(report.os)", color: .secondaryLabelColor)
line("Scan date: \(report.scannedAt.formatted(date: .abbreviated, time: .standard))", color: .secondaryLabelColor)
section("Executive summary", ["\(report.phpVersions.count) PHP runtimes • \(report.findings.count) findings • \(report.availableUpdates.count) package updates", "CVE status is unverified until matched with vendor advisories."])
section("PHP installations", report.phpVersions)
section("PHP-FPM services", report.fpmServices)
section("Available package updates", report.availableUpdates)
section("Security findings", report.findings.map { "[\($0.severity)] \($0.title): \($0.detail)" })
section("Security triage", report.securityTriage)
section("Nginx FastCGI", report.nginxUpstreams)
section("Installed packages", report.packageVersions)
section("APT package origins", report.packageOrigins)
section("Docker PHP", report.dockerPHP)
section("Docker coverage", report.dockerCoverage)
section("Recent PHP files", report.recentPHPFiles)
section("Web error signals", report.webErrorSignals)
section("Assessment notes", [report.packageAuditStatus, report.dockerAuditStatus, report.investigationStatus])
endPage(); ctx.closePDF(); try (data as Data).write(to: url, options: .atomic)
}
}