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