Updates to scanner with CVE saving and auto update

This commit is contained in:
Mark Gregory
2026-10-22 15:26:37 +01:00
parent 2c90bcb365
commit 2478e4f7b3
16 changed files with 5892 additions and 63 deletions
+12 -1
View File
@@ -166,9 +166,20 @@ import SwiftUI
)
}
scanStage = "Checking Ubuntu advisories"
scanDetail = "Comparing CVEs with Ubuntu package advisories"
scanFraction = 0.95
if report.ubuntuReleaseID == "ubuntu" {
all = await VulnerabilityService.assessUbuntu(
all,
report: report
)
}
report.cveAssessments = all.sorted { $0.cveID > $1.cveID }
report.cveAssessmentStatus = failures.isEmpty
? "Upstream CPE assessment complete; distribution fixes unverified"
? "NVD assessment complete; Ubuntu advisories checked where available"
: "Partial/unknown: " + failures.joined(separator: "; ")
report.cveAssessedAt = Date()
}
+86 -60
View File
@@ -164,7 +164,7 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 10) {
Text("CVE assessment").font(.title2.bold())
Text(result.cveAssessmentStatus).foregroundStyle(.secondary)
Text("NVD upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.")
Text("NVD identifies upstream candidates. Ubuntu advisory status is checked separately. A published fix does not prove that the installed package is patched.")
.font(.caption).foregroundStyle(.secondary)
if result.cveAssessments.isEmpty {
Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.")
@@ -180,7 +180,41 @@ struct ContentView: View {
Text(item.status).foregroundStyle(.orange)
}
Text(item.runtime + " — " + item.summary).lineLimit(3)
Text(item.evidence).font(.caption).foregroundStyle(.secondary)
Text(item.evidence)
.font(.caption)
.foregroundStyle(.secondary)
if let vendorStatus = item.vendorStatus {
HStack(spacing: 8) {
Image(
systemName:
vendorStatus == "Not affected"
? "checkmark.shield.fill"
: "shield.lefthalf.filled"
)
Text("Ubuntu: " + vendorStatus)
.font(.subheadline.bold())
}
.foregroundStyle(
vendorStatus == "Not affected"
? Color.green
: vendorStatus == "Ubuntu fix needed"
? Color.red
: Color.orange
)
.padding(.top, 5)
}
if let vendorEvidence = item.vendorEvidence {
Text(vendorEvidence)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}.padding(10)
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10))
}
@@ -479,56 +513,65 @@ struct ContentView: View {
private func packageInventoryCard(_ values: [String]) -> some View {
DisclosureGroup(isExpanded: $showInstalledPackages) {
inventoryCard(
title: "Installed Packages",
subtitle: "PHP package inventory",
icon: "shippingbox.fill",
tint: .orange,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP packages detected")
} else {
VStack(spacing: 0) {
ForEach(Array(values.enumerated()), id: \.offset) {
index, line in
title: "Installed Packages",
subtitle: "PHP package inventory",
icon: "shippingbox.fill",
tint: .orange,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP packages detected")
} else {
VStack(spacing: 0) {
ForEach(Array(values.enumerated()), id: \.offset) { index, line in
let parts = line.split(
separator: " ",
maxSplits: 1,
omittingEmptySubsequences: true
)
let parts = line.split(
separator: " ",
maxSplits: 1,
omittingEmptySubsequences: true
)
let name = parts.first.map(String.init) ?? line
let version = parts.count > 1
? String(parts[1])
: "Version not reported"
let name = parts.first.map(String.init) ?? line
let version = parts.count > 1
? String(parts[1])
: "Version not reported"
VStack(alignment: .leading, spacing: 5) {
Text(name)
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 5) {
Text(name)
.font(.subheadline.weight(.semibold))
Text(version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 9)
Text(version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(
horizontal: false,
vertical: true
)
}
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(.vertical, 9)
if index < values.count - 1 {
Divider()
if index < values.count - 1 {
Divider()
}
}
}
}
}
} label: {
HStack {
Label("Installed Packages", systemImage: "shippingbox.fill")
.font(.headline)
Spacer()
Text("\(values.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
}
private func inventoryEmptyState(_ message: String) -> some View {
@@ -628,23 +671,6 @@ struct ContentView: View {
}
} label: {
HStack {
Label("Installed Packages", systemImage: "shippingbox.fill")
.font(.headline)
Spacer()
Text("\(values.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
}
struct ServerEditor: View {
@@ -0,0 +1,877 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var store: AuditStore
@State private var showInstalledPackages = false
@State private var showAPTOrigins = false
@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: $store.scanProgressVisible) {
ScanProgressView(store: store)
.interactiveDismissDisabled(!store.scanning.isEmpty)
}
.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 checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.")
.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)")
inventoryCards(
php: result.phpVersions,
fpm: result.fpmServices,
nginx: result.nginxUpstreams,
packages: result.packageVersions
)
dataSection("Available PHP package upgrades", result.availableUpdates)
DisclosureGroup(
isExpanded: $showAPTOrigins
) {
dataSection(
"APT package origins and candidate revisions",
result.packageOrigins
)
} label: {
Label(
"APT package origins and candidate revisions",
systemImage: "shippingbox"
)
.font(.headline)
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
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)
VStack(alignment: .leading, spacing: 10) {
Text("CVE assessment").font(.title2.bold())
Text(result.cveAssessmentStatus).foregroundStyle(.secondary)
Text("NVD upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.")
.font(.caption).foregroundStyle(.secondary)
if result.cveAssessments.isEmpty {
Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.")
.foregroundStyle(.secondary)
}
ForEach(result.cveAssessments) { item in
VStack(alignment: .leading, spacing: 5) {
HStack {
Link(item.cveID, destination: URL(string: item.referenceURL)!)
.font(.headline)
Text(item.severity).font(.caption.bold())
Spacer()
Text(item.status).foregroundStyle(.orange)
}
Text(item.runtime + " — " + item.summary).lineLimit(3)
Text(item.evidence).font(.caption).foregroundStyle(.secondary)
}.padding(10)
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10))
}
}
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)
}
}
// MARK: - PHP Inventory Dashboard
private func inventoryCards(
php: [String],
fpm: [String],
nginx: [String],
packages: [String]
) -> some View {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 16, alignment: .top),
GridItem(.flexible(), spacing: 16, alignment: .top)
],
alignment: .leading,
spacing: 16
) {
phpInventoryCard(php)
fpmInventoryCard(fpm)
nginxInventoryCard(nginx)
packageInventoryCard(packages)
}
.frame(maxWidth: .infinity)
}
private func inventoryCard<Content: View>(
title: String,
subtitle: String,
icon: String,
tint: Color,
count: Int,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .center, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 40, height: 40)
.background(
tint.opacity(0.12),
in: RoundedRectangle(cornerRadius: 11)
)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text("\(count)")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Color.secondary.opacity(0.10),
in: Capsule()
)
}
Rectangle()
.fill(Color.primary.opacity(0.07))
.frame(height: 1)
content()
Spacer(minLength: 0)
}
.padding(20)
.frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading)
.background(
Color(nsColor: .controlBackgroundColor),
in: RoundedRectangle(cornerRadius: 18)
)
.overlay {
RoundedRectangle(cornerRadius: 18)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
}
}
private func inventoryBadge(
_ text: String,
color: Color
) -> some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 7, height: 7)
Text(text)
.font(.caption.weight(.semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.10), in: Capsule())
}
private func phpInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "PHP Installations",
subtitle: "Detected PHP runtimes",
icon: "chevron.left.forwardslash.chevron.right",
tint: .blue,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP runtime detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let parts = line.split(
separator: " ",
omittingEmptySubsequences: true
)
let version = parts.last.map(String.init) ?? "Unknown"
let executable = parts.first.map(String.init) ?? line
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Text("PHP \(version)")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.75)
Spacer()
inventoryBadge("Detected", color: .green)
}
Label(executable, systemImage: "terminal")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
}
}
private func fpmInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "PHP-FPM Services",
subtitle: "FastCGI process managers",
icon: "gearshape.2.fill",
tint: .purple,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP-FPM services detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let parts = line.split(
whereSeparator: { $0.isWhitespace }
)
let service = parts.first.map(String.init) ?? line
let running = line.localizedCaseInsensitiveContains(
"active running"
) || (
line.contains("active") &&
line.contains("running")
)
VStack(alignment: .leading, spacing: 9) {
HStack(alignment: .top, spacing: 10) {
Text(service)
.font(.system(.body, design: .monospaced))
.fontWeight(.semibold)
.lineLimit(2)
Spacer(minLength: 6)
inventoryBadge(
running ? "Running" : "Check status",
color: running ? .green : .orange
)
}
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
.textSelection(.enabled)
}
}
}
}
}
}
private func nginxInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "Nginx FastCGI",
subtitle: "PHP routing configuration",
icon: "network",
tint: .cyan,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No FastCGI routes detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let route = line
.replacingOccurrences(
of: "fastcgi_pass",
with: ""
)
.trimmingCharacters(
in: .whitespacesAndNewlines
)
.trimmingCharacters(
in: CharacterSet(charactersIn: ";")
)
let isSocket = route.hasPrefix("unix:")
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(
isSocket
? "Unix socket"
: "FastCGI endpoint"
)
.font(.subheadline.weight(.semibold))
Spacer()
inventoryBadge(
"Configured",
color: .blue
)
}
Text(
isSocket
? String(route.dropFirst(5))
: route
)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(
horizontal: false,
vertical: true
)
}
}
}
}
}
}
private func packageInventoryCard(_ values: [String]) -> some View {
DisclosureGroup(isExpanded: $showInstalledPackages) {
inventoryCard(
title: "Installed Packages",
subtitle: "PHP package inventory",
icon: "shippingbox.fill",
tint: .orange,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP packages detected")
} else {
VStack(spacing: 0) {
ForEach(Array(values.enumerated()), id: \.offset) {
index, line in
let parts = line.split(
separator: " ",
maxSplits: 1,
omittingEmptySubsequences: true
)
let name = parts.first.map(String.init) ?? line
let version = parts.count > 1
? String(parts[1])
: "Version not reported"
VStack(alignment: .leading, spacing: 5) {
Text(name)
.font(.subheadline.weight(.semibold))
Text(version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(
horizontal: false,
vertical: true
)
}
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(.vertical, 9)
if index < values.count - 1 {
Divider()
}
}
}
}
}
}
private func inventoryEmptyState(_ message: String) -> some View {
HStack(spacing: 9) {
Image(systemName: "minus.circle")
.foregroundStyle(.tertiary)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 12)
}
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.") }
}
} label: {
HStack {
Label("Installed Packages", systemImage: "shippingbox.fill")
.font(.headline)
Spacer()
Text("\(values.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
}
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)
}
}
// MARK: - Security Assessment Progress
struct ScanProgressView: View {
@ObservedObject var store: AuditStore
@State private var pulse = false
private var elapsed: String {
let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt)))
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
}
var body: some View {
VStack(spacing: 0) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.09))
.frame(width: 108, height: 108)
.scaleEffect(pulse ? 1.12 : 0.94)
Circle()
.stroke(Color.accentColor.opacity(0.18), lineWidth: 2)
.frame(width: 94, height: 94)
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 42, weight: .medium))
.foregroundStyle(Color.accentColor)
.symbolRenderingMode(.hierarchical)
}
.padding(.top, 34)
Text("Security Assessment")
.font(.system(size: 25, weight: .bold))
.padding(.top, 22)
Text("PHPGuard is inspecting your server")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 15) {
HStack(spacing: 12) {
Image(systemName: "server.rack")
.font(.title3)
.foregroundStyle(Color.accentColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(store.scanServerName)
.font(.headline)
Text(store.scanServerHost)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "checkmark.shield")
.foregroundStyle(.secondary)
}
Divider()
HStack {
VStack(alignment: .leading, spacing: 5) {
Text(store.scanStage)
.font(.subheadline.weight(.semibold))
Text(store.scanDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
ProgressView()
.controlSize(.regular)
}
ProgressView(value: store.scanFraction, total: 1.0)
.progressViewStyle(.linear)
.tint(Color.accentColor)
HStack {
Text("\(Int(store.scanFraction * 100))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
if store.scanTotalChecks > 0 {
Text(
"CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)"
)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
HStack {
Label(
"\(store.scanning.count) active",
systemImage: "network"
)
Spacer()
TimelineView(.periodic(from: .now, by: 1)) { context in
let seconds = max(
0,
Int(context.date.timeIntervalSince(store.scanStartedAt))
)
Text(
String(
format: "%02d:%02d",
seconds / 60,
seconds % 60
)
)
.monospacedDigit()
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(22)
.background(
Color.primary.opacity(0.035),
in: RoundedRectangle(cornerRadius: 16)
)
.padding(.horizontal, 30)
.padding(.top, 26)
Text("The assessment runs remotely over SSH. No server changes are made.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 35)
.padding(.top, 22)
.padding(.bottom, 30)
}
.frame(width: 540)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
pulse = true
}
.animation(
.easeInOut(duration: 1.7).repeatForever(autoreverses: true),
value: pulse
)
}
}
@@ -0,0 +1,879 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var store: AuditStore
@State private var showInstalledPackages = false
@State private var showAPTOrigins = false
@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: $store.scanProgressVisible) {
ScanProgressView(store: store)
.interactiveDismissDisabled(!store.scanning.isEmpty)
}
.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 checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.")
.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)")
inventoryCards(
php: result.phpVersions,
fpm: result.fpmServices,
nginx: result.nginxUpstreams,
packages: result.packageVersions
)
dataSection("Available PHP package upgrades", result.availableUpdates)
DisclosureGroup(
isExpanded: $showAPTOrigins
) {
dataSection(
"APT package origins and candidate revisions",
result.packageOrigins
)
} label: {
Label(
"APT package origins and candidate revisions",
systemImage: "shippingbox"
)
.font(.headline)
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
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)
VStack(alignment: .leading, spacing: 10) {
Text("CVE assessment").font(.title2.bold())
Text(result.cveAssessmentStatus).foregroundStyle(.secondary)
Text("NVD upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.")
.font(.caption).foregroundStyle(.secondary)
if result.cveAssessments.isEmpty {
Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.")
.foregroundStyle(.secondary)
}
ForEach(result.cveAssessments) { item in
VStack(alignment: .leading, spacing: 5) {
HStack {
Link(item.cveID, destination: URL(string: item.referenceURL)!)
.font(.headline)
Text(item.severity).font(.caption.bold())
Spacer()
Text(item.status).foregroundStyle(.orange)
}
Text(item.runtime + " — " + item.summary).lineLimit(3)
Text(item.evidence).font(.caption).foregroundStyle(.secondary)
}.padding(10)
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10))
}
}
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)
}
}
// MARK: - PHP Inventory Dashboard
private func inventoryCards(
php: [String],
fpm: [String],
nginx: [String],
packages: [String]
) -> some View {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 16, alignment: .top),
GridItem(.flexible(), spacing: 16, alignment: .top)
],
alignment: .leading,
spacing: 16
) {
phpInventoryCard(php)
fpmInventoryCard(fpm)
nginxInventoryCard(nginx)
packageInventoryCard(packages)
}
.frame(maxWidth: .infinity)
}
private func inventoryCard<Content: View>(
title: String,
subtitle: String,
icon: String,
tint: Color,
count: Int,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .center, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 40, height: 40)
.background(
tint.opacity(0.12),
in: RoundedRectangle(cornerRadius: 11)
)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text("\(count)")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Color.secondary.opacity(0.10),
in: Capsule()
)
}
Rectangle()
.fill(Color.primary.opacity(0.07))
.frame(height: 1)
content()
Spacer(minLength: 0)
}
.padding(20)
.frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading)
.background(
Color(nsColor: .controlBackgroundColor),
in: RoundedRectangle(cornerRadius: 18)
)
.overlay {
RoundedRectangle(cornerRadius: 18)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
}
}
private func inventoryBadge(
_ text: String,
color: Color
) -> some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 7, height: 7)
Text(text)
.font(.caption.weight(.semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.10), in: Capsule())
}
private func phpInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "PHP Installations",
subtitle: "Detected PHP runtimes",
icon: "chevron.left.forwardslash.chevron.right",
tint: .blue,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP runtime detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let parts = line.split(
separator: " ",
omittingEmptySubsequences: true
)
let version = parts.last.map(String.init) ?? "Unknown"
let executable = parts.first.map(String.init) ?? line
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Text("PHP \(version)")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.75)
Spacer()
inventoryBadge("Detected", color: .green)
}
Label(executable, systemImage: "terminal")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
}
}
private func fpmInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "PHP-FPM Services",
subtitle: "FastCGI process managers",
icon: "gearshape.2.fill",
tint: .purple,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP-FPM services detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let parts = line.split(
whereSeparator: { $0.isWhitespace }
)
let service = parts.first.map(String.init) ?? line
let running = line.localizedCaseInsensitiveContains(
"active running"
) || (
line.contains("active") &&
line.contains("running")
)
VStack(alignment: .leading, spacing: 9) {
HStack(alignment: .top, spacing: 10) {
Text(service)
.font(.system(.body, design: .monospaced))
.fontWeight(.semibold)
.lineLimit(2)
Spacer(minLength: 6)
inventoryBadge(
running ? "Running" : "Check status",
color: running ? .green : .orange
)
}
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
.textSelection(.enabled)
}
}
}
}
}
}
private func nginxInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "Nginx FastCGI",
subtitle: "PHP routing configuration",
icon: "network",
tint: .cyan,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No FastCGI routes detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let route = line
.replacingOccurrences(
of: "fastcgi_pass",
with: ""
)
.trimmingCharacters(
in: .whitespacesAndNewlines
)
.trimmingCharacters(
in: CharacterSet(charactersIn: ";")
)
let isSocket = route.hasPrefix("unix:")
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(
isSocket
? "Unix socket"
: "FastCGI endpoint"
)
.font(.subheadline.weight(.semibold))
Spacer()
inventoryBadge(
"Configured",
color: .blue
)
}
Text(
isSocket
? String(route.dropFirst(5))
: route
)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(
horizontal: false,
vertical: true
)
}
}
}
}
}
}
private func packageInventoryCard(_ values: [String]) -> some View {
DisclosureGroup(isExpanded: $showInstalledPackages) {
inventoryCard(
title: "Installed Packages",
subtitle: "PHP package inventory",
icon: "shippingbox.fill",
tint: .orange,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP packages detected")
} else {
VStack(spacing: 0) {
ForEach(Array(values.enumerated()), id: \.offset) {
index, line in
let parts = line.split(
separator: " ",
maxSplits: 1,
omittingEmptySubsequences: true
)
let name = parts.first.map(String.init) ?? line
let version = parts.count > 1
? String(parts[1])
: "Version not reported"
VStack(alignment: .leading, spacing: 5) {
Text(name)
.font(.subheadline.weight(.semibold))
Text(version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(
horizontal: false,
vertical: true
)
}
.frame(
maxWidth: .infinity,
alignment: .leading
)
.padding(.vertical, 9)
if index < values.count - 1 {
Divider()
}
}
}
}
}
}
}
private func inventoryEmptyState(_ message: String) -> some View {
HStack(spacing: 9) {
Image(systemName: "minus.circle")
.foregroundStyle(.tertiary)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 12)
}
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.") }
}
} label: {
HStack {
Label("Installed Packages", systemImage: "shippingbox.fill")
.font(.headline)
Spacer()
Text("\(values.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
}
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)
}
}
// MARK: - Security Assessment Progress
struct ScanProgressView: View {
@ObservedObject var store: AuditStore
@State private var pulse = false
private var elapsed: String {
let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt)))
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
}
var body: some View {
VStack(spacing: 0) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.09))
.frame(width: 108, height: 108)
.scaleEffect(pulse ? 1.12 : 0.94)
Circle()
.stroke(Color.accentColor.opacity(0.18), lineWidth: 2)
.frame(width: 94, height: 94)
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 42, weight: .medium))
.foregroundStyle(Color.accentColor)
.symbolRenderingMode(.hierarchical)
}
.padding(.top, 34)
Text("Security Assessment")
.font(.system(size: 25, weight: .bold))
.padding(.top, 22)
Text("PHPGuard is inspecting your server")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 15) {
HStack(spacing: 12) {
Image(systemName: "server.rack")
.font(.title3)
.foregroundStyle(Color.accentColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(store.scanServerName)
.font(.headline)
Text(store.scanServerHost)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "checkmark.shield")
.foregroundStyle(.secondary)
}
Divider()
HStack {
VStack(alignment: .leading, spacing: 5) {
Text(store.scanStage)
.font(.subheadline.weight(.semibold))
Text(store.scanDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
ProgressView()
.controlSize(.regular)
}
ProgressView(value: store.scanFraction, total: 1.0)
.progressViewStyle(.linear)
.tint(Color.accentColor)
HStack {
Text("\(Int(store.scanFraction * 100))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
if store.scanTotalChecks > 0 {
Text(
"CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)"
)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
HStack {
Label(
"\(store.scanning.count) active",
systemImage: "network"
)
Spacer()
TimelineView(.periodic(from: .now, by: 1)) { context in
let seconds = max(
0,
Int(context.date.timeIntervalSince(store.scanStartedAt))
)
Text(
String(
format: "%02d:%02d",
seconds / 60,
seconds % 60
)
)
.monospacedDigit()
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(22)
.background(
Color.primary.opacity(0.035),
in: RoundedRectangle(cornerRadius: 16)
)
.padding(.horizontal, 30)
.padding(.top, 26)
Text("The assessment runs remotely over SSH. No server changes are made.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 35)
.padding(.top, 22)
.padding(.bottom, 30)
}
.frame(width: 540)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
pulse = true
}
.animation(
.easeInOut(duration: 1.7).repeatForever(autoreverses: true),
value: pulse
)
}
}
+14 -1
View File
@@ -27,6 +27,9 @@ struct AuditResult: Codable, Identifiable {
var fpmServices: [String] = []
var nginxUpstreams: [String] = []
var packageVersions: [String] = []
var packageSources: [String] = []
var ubuntuCodename = ""
var ubuntuReleaseID = ""
var availableUpdates: [String] = []
var packageOrigins: [String] = []
var dockerPHP: [String] = []
@@ -44,7 +47,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, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, cveAssessments, cveAssessmentStatus, cveAssessedAt, rawOutput, error, id }
enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, packageSources, ubuntuCodename, ubuntuReleaseID, availableUpdates, packageOrigins, dockerPHP, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, cveAssessments, cveAssessmentStatus, cveAssessedAt, 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)
@@ -57,6 +60,9 @@ struct AuditResult: Codable, Identifiable {
fpmServices = try container.decodeIfPresent([String].self, forKey: .fpmServices) ?? []
nginxUpstreams = try container.decodeIfPresent([String].self, forKey: .nginxUpstreams) ?? []
packageVersions = try container.decodeIfPresent([String].self, forKey: .packageVersions) ?? []
packageSources = try container.decodeIfPresent([String].self, forKey: .packageSources) ?? []
ubuntuCodename = try container.decodeIfPresent(String.self, forKey: .ubuntuCodename) ?? ""
ubuntuReleaseID = try container.decodeIfPresent(String.self, forKey: .ubuntuReleaseID) ?? ""
availableUpdates = try container.decodeIfPresent([String].self, forKey: .availableUpdates) ?? []
packageOrigins = try container.decodeIfPresent([String].self, forKey: .packageOrigins) ?? []
dockerPHP = try container.decodeIfPresent([String].self, forKey: .dockerPHP) ?? []
@@ -86,6 +92,9 @@ struct AuditResult: Codable, Identifiable {
try container.encode(fpmServices, forKey: .fpmServices)
try container.encode(nginxUpstreams, forKey: .nginxUpstreams)
try container.encode(packageVersions, forKey: .packageVersions)
try container.encode(packageSources, forKey: .packageSources)
try container.encode(ubuntuCodename, forKey: .ubuntuCodename)
try container.encode(ubuntuReleaseID, forKey: .ubuntuReleaseID)
try container.encode(availableUpdates, forKey: .availableUpdates)
try container.encode(packageOrigins, forKey: .packageOrigins)
try container.encode(dockerPHP, forKey: .dockerPHP)
@@ -125,5 +134,9 @@ struct CVEServerAssessment: Codable, Identifiable {
var summary: String
var status: String
var evidence: String
var vendorStatus: String? = nil
var vendorEvidence: String? = nil
var vendorFixedVersion: String? = nil
var vendorInstalledVersion: String? = nil
var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + cveID }
}
+40 -1
View File
@@ -15,13 +15,41 @@ enum SSHScanner {
static let script = #"""
printf '===HOST===\n'; hostname
printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s
printf '===OS_RELEASE===\n'
if [ -r /etc/os-release ]; then
. /etc/os-release
printf 'ID=%s\n' "${ID:-unknown}"
printf 'VERSION_ID=%s\n' "${VERSION_ID:-unknown}"
printf 'VERSION_CODENAME=%s\n' "${VERSION_CODENAME:-unknown}"
fi
printf '===PHP===\n'
for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do
if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi
done | sort -u
printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true
printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true
printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)? ' | sort -u || true
printf '===PACKAGES===\n'
dpkg-query -W \
-f='${db:Status-Abbrev} ${binary:Package} ${Version}\n' \
'php*' 2>/dev/null |
awk '$1 ~ /^ii/ && $2 ~ /^php/ { print $2, $3 }' |
sort -u || true
printf '===PACKAGE_SOURCES===\n'
dpkg-query -W \
-f='${db:Status-Abbrev}|${binary:Package}|${Version}|${source:Package}|${source:Version}\n' \
'php*' 2>/dev/null |
while IFS='|' read -r state binary version src srcversion; do
case "$state" in ii*) ;; *) continue ;; esac
case "$binary" in php*) ;; *) continue ;; esac
[ -n "$version" ] || continue
[ -n "$src" ] || src="$binary"
[ -n "$srcversion" ] || srcversion="$version"
printf '%s|%s|%s|%s\n' \
"$binary" "$version" "$src" "$srcversion"
done
printf '===UPDATES===\n'
if command -v apt >/dev/null 2>&1; then
apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true
@@ -293,6 +321,17 @@ enum SSHScanner {
result.fpmServices = fields["FPM"] ?? []
result.nginxUpstreams = fields["NGINX"] ?? []
result.packageVersions = fields["PACKAGES"] ?? []
result.packageSources = fields["PACKAGE_SOURCES"] ?? []
let osRelease = fields["OS_RELEASE"] ?? []
result.ubuntuReleaseID = osRelease
.first(where: { $0.hasPrefix("ID=") })
.map { String($0.dropFirst(3)) } ?? ""
result.ubuntuCodename = osRelease
.first(where: { $0.hasPrefix("VERSION_CODENAME=") })
.map { String($0.dropFirst("VERSION_CODENAME=".count)) } ?? ""
result.availableUpdates = fields["UPDATES"] ?? []
result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? []
result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" }
@@ -0,0 +1,496 @@
import Foundation
enum ScanError: LocalizedError {
case invalidServer, commandFailed(String)
var errorDescription: String? {
switch self {
case .invalidServer: return "Invalid SSH host, username, port or identity file."
case .commandFailed(let message): return message
}
}
}
enum SSHScanner {
// Commands are fixed and read-only. User-supplied strings are passed as SSH arguments, never shell source.
static let script = #"""
printf '===HOST===\n'; hostname
printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s
printf '===PHP===\n'
for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do
if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi
done | sort -u
printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true
printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true
printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)? ' | sort -u || true
printf '===UPDATES===\n'
if command -v apt >/dev/null 2>&1; then
apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true
else
printf 'Package manager not supported\n'
fi
printf '===PACKAGE_ORIGINS===\n'
if command -v apt-cache >/dev/null 2>&1; then
dpkg-query -W -f='${Package}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)?$' | sort -u | while IFS= read -r pkg; do
[ -n "$pkg" ] || continue
installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true)
candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1)
printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}"
apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /'
done
else
printf 'APT metadata unavailable\n'
fi
printf '===DOCKER===\n'
if ! command -v docker >/dev/null 2>&1; then
printf 'STATUS|not-installed\n'
elif ! docker info >/dev/null 2>&1; then
printf 'STATUS|permission-denied\n'
else
ids=$(docker ps -q 2>/dev/null)
if [ -z "$ids" ]; then printf 'STATUS|no-running-containers\n'; fi
for id in $ids; do
name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,')
image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null)
version=$(docker exec "$id" php -r 'echo PHP_VERSION;' 2>/dev/null)
exit_code=$?
if [ "$exit_code" -eq 0 ] &&
printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$'; then
printf 'PHP|%s|%s|%s\n' "$name" "$image" "$version"
else
printf 'NO_PHP_CLI|%s|%s\n' "$name" "$image"
fi
done
fi
printf '===RECENT_PHP_FILES===\n'
if [ -d /var/www ]; then
find /var/www -type f -name '*.php' -mtime -3 -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40
else
printf 'Web root unavailable\n'
fi
printf '===WEB_ERROR_SIGNALS===\n'
# Portable, read-only web-server investigation.
# Never require root access or changes to server permissions.
webservers=""
if command -v nginx >/dev/null 2>&1 ||
[ -d /etc/nginx ]; then
webservers="nginx"
fi
if command -v apache2 >/dev/null 2>&1 ||
command -v httpd >/dev/null 2>&1 ||
[ -d /etc/apache2 ] ||
[ -d /etc/httpd ]; then
webservers="$webservers apache"
fi
webservers=$(printf '%s' "$webservers" | sed 's/^ *//')
if [ -z "$webservers" ]; then
printf 'STATUS|not-applicable|No Nginx or Apache installation detected\n'
else
checked=0
restricted=0
missing=0
total=0
journal_checked=0
for webserver in $webservers; do
case "$webserver" in
nginx)
candidates="/var/log/nginx/error.log"
# Prefer configured log locations when readable.
if command -v nginx >/dev/null 2>&1; then
config=$(nginx -T 2>/dev/null)
if [ -z "$config" ]; then
config=$(sudo -n nginx -T 2>/dev/null)
fi
configured=$(printf '%s\n' "$config" |
sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' |
grep '^/' |
sort -u)
if [ -n "$configured" ]; then
candidates="$configured"
fi
fi
;;
apache)
candidates="/var/log/apache2/error.log /var/log/httpd/error_log"
# Include configured absolute ErrorLog paths.
configured=$(
grep -RhEi '^[[:space:]]*ErrorLog[[:space:]]+/' \
/etc/apache2 /etc/httpd \
2>/dev/null |
awk '{print $2}' |
sort -u
)
if [ -n "$configured" ]; then
candidates="$configured"
fi
;;
esac
found_log=0
for log in $candidates; do
case "$log" in
/*) ;;
*) continue ;;
esac
total=$((total + 1))
if [ ! -e "$log" ]; then
missing=$((missing + 1))
continue
fi
found_log=1
log_data=""
read_ok=0
if [ -r "$log" ]; then
log_data=$(tail -n 1000 "$log" 2>/dev/null)
read_ok=$?
elif command -v sudo >/dev/null 2>&1; then
log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null)
read_ok=$?
else
read_ok=1
fi
if [ "$read_ok" -eq 0 ]; then
checked=$((checked + 1))
printf '%s\n' "$log_data" |
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
tail -20 |
while IFS= read -r entry; do
[ -n "$entry" ] &&
printf 'SIGNAL|%s|%s\n' "$log" "$entry"
done
else
restricted=$((restricted + 1))
fi
done
# Journal access may work even when log files are restricted.
if [ "$webserver" = "nginx" ]; then
units="nginx"
else
units="apache2 httpd"
fi
for unit in $units; do
if ! command -v journalctl >/dev/null 2>&1; then
continue
fi
journal_data=$(journalctl -u "$unit" -n 300 \
--no-pager --output=cat 2>/dev/null)
journal_rc=$?
if [ "$journal_rc" -ne 0 ]; then
journal_data=$(sudo -n journalctl -u "$unit" \
-n 300 --no-pager --output=cat 2>/dev/null)
journal_rc=$?
fi
if [ "$journal_rc" -eq 0 ] &&
[ -n "$journal_data" ] &&
[ "$journal_data" != "-- No entries --" ]; then
journal_checked=$((journal_checked + 1))
printf '%s\n' "$journal_data" |
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
tail -20 |
while IFS= read -r entry; do
[ -n "$entry" ] &&
printf 'SIGNAL|journal:%s|%s\n' "$unit" "$entry"
done
fi
done
done
if [ "$restricted" -gt 0 ]; then
printf 'STATUS|limited-access|servers=%s|logs_checked=%s|restricted=%s|journal_sources=%s\n' \
"$webservers" "$checked" "$restricted" "$journal_checked"
elif [ "$checked" -gt 0 ]; then
printf 'STATUS|complete|servers=%s|logs_checked=%s|journal_sources=%s\n' \
"$webservers" "$checked" "$journal_checked"
elif [ "$journal_checked" -gt 0 ]; then
printf 'STATUS|journal-inspected|servers=%s|journal_sources=%s\n' \
"$webservers" "$journal_checked"
else
printf 'STATUS|no-accessible-logs|servers=%s|logs_found=%s|missing=%s\n' \
"$webservers" "$found_log" "$missing"
fi
fi
printf '===END===\n'
"""#
static func scan(_ server: Server) async throws -> AuditResult {
try await Task.detached(priority: .userInitiated) { try run(server) }.value
}
private static func run(_ server: Server) throws -> AuditResult {
let validName = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/
let validHost = /^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/
guard server.user.wholeMatch(of: validName) != nil,
server.host.wholeMatch(of: validHost) != nil,
(1...65535).contains(server.port),
!server.identityFile.contains("\n") else { throw ScanError.invalidServer }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
var args = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(server.port)]
if !server.identityFile.isEmpty {
args += ["-i", (server.identityFile as NSString).expandingTildeInPath]
}
args += ["--", "\(server.user)@\(server.host)", "sh -s"]
process.arguments = args
let input = Pipe(), output = Pipe(), errors = Pipe()
process.standardInput = input
process.standardOutput = output
process.standardError = errors
try process.run()
input.fileHandleForWriting.write(Data(script.utf8))
try? input.fileHandleForWriting.close()
// Both pipes are drained concurrently to avoid deadlocks on large output.
let group = DispatchGroup()
var stdout = Data(), stderr = Data()
group.enter()
DispatchQueue.global().async { stdout = output.fileHandleForReading.readDataToEndOfFile(); group.leave() }
group.enter()
DispatchQueue.global().async { stderr = errors.fileHandleForReading.readDataToEndOfFile(); group.leave() }
process.waitUntilExit()
group.wait()
guard process.terminationStatus == 0 else {
throw ScanError.commandFailed(String(data: stderr, encoding: .utf8) ?? "SSH failed (\(process.terminationStatus))")
}
return parse(String(decoding: stdout, as: UTF8.self), server: server)
}
static func parse(_ raw: String, server: Server) -> AuditResult {
var result = AuditResult(serverID: server.id, serverName: server.name)
result.rawOutput = raw
var section = ""
var fields: [String: [String]] = [:]
for line in raw.components(separatedBy: .newlines) {
if line.hasPrefix("===") && line.hasSuffix("===") { section = String(line.dropFirst(3).dropLast(3)); continue }
if !line.trimmingCharacters(in: .whitespaces).isEmpty { fields[section, default: []].append(line) }
}
result.hostname = fields["HOST"]?.first ?? "Unknown"
result.os = fields["OS"]?.first ?? "Unknown"
result.phpVersions = fields["PHP"] ?? []
result.fpmServices = fields["FPM"] ?? []
result.nginxUpstreams = fields["NGINX"] ?? []
result.packageVersions = fields["PACKAGES"] ?? []
result.availableUpdates = fields["UPDATES"] ?? []
result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? []
result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" }
let nginxLines = fields["WEB_ERROR_SIGNALS"] ?? []
result.webErrorSignals = nginxLines.compactMap { line in
guard line.hasPrefix("SIGNAL|") else { return nil }
return String(line.dropFirst("SIGNAL|".count))
}
if let status = nginxLines.first(where: { $0.hasPrefix("STATUS|") }) {
let parts = status.split(separator: "|").map(String.init)
let kind = parts.count > 1 ? parts[1] : ""
let details = parts.dropFirst(2).joined(separator: ", ")
switch kind {
case "not-applicable":
result.investigationStatus =
"Not applicable: No Nginx or Apache installation detected"
case "limited-access":
result.investigationStatus =
"Limited access: Some web-server logs require additional permissions (" + details + ")"
case "no-accessible-logs":
result.investigationStatus =
"Limited coverage: No accessible web-server logs (" + details + ")"
case "complete":
result.investigationStatus =
"Complete: Web-server error logs inspected (" + details + ")"
case "journal-inspected":
result.investigationStatus =
"Complete: Web-server systemd journal inspected"
case "partial":
result.investigationStatus =
"Partial: Nginx logs inspected with limitations (" + details + ")"
case "unavailable":
result.investigationStatus =
"Partial: Nginx logging unavailable (" + details + ")"
default:
result.investigationStatus =
"Partial: Nginx inspection status unknown"
}
} else if nginxLines.contains(where: {
$0.hasPrefix("Nginx error log unavailable")
}) {
result.investigationStatus =
"Partial: Nginx error log inaccessible or absent"
} else {
result.investigationStatus =
"Partial: Nginx log inspection status not reported"
}
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
}
let noPHP = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }
let dockerStatus = dockerLines.first(where: { $0.hasPrefix("STATUS|") })
if let dockerStatus {
switch dockerStatus {
case "STATUS|permission-denied": result.dockerAuditStatus = "Docker inaccessible: permission or daemon error"
case "STATUS|not-installed": result.dockerAuditStatus = "Docker not installed"
case "STATUS|no-running-containers": result.dockerAuditStatus = "No running containers"
default: result.dockerAuditStatus = "Docker inspection incomplete"
}
} else {
result.dockerAuditStatus = "\(result.dockerPHP.count) PHP runtimes; \(noPHP.count) containers without PHP CLI (not a security finding)"
}
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"
// Recommendations are derived from this server's collected evidence.
var actions: [String] = []
if !result.availableUpdates.isEmpty {
actions.append(
"PRIORITY: Review \(result.availableUpdates.count) available PHP package update(s). " +
"Check the applicable vendor advisories before applying changes."
)
}
if result.phpVersions.isEmpty {
actions.append(
"COVERAGE: No host PHP CLI was detected in standard locations. " +
"Check custom installations and application-specific runtimes."
)
}
if !result.dockerPHP.isEmpty {
actions.append(
"CONTAINERS: Review \(result.dockerPHP.count) detected PHP runtime(s) " +
"against their image maintainers' current security releases. " +
"Host package updates do not update container images."
)
}
if result.dockerAuditStatus.hasPrefix("Docker inaccessible") {
actions.append(
"COVERAGE: Docker inspection failed. Verify container inventory access " +
"before treating this assessment as complete."
)
}
if result.packageAuditStatus.contains("unavailable") {
actions.append(
"COVERAGE: PHP package provenance could not be established. " +
"Verify package repositories and installed revisions."
)
}
if !result.webErrorSignals.isEmpty {
let blocked = result.webErrorSignals.filter {
$0.localizedCaseInsensitiveContains("access forbidden by rule")
}.count
let other = result.webErrorSignals.count - blocked
if other > 0 {
actions.append(
"INVESTIGATE: Review \(other) web-server error signal(s) " +
"for application failures, PHP errors or abnormal behaviour."
)
}
if blocked > 0 {
actions.append(
"INFORMATION: Nginx blocked \(blocked) suspicious request(s). " +
"Confirm access-control rules remain effective. " +
"Blocked requests alone do not establish a compromise."
)
}
}
if !result.recentPHPFiles.isEmpty {
actions.append(
"VERIFY: \(result.recentPHPFiles.count) recently modified PHP file(s) " +
"were identified. Compare them against expected deployments."
)
}
if actions.isEmpty {
actions.append(
"No immediate follow-up actions were identified by the collected indicators. " +
"Continue monitoring and verify PHP security advisories independently."
)
}
result.securityTriage = actions
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."))
}
for line in result.phpVersions {
let parts = line.split(separator: " ")
guard let version = parts.last, let major = Int(version.split(separator: ".").first ?? "") else { continue }
if major < 8 {
result.findings.append(Finding(severity: "Warning", title: "Legacy PHP detected", detail: "\(line). Verify vendor support and distribution security maintenance."))
}
}
for line in result.packageOrigins where line.contains("installed=") && line.contains("candidate=") {
let parts = line.split(separator: " ")
guard let name = parts.first, name.hasPrefix("php") else { continue }
let installed = parts.first(where: { $0.hasPrefix("installed=") }).map { String($0.dropFirst(10)) } ?? ""
let candidate = parts.first(where: { $0.hasPrefix("candidate=") }).map { String($0.dropFirst(10)) } ?? ""
if !installed.isEmpty && installed == candidate {
// Equal revisions indicate no available APT upgrade, not confirmed CVE remediation.
continue
}
if !installed.isEmpty && !candidate.isEmpty && candidate != "(none)" {
result.findings.append(Finding(severity: "Info", title: "Package revision review", detail: "\(name): installed \(installed), candidate \(candidate). Confirm with vendor advisory and dpkg version ordering."))
}
}
for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") {
result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure."))
}
if result.packageOrigins.contains("APT metadata unavailable") {
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 + ". 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))
}
if !result.webErrorSignals.isEmpty {
result.findings.append(Finding(severity: "Warning", title: "Web server error signals", detail: "\(result.webErrorSignals.count) potentially relevant log entries. Review context; errors alone do not establish an attack."))
}
if !result.recentPHPFiles.isEmpty {
result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments."))
}
result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan."))
return result
}
}
+277
View File
@@ -178,3 +178,280 @@ extension VulnerabilityService {
private struct VersionMetric: Decodable { let cvssData: VersionCVSS }
private struct VersionCVSS: Decodable { let baseScore: Double?; let baseSeverity: String? }
}
// MARK: - Ubuntu Security Advisory Verification
extension VulnerabilityService {
private struct UbuntuAdvisory: Decodable {
let packages: [UbuntuPackage]?
}
private struct UbuntuPackage: Decodable {
let name: String
let statuses: [UbuntuStatus]?
}
private struct UbuntuStatus: Decodable {
let release_codename: String
let status: String
let description: String?
let pocket: String?
}
private struct InstalledPackage {
let binary: String
let version: String
let source: String
let sourceVersion: String
}
private static func installedPackages(
_ lines: [String]
) -> [InstalledPackage] {
lines.compactMap { line in
let fields = line.split(
separator: "|",
omittingEmptySubsequences: false
).map(String.init)
guard fields.count >= 4 else {
return nil
}
return InstalledPackage(
binary: fields[0],
version: fields[1],
source: fields[2],
sourceVersion: fields[3]
)
}
}
static func assessUbuntu(
_ assessments: [CVEServerAssessment],
report: AuditResult
) async -> [CVEServerAssessment] {
guard report.ubuntuReleaseID == "ubuntu",
!report.ubuntuCodename.isEmpty else {
return assessments.map { original in
var item = original
item.vendorStatus = "Unverified"
item.vendorEvidence =
"Ubuntu release could not be established."
return item
}
}
let installed = installedPackages(report.packageSources)
guard !installed.isEmpty else {
return assessments.map { original in
var item = original
item.vendorStatus = "Unverified"
item.vendorEvidence =
"No installed PHP source packages were collected."
return item
}
}
var output: [CVEServerAssessment] = []
// Cache repeated CVEs across different PHP runtimes.
var cache: [String: UbuntuAdvisory] = [:]
for original in assessments {
var item = original
item.vendorStatus = "Unverified"
// Ubuntu host packages must never be used to certify
// a Docker runtime as patched.
let runtimeVersion = original.runtime
.replacingOccurrences(of: "PHP ", with: "")
let hostHasVersion = report.phpVersions.contains {
$0.contains(runtimeVersion)
}
guard hostHasVersion else {
item.vendorEvidence =
"Runtime not confirmed as a host PHP installation. " +
"Docker image security must be checked separately."
output.append(item)
continue
}
guard original.cveID.range(
of: #"^CVE-[0-9]{4}-[0-9]{4,}$"#,
options: .regularExpression
) != nil else {
item.vendorEvidence = "Invalid CVE identifier."
output.append(item)
continue
}
do {
let advisory: UbuntuAdvisory
if let existing = cache[original.cveID] {
advisory = existing
} else {
let address =
"https://ubuntu.com/security/cves/" +
original.cveID + ".json"
guard let url = URL(string: address) else {
throw ServiceError.invalidResponse
}
var request = URLRequest(url: url)
request.timeoutInterval = 20
request.setValue(
"application/json",
forHTTPHeaderField: "Accept"
)
let (data, response) =
try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw ServiceError.invalidResponse
}
advisory = try JSONDecoder().decode(
UbuntuAdvisory.self,
from: data
)
cache[original.cveID] = advisory
}
var matches: [(String, String)] = []
for package in installed {
guard let advisoryPackage =
advisory.packages?.first(where: {
$0.name == package.source
}) else {
continue
}
let statuses = (
advisoryPackage.statuses ?? []
).filter {
$0.release_codename ==
report.ubuntuCodename
}
for status in statuses {
let state = status.status.lowercased()
let description =
status.description ?? ""
let evidence =
"\(package.binary) \(package.version), " +
"source \(package.source) " +
"\(package.sourceVersion), " +
"Ubuntu \(report.ubuntuCodename): " +
"\(status.status)" +
(
description.isEmpty
? ""
: " (\(description))"
)
switch state {
case "not-affected":
matches.append((
"Not affected",
evidence
))
case "released", "fixed":
matches.append((
"Ubuntu fix published",
evidence +
". Installed revision has not " +
"been compared with the fixed revision."
))
case "needed":
matches.append((
"Ubuntu fix needed",
evidence
))
case "dne":
matches.append((
"Package not in Ubuntu release",
evidence +
". Check third-party repositories."
))
default:
matches.append((
"Unverified",
evidence
))
}
}
}
if matches.count == 1 {
item.vendorStatus = matches[0].0
item.vendorEvidence = matches[0].1
} else if matches.isEmpty {
item.vendorStatus = "Unverified"
item.vendorEvidence =
"No matching Ubuntu security advisory " +
"for installed PHP source packages."
} else {
// Different PHP packages can have different
// security states. Avoid a false patched verdict.
item.vendorStatus = "Multiple package states"
item.vendorEvidence = matches
.map { $0.1 }
.joined(separator: "; ")
}
} catch {
item.vendorStatus = "Unverified"
item.vendorEvidence =
"Ubuntu advisory lookup failed: " +
error.localizedDescription
}
output.append(item)
}
return output
}
}
@@ -0,0 +1,244 @@
import Foundation
import SwiftUI
@MainActor final class AuditStore: ObservableObject {
@Published var servers: [Server] = [] { didSet { saveServers() } }
@Published var results: [AuditResult] = [] { didSet { saveResults() } }
@Published var scanning: Set<UUID> = []
@Published var scanProgressVisible = false
@Published var scanStartedAt = Date()
@Published var scanServerName = ""
@Published var scanServerHost = ""
@Published var scanStage = "Preparing scan"
@Published var scanDetail = ""
@Published var scanFraction = 0.0
@Published var scanCompletedChecks = 0
@Published var scanTotalChecks = 0
@Published var lastError: String?
@Published var discoveredCVEs: [CVERecord] = [] { didSet { saveCVECache() } }
@Published var cveLastChecked: Date? { didSet { saveCVECache() } }
@Published var cveError: String?
@Published var fetchingCVEs = false
private var folder: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let path = base.appendingPathComponent("PHPGuard", isDirectory: true)
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
return path
}
init() {
let defaults = UserDefaults.standard
autoRefreshCVEs = defaults.object(forKey: "phpguard.autoRefreshCVEs") as? Bool ?? true
showTechnicalDetails = defaults.bool(forKey: "phpguard.showTechnicalDetails")
confirmBeforeClearing = defaults.object(forKey: "phpguard.confirmClear") as? Bool ?? true
preferredReportAccent = defaults.string(forKey: "phpguard.reportAccent") ?? "Blue"
retainHistoryLimit = defaults.object(forKey: "phpguard.historyLimit") as? Int ?? 250
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
result.dockerPHP = source.dockerPHP.filter {
!$0.contains("OCI runtime exec failed") && !$0.contains("executable file not found")
}
result.findings.removeAll {
$0.title == "PHP runtime in Docker" &&
($0.detail.contains("OCI runtime exec failed") || $0.detail.contains("executable file not found"))
}
if result.dockerPHP.count != source.dockerPHP.count {
result.dockerAuditStatus = "Legacy scan: missing PHP executables excluded; rescan for structured Docker coverage"
}
return result
}
func importReport() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let report = Self.normalized(try decoder.decode(AuditResult.self, from: Data(contentsOf: url)))
guard !results.contains(where: { $0.id == report.id }) else {
lastError = "This report has already been imported."
return
}
results.insert(report, at: 0)
} catch { lastError = "Could not import JSON report: \(error.localizedDescription)" }
}
private func load<T: Decodable>(_ name: String) -> T? {
guard let data = try? Data(contentsOf: folder.appendingPathComponent(name)) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
private func save<T: Encodable>(_ value: T, as name: String) {
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(max(1, retainHistoryLimit))), as: "results.json") }
func latest(for server: Server) -> AuditResult? { results.first { $0.serverID == server.id } }
func scan(_ server: Server) {
guard !scanning.contains(server.id) else { return }
if scanning.isEmpty {
scanStartedAt = Date()
}
scanServerName = server.name
scanServerHost = server.host
scanProgressVisible = true
scanStage = "Connecting to server"
scanDetail = "Establishing SSH connection and collecting evidence"
scanFraction = 0.05
scanCompletedChecks = 0
scanTotalChecks = 0
scanning.insert(server.id)
Task {
defer {
scanning.remove(server.id)
if scanning.isEmpty {
scanProgressVisible = false
}
}
do {
var report = try await SSHScanner.scan(server)
scanStage = "SSH assessment complete"
scanDetail = "Analysing PHP runtimes and preparing vulnerability checks"
scanFraction = 0.60
report.cveAssessmentStatus = "Checking NVD PHP product applicability"
let runtimeLines = report.phpVersions + report.dockerPHP
var versions: [String: String] = [:]
for line in runtimeLines {
let tokens = line.split(whereSeparator: { $0.isWhitespace })
guard let token = tokens.last else { continue }
let version = String(token)
let parts = version.split(separator: ".")
guard parts.count >= 3,
parts.prefix(3).allSatisfy({ Int($0) != nil }) else { continue }
let canonical = parts.prefix(3).joined(separator: ".")
versions[canonical] = "PHP " + canonical
}
scanTotalChecks = versions.count
scanCompletedChecks = 0
if versions.isEmpty {
scanStage = "No PHP versions detected"
scanDetail = "Skipping NVD vulnerability assessment"
scanFraction = 0.95
report.cveAssessmentStatus = "Not assessed: no parseable PHP runtime version"
} else {
var all: [CVEServerAssessment] = []
var failures: [String] = []
// Sequential requests respect unauthenticated NVD API rate limits.
for version in versions.keys.sorted() {
scanStage = "Checking PHP vulnerabilities"
scanDetail = "Querying NVD for PHP " + version
do {
let records = try await VulnerabilityService.assessPHPVersion(
version, runtime: versions[version] ?? version)
all.append(contentsOf: records)
} catch {
failures.append("PHP \(version): \(error.localizedDescription)")
}
scanCompletedChecks += 1
scanFraction = 0.60 + (
0.35 * Double(scanCompletedChecks) /
Double(max(1, scanTotalChecks))
)
}
report.cveAssessments = all.sorted { $0.cveID > $1.cveID }
report.cveAssessmentStatus = failures.isEmpty
? "Upstream CPE assessment complete; distribution fixes unverified"
: "Partial/unknown: " + failures.joined(separator: "; ")
report.cveAssessedAt = Date()
}
scanStage = "Saving assessment"
scanDetail = "Writing the completed security report"
scanFraction = 0.98
results.insert(report, at: 0)
scanStage = "Assessment complete"
scanDetail = "Security report saved"
scanFraction = 1.0
}
catch {
var failed = AuditResult(serverID: server.id, serverName: server.name)
failed.error = error.localizedDescription
results.insert(failed, at: 0)
lastError = "\(server.name): \(error.localizedDescription)"
}
}
}
func scanAll() { servers.forEach { scan($0) } }
func refreshCVEsOnLaunch() {
// Preserve the local cache while checking NVD for a newer result set.
if autoRefreshCVEs { refreshCVEs() }
}
func refreshCVEs() {
guard !fetchingCVEs else { return }
fetchingCVEs = true
cveError = nil
Task {
defer { fetchingCVEs = false }
do {
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 }
}
}
@Published var showTechnicalDetails = false
@Published var confirmBeforeClearing = true
@Published var preferredReportAccent = "Blue"
@Published var autoRefreshCVEs = true
@Published var retainHistoryLimit = 250
func clearHistory() { results.removeAll() }
func removeHistory(_ offsets: IndexSet) { results.remove(atOffsets: offsets) }
func exportPDF(_ result: AuditResult) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.pdf]
panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).pdf"
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try ReportPDF.write(result, to: url, accent: preferredReportAccent) }
catch { lastError = "PDF export failed: \(error.localizedDescription)" }
}
func export(_ result: AuditResult) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).json"
if panel.runModal() == .OK, let url = panel.url {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(result).write(to: url)
} catch { lastError = error.localizedDescription }
}
}
}
@@ -0,0 +1,869 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var store: AuditStore
@State private var showInstalledPackages = false
@State private var showAPTOrigins = false
@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: $store.scanProgressVisible) {
ScanProgressView(store: store)
.interactiveDismissDisabled(!store.scanning.isEmpty)
}
.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 checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.")
.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)")
inventoryCards(
php: result.phpVersions,
fpm: result.fpmServices,
nginx: result.nginxUpstreams,
packages: result.packageVersions
)
dataSection("Available PHP package upgrades", result.availableUpdates)
DisclosureGroup(
isExpanded: $showAPTOrigins
) {
dataSection(
"APT package origins and candidate revisions",
result.packageOrigins
)
} label: {
Label(
"APT package origins and candidate revisions",
systemImage: "shippingbox"
)
.font(.headline)
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
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)
VStack(alignment: .leading, spacing: 10) {
Text("CVE assessment").font(.title2.bold())
Text(result.cveAssessmentStatus).foregroundStyle(.secondary)
Text("NVD upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.")
.font(.caption).foregroundStyle(.secondary)
if result.cveAssessments.isEmpty {
Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.")
.foregroundStyle(.secondary)
}
ForEach(result.cveAssessments) { item in
VStack(alignment: .leading, spacing: 5) {
HStack {
Link(item.cveID, destination: URL(string: item.referenceURL)!)
.font(.headline)
Text(item.severity).font(.caption.bold())
Spacer()
Text(item.status).foregroundStyle(.orange)
}
Text(item.runtime + " — " + item.summary).lineLimit(3)
Text(item.evidence).font(.caption).foregroundStyle(.secondary)
}.padding(10)
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10))
}
}
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)
}
}
// MARK: - PHP Inventory Dashboard
private func inventoryCards(
php: [String],
fpm: [String],
nginx: [String],
packages: [String]
) -> some View {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 16, alignment: .top),
GridItem(.flexible(), spacing: 16, alignment: .top)
],
alignment: .leading,
spacing: 16
) {
phpInventoryCard(php)
fpmInventoryCard(fpm)
nginxInventoryCard(nginx)
packageInventoryCard(packages)
}
.frame(maxWidth: .infinity)
}
private func inventoryCard<Content: View>(
title: String,
subtitle: String,
icon: String,
tint: Color,
count: Int,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .center, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 40, height: 40)
.background(
tint.opacity(0.12),
in: RoundedRectangle(cornerRadius: 11)
)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text("\(count)")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Color.secondary.opacity(0.10),
in: Capsule()
)
}
Rectangle()
.fill(Color.primary.opacity(0.07))
.frame(height: 1)
content()
Spacer(minLength: 0)
}
.padding(20)
.frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading)
.background(
Color(nsColor: .controlBackgroundColor),
in: RoundedRectangle(cornerRadius: 18)
)
.overlay {
RoundedRectangle(cornerRadius: 18)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
}
}
private func inventoryBadge(
_ text: String,
color: Color
) -> some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 7, height: 7)
Text(text)
.font(.caption.weight(.semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.10), in: Capsule())
}
private func phpInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "PHP Installations",
subtitle: "Detected PHP runtimes",
icon: "chevron.left.forwardslash.chevron.right",
tint: .blue,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP runtime detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let parts = line.split(
separator: " ",
omittingEmptySubsequences: true
)
let version = parts.last.map(String.init) ?? "Unknown"
let executable = parts.first.map(String.init) ?? line
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Text("PHP \(version)")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.75)
Spacer()
inventoryBadge("Detected", color: .green)
}
Label(executable, systemImage: "terminal")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
}
}
private func fpmInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "PHP-FPM Services",
subtitle: "FastCGI process managers",
icon: "gearshape.2.fill",
tint: .purple,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP-FPM services detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let parts = line.split(
whereSeparator: { $0.isWhitespace }
)
let service = parts.first.map(String.init) ?? line
let running = line.localizedCaseInsensitiveContains(
"active running"
) || (
line.contains("active") &&
line.contains("running")
)
VStack(alignment: .leading, spacing: 9) {
HStack(alignment: .top, spacing: 10) {
Text(service)
.font(.system(.body, design: .monospaced))
.fontWeight(.semibold)
.lineLimit(2)
Spacer(minLength: 6)
inventoryBadge(
running ? "Running" : "Check status",
color: running ? .green : .orange
)
}
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
.textSelection(.enabled)
}
}
}
}
}
}
private func nginxInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "Nginx FastCGI",
subtitle: "PHP routing configuration",
icon: "network",
tint: .cyan,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No FastCGI routes detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let route = line
.replacingOccurrences(
of: "fastcgi_pass",
with: ""
)
.trimmingCharacters(
in: .whitespacesAndNewlines
)
.trimmingCharacters(
in: CharacterSet(charactersIn: ";")
)
let isSocket = route.hasPrefix("unix:")
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(
isSocket
? "Unix socket"
: "FastCGI endpoint"
)
.font(.subheadline.weight(.semibold))
Spacer()
inventoryBadge(
"Configured",
color: .blue
)
}
Text(
isSocket
? String(route.dropFirst(5))
: route
)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(
horizontal: false,
vertical: true
)
}
}
}
}
}
}
private func packageInventoryCard(_ values: [String]) -> some View {
DisclosureGroup(isExpanded: $showInstalledPackages) {
inventoryCard(
title: "Installed Packages",
subtitle: "PHP package inventory",
icon: "shippingbox.fill",
tint: .orange,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP packages detected")
} else {
VStack(spacing: 0) {
ForEach(Array(values.enumerated()), id: \.offset) { index, line in
let parts = line.split(
separator: " ",
maxSplits: 1,
omittingEmptySubsequences: true
)
let name = parts.first.map(String.init) ?? line
let version = parts.count > 1
? String(parts[1])
: "Version not reported"
VStack(alignment: .leading, spacing: 5) {
Text(name)
.font(.subheadline.weight(.semibold))
Text(version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 9)
if index < values.count - 1 {
Divider()
}
}
}
}
}
} label: {
HStack {
Label("Installed Packages", systemImage: "shippingbox.fill")
.font(.headline)
Spacer()
Text("\(values.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
}
private func inventoryEmptyState(_ message: String) -> some View {
HStack(spacing: 9) {
Image(systemName: "minus.circle")
.foregroundStyle(.tertiary)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 12)
}
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)
}
}
// MARK: - Security Assessment Progress
struct ScanProgressView: View {
@ObservedObject var store: AuditStore
@State private var pulse = false
private var elapsed: String {
let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt)))
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
}
var body: some View {
VStack(spacing: 0) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.09))
.frame(width: 108, height: 108)
.scaleEffect(pulse ? 1.12 : 0.94)
Circle()
.stroke(Color.accentColor.opacity(0.18), lineWidth: 2)
.frame(width: 94, height: 94)
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 42, weight: .medium))
.foregroundStyle(Color.accentColor)
.symbolRenderingMode(.hierarchical)
}
.padding(.top, 34)
Text("Security Assessment")
.font(.system(size: 25, weight: .bold))
.padding(.top, 22)
Text("PHPGuard is inspecting your server")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 15) {
HStack(spacing: 12) {
Image(systemName: "server.rack")
.font(.title3)
.foregroundStyle(Color.accentColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(store.scanServerName)
.font(.headline)
Text(store.scanServerHost)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "checkmark.shield")
.foregroundStyle(.secondary)
}
Divider()
HStack {
VStack(alignment: .leading, spacing: 5) {
Text(store.scanStage)
.font(.subheadline.weight(.semibold))
Text(store.scanDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
ProgressView()
.controlSize(.regular)
}
ProgressView(value: store.scanFraction, total: 1.0)
.progressViewStyle(.linear)
.tint(Color.accentColor)
HStack {
Text("\(Int(store.scanFraction * 100))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
if store.scanTotalChecks > 0 {
Text(
"CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)"
)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
HStack {
Label(
"\(store.scanning.count) active",
systemImage: "network"
)
Spacer()
TimelineView(.periodic(from: .now, by: 1)) { context in
let seconds = max(
0,
Int(context.date.timeIntervalSince(store.scanStartedAt))
)
Text(
String(
format: "%02d:%02d",
seconds / 60,
seconds % 60
)
)
.monospacedDigit()
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(22)
.background(
Color.primary.opacity(0.035),
in: RoundedRectangle(cornerRadius: 16)
)
.padding(.horizontal, 30)
.padding(.top, 26)
Text("The assessment runs remotely over SSH. No server changes are made.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 35)
.padding(.top, 22)
.padding(.bottom, 30)
}
.frame(width: 540)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
pulse = true
}
.animation(
.easeInOut(duration: 1.7).repeatForever(autoreverses: true),
value: pulse
)
}
}
@@ -0,0 +1,180 @@
import Foundation
enum VulnerabilityService {
enum ServiceError: LocalizedError {
case invalidResponse
case httpError(Int)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from the vulnerability database."
case .httpError(let code):
return "Vulnerability database returned HTTP \(code)."
}
}
}
private struct NVDResponse: Decodable {
let vulnerabilities: [Entry]
}
private struct Entry: Decodable {
let cve: CVE
}
private struct CVE: Decodable {
let id: String
let published: String
let descriptions: [Description]
let metrics: Metrics?
}
private struct Description: Decodable {
let lang: String
let value: String
}
private struct Metrics: Decodable {
let cvssMetricV31: [Metric]?
let cvssMetricV30: [Metric]?
let cvssMetricV2: [Metric]?
}
private struct Metric: Decodable {
let cvssData: CVSSData
}
private struct CVSSData: Decodable {
let baseScore: Double?
let baseSeverity: String?
}
static func discover() async throws -> [CVERecord] {
var components = URLComponents(
string: "https://services.nvd.nist.gov/rest/json/cves/2.0"
)!
components.queryItems = [
URLQueryItem(name: "keywordSearch", value: "PHP"),
URLQueryItem(name: "resultsPerPage", value: "100")
]
guard let url = components.url else {
throw ServiceError.invalidResponse
}
var request = URLRequest(url: url)
request.timeoutInterval = 30
request.setValue(
"application/json",
forHTTPHeaderField: "Accept"
)
request.setValue(
"PHPGuard/1.4",
forHTTPHeaderField: "User-Agent"
)
let (data, response) = try await URLSession.shared.data(
for: request
)
guard let http = response as? HTTPURLResponse else {
throw ServiceError.invalidResponse
}
guard (200...299).contains(http.statusCode) else {
throw ServiceError.httpError(http.statusCode)
}
let decoded = try JSONDecoder().decode(
NVDResponse.self,
from: data
)
return decoded.vulnerabilities.map { entry in
let cve = entry.cve
let description = cve.descriptions.first {
$0.lang == "en"
}?.value ?? "No description available."
let metric =
cve.metrics?.cvssMetricV31?.first ??
cve.metrics?.cvssMetricV30?.first ??
cve.metrics?.cvssMetricV2?.first
return CVERecord(
id: cve.id,
description: description,
published: cve.published,
severity: metric?.cvssData.baseSeverity ?? "UNKNOWN",
score: metric?.cvssData.baseScore
)
}
.sorted { $0.published > $1.published }
}
}
extension VulnerabilityService {
// Returns upstream PHP CVE candidates, not confirmed Ubuntu/Debian exposures.
static func assessPHPVersion(_ version: String, runtime: String) async throws -> [CVEServerAssessment] {
let parts = version.split(separator: ".")
guard parts.count >= 3,
parts.prefix(3).allSatisfy({ Int($0) != nil }) else { return [] }
let canonical = parts.prefix(3).joined(separator: ".")
var components = URLComponents(string: "https://services.nvd.nist.gov/rest/json/cves/2.0")!
components.queryItems = [
URLQueryItem(name: "cpeName", value: "cpe:2.3:a:php:php:\(canonical):*:*:*:*:*:*:*"),
URLQueryItem(name: "resultsPerPage", value: "2000")
]
guard let url = components.url else { throw ServiceError.invalidResponse }
var request = URLRequest(url: url)
request.timeoutInterval = 45
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("PHPGuard/1.5", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse }
guard (200...299).contains(http.statusCode) else { throw ServiceError.httpError(http.statusCode) }
let payload = try JSONDecoder().decode(VersionResponse.self, from: data)
// An incomplete NVD response must not be presented as full coverage.
guard payload.totalResults <= payload.vulnerabilities.count else {
throw ServiceError.invalidResponse
}
return payload.vulnerabilities.map { entry in
let cve = entry.cve
let metric = cve.metrics?.cvssMetricV31?.first ?? cve.metrics?.cvssMetricV30?.first ?? cve.metrics?.cvssMetricV2?.first
return CVEServerAssessment(
cveID: cve.id, runtime: runtime,
severity: metric?.cvssData.baseSeverity ?? "UNKNOWN",
score: metric?.cvssData.baseScore,
summary: cve.descriptions.first(where: { $0.lang == "en" })?.value ?? "No English description",
status: "Potentially affected",
evidence: "NVD PHP product CPE matches upstream PHP \(canonical). Distribution package revisions, backported patches, and container image fixes have NOT been verified."
)
}
}
private struct VersionResponse: Decodable {
let totalResults: Int
let vulnerabilities: [VersionEntry]
}
private struct VersionEntry: Decodable { let cve: VersionCVE }
private struct VersionCVE: Decodable {
let id: String
let descriptions: [VersionDescription]
let metrics: VersionMetrics?
}
private struct VersionDescription: Decodable { let lang: String; let value: String }
private struct VersionMetrics: Decodable {
let cvssMetricV31: [VersionMetric]?
let cvssMetricV30: [VersionMetric]?
let cvssMetricV2: [VersionMetric]?
}
private struct VersionMetric: Decodable { let cvssData: VersionCVSS }
private struct VersionCVSS: Decodable { let baseScore: Double?; let baseSeverity: String? }
}
@@ -0,0 +1,244 @@
import Foundation
import SwiftUI
@MainActor final class AuditStore: ObservableObject {
@Published var servers: [Server] = [] { didSet { saveServers() } }
@Published var results: [AuditResult] = [] { didSet { saveResults() } }
@Published var scanning: Set<UUID> = []
@Published var scanProgressVisible = false
@Published var scanStartedAt = Date()
@Published var scanServerName = ""
@Published var scanServerHost = ""
@Published var scanStage = "Preparing scan"
@Published var scanDetail = ""
@Published var scanFraction = 0.0
@Published var scanCompletedChecks = 0
@Published var scanTotalChecks = 0
@Published var lastError: String?
@Published var discoveredCVEs: [CVERecord] = [] { didSet { saveCVECache() } }
@Published var cveLastChecked: Date? { didSet { saveCVECache() } }
@Published var cveError: String?
@Published var fetchingCVEs = false
private var folder: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let path = base.appendingPathComponent("PHPGuard", isDirectory: true)
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
return path
}
init() {
let defaults = UserDefaults.standard
autoRefreshCVEs = defaults.object(forKey: "phpguard.autoRefreshCVEs") as? Bool ?? true
showTechnicalDetails = defaults.bool(forKey: "phpguard.showTechnicalDetails")
confirmBeforeClearing = defaults.object(forKey: "phpguard.confirmClear") as? Bool ?? true
preferredReportAccent = defaults.string(forKey: "phpguard.reportAccent") ?? "Blue"
retainHistoryLimit = defaults.object(forKey: "phpguard.historyLimit") as? Int ?? 250
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
result.dockerPHP = source.dockerPHP.filter {
!$0.contains("OCI runtime exec failed") && !$0.contains("executable file not found")
}
result.findings.removeAll {
$0.title == "PHP runtime in Docker" &&
($0.detail.contains("OCI runtime exec failed") || $0.detail.contains("executable file not found"))
}
if result.dockerPHP.count != source.dockerPHP.count {
result.dockerAuditStatus = "Legacy scan: missing PHP executables excluded; rescan for structured Docker coverage"
}
return result
}
func importReport() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let report = Self.normalized(try decoder.decode(AuditResult.self, from: Data(contentsOf: url)))
guard !results.contains(where: { $0.id == report.id }) else {
lastError = "This report has already been imported."
return
}
results.insert(report, at: 0)
} catch { lastError = "Could not import JSON report: \(error.localizedDescription)" }
}
private func load<T: Decodable>(_ name: String) -> T? {
guard let data = try? Data(contentsOf: folder.appendingPathComponent(name)) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
private func save<T: Encodable>(_ value: T, as name: String) {
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(max(1, retainHistoryLimit))), as: "results.json") }
func latest(for server: Server) -> AuditResult? { results.first { $0.serverID == server.id } }
func scan(_ server: Server) {
guard !scanning.contains(server.id) else { return }
if scanning.isEmpty {
scanStartedAt = Date()
}
scanServerName = server.name
scanServerHost = server.host
scanProgressVisible = true
scanStage = "Connecting to server"
scanDetail = "Establishing SSH connection and collecting evidence"
scanFraction = 0.05
scanCompletedChecks = 0
scanTotalChecks = 0
scanning.insert(server.id)
Task {
defer {
scanning.remove(server.id)
if scanning.isEmpty {
scanProgressVisible = false
}
}
do {
var report = try await SSHScanner.scan(server)
scanStage = "SSH assessment complete"
scanDetail = "Analysing PHP runtimes and preparing vulnerability checks"
scanFraction = 0.60
report.cveAssessmentStatus = "Checking NVD PHP product applicability"
let runtimeLines = report.phpVersions + report.dockerPHP
var versions: [String: String] = [:]
for line in runtimeLines {
let tokens = line.split(whereSeparator: { $0.isWhitespace })
guard let token = tokens.last else { continue }
let version = String(token)
let parts = version.split(separator: ".")
guard parts.count >= 3,
parts.prefix(3).allSatisfy({ Int($0) != nil }) else { continue }
let canonical = parts.prefix(3).joined(separator: ".")
versions[canonical] = "PHP " + canonical
}
scanTotalChecks = versions.count
scanCompletedChecks = 0
if versions.isEmpty {
scanStage = "No PHP versions detected"
scanDetail = "Skipping NVD vulnerability assessment"
scanFraction = 0.95
report.cveAssessmentStatus = "Not assessed: no parseable PHP runtime version"
} else {
var all: [CVEServerAssessment] = []
var failures: [String] = []
// Sequential requests respect unauthenticated NVD API rate limits.
for version in versions.keys.sorted() {
scanStage = "Checking PHP vulnerabilities"
scanDetail = "Querying NVD for PHP " + version
do {
let records = try await VulnerabilityService.assessPHPVersion(
version, runtime: versions[version] ?? version)
all.append(contentsOf: records)
} catch {
failures.append("PHP \(version): \(error.localizedDescription)")
}
scanCompletedChecks += 1
scanFraction = 0.60 + (
0.35 * Double(scanCompletedChecks) /
Double(max(1, scanTotalChecks))
)
}
report.cveAssessments = all.sorted { $0.cveID > $1.cveID }
report.cveAssessmentStatus = failures.isEmpty
? "Upstream CPE assessment complete; distribution fixes unverified"
: "Partial/unknown: " + failures.joined(separator: "; ")
report.cveAssessedAt = Date()
}
scanStage = "Saving assessment"
scanDetail = "Writing the completed security report"
scanFraction = 0.98
results.insert(report, at: 0)
scanStage = "Assessment complete"
scanDetail = "Security report saved"
scanFraction = 1.0
}
catch {
var failed = AuditResult(serverID: server.id, serverName: server.name)
failed.error = error.localizedDescription
results.insert(failed, at: 0)
lastError = "\(server.name): \(error.localizedDescription)"
}
}
}
func scanAll() { servers.forEach { scan($0) } }
func refreshCVEsOnLaunch() {
// Preserve the local cache while checking NVD for a newer result set.
if autoRefreshCVEs { refreshCVEs() }
}
func refreshCVEs() {
guard !fetchingCVEs else { return }
fetchingCVEs = true
cveError = nil
Task {
defer { fetchingCVEs = false }
do {
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 }
}
}
@Published var showTechnicalDetails = false
@Published var confirmBeforeClearing = true
@Published var preferredReportAccent = "Blue"
@Published var autoRefreshCVEs = true
@Published var retainHistoryLimit = 250
func clearHistory() { results.removeAll() }
func removeHistory(_ offsets: IndexSet) { results.remove(atOffsets: offsets) }
func exportPDF(_ result: AuditResult) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.pdf]
panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).pdf"
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try ReportPDF.write(result, to: url, accent: preferredReportAccent) }
catch { lastError = "PDF export failed: \(error.localizedDescription)" }
}
func export(_ result: AuditResult) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).json"
if panel.runModal() == .OK, let url = panel.url {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(result).write(to: url)
} catch { lastError = error.localizedDescription }
}
}
}
@@ -0,0 +1,869 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var store: AuditStore
@State private var showInstalledPackages = false
@State private var showAPTOrigins = false
@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: $store.scanProgressVisible) {
ScanProgressView(store: store)
.interactiveDismissDisabled(!store.scanning.isEmpty)
}
.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 checks and Docker discovery. Per-runtime upstream NVD CVE candidates are assessed; distribution security fixes remain unverified.")
.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)")
inventoryCards(
php: result.phpVersions,
fpm: result.fpmServices,
nginx: result.nginxUpstreams,
packages: result.packageVersions
)
dataSection("Available PHP package upgrades", result.availableUpdates)
DisclosureGroup(
isExpanded: $showAPTOrigins
) {
dataSection(
"APT package origins and candidate revisions",
result.packageOrigins
)
} label: {
Label(
"APT package origins and candidate revisions",
systemImage: "shippingbox"
)
.font(.headline)
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
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)
VStack(alignment: .leading, spacing: 10) {
Text("CVE assessment").font(.title2.bold())
Text(result.cveAssessmentStatus).foregroundStyle(.secondary)
Text("NVD upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.")
.font(.caption).foregroundStyle(.secondary)
if result.cveAssessments.isEmpty {
Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.")
.foregroundStyle(.secondary)
}
ForEach(result.cveAssessments) { item in
VStack(alignment: .leading, spacing: 5) {
HStack {
Link(item.cveID, destination: URL(string: item.referenceURL)!)
.font(.headline)
Text(item.severity).font(.caption.bold())
Spacer()
Text(item.status).foregroundStyle(.orange)
}
Text(item.runtime + " — " + item.summary).lineLimit(3)
Text(item.evidence).font(.caption).foregroundStyle(.secondary)
}.padding(10)
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10))
}
}
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)
}
}
// MARK: - PHP Inventory Dashboard
private func inventoryCards(
php: [String],
fpm: [String],
nginx: [String],
packages: [String]
) -> some View {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 16, alignment: .top),
GridItem(.flexible(), spacing: 16, alignment: .top)
],
alignment: .leading,
spacing: 16
) {
phpInventoryCard(php)
fpmInventoryCard(fpm)
nginxInventoryCard(nginx)
packageInventoryCard(packages)
}
.frame(maxWidth: .infinity)
}
private func inventoryCard<Content: View>(
title: String,
subtitle: String,
icon: String,
tint: Color,
count: Int,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .center, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 40, height: 40)
.background(
tint.opacity(0.12),
in: RoundedRectangle(cornerRadius: 11)
)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Text("\(count)")
.font(.caption.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Color.secondary.opacity(0.10),
in: Capsule()
)
}
Rectangle()
.fill(Color.primary.opacity(0.07))
.frame(height: 1)
content()
Spacer(minLength: 0)
}
.padding(20)
.frame(maxWidth: .infinity, minHeight: 205, alignment: .topLeading)
.background(
Color(nsColor: .controlBackgroundColor),
in: RoundedRectangle(cornerRadius: 18)
)
.overlay {
RoundedRectangle(cornerRadius: 18)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
}
}
private func inventoryBadge(
_ text: String,
color: Color
) -> some View {
HStack(spacing: 6) {
Circle()
.fill(color)
.frame(width: 7, height: 7)
Text(text)
.font(.caption.weight(.semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(color.opacity(0.10), in: Capsule())
}
private func phpInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "PHP Installations",
subtitle: "Detected PHP runtimes",
icon: "chevron.left.forwardslash.chevron.right",
tint: .blue,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP runtime detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let parts = line.split(
separator: " ",
omittingEmptySubsequences: true
)
let version = parts.last.map(String.init) ?? "Unknown"
let executable = parts.first.map(String.init) ?? line
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Text("PHP \(version)")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.75)
Spacer()
inventoryBadge("Detected", color: .green)
}
Label(executable, systemImage: "terminal")
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
}
}
private func fpmInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "PHP-FPM Services",
subtitle: "FastCGI process managers",
icon: "gearshape.2.fill",
tint: .purple,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP-FPM services detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let parts = line.split(
whereSeparator: { $0.isWhitespace }
)
let service = parts.first.map(String.init) ?? line
let running = line.localizedCaseInsensitiveContains(
"active running"
) || (
line.contains("active") &&
line.contains("running")
)
VStack(alignment: .leading, spacing: 9) {
HStack(alignment: .top, spacing: 10) {
Text(service)
.font(.system(.body, design: .monospaced))
.fontWeight(.semibold)
.lineLimit(2)
Spacer(minLength: 6)
inventoryBadge(
running ? "Running" : "Check status",
color: running ? .green : .orange
)
}
Text(line)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(3)
.textSelection(.enabled)
}
}
}
}
}
}
private func nginxInventoryCard(_ values: [String]) -> some View {
inventoryCard(
title: "Nginx FastCGI",
subtitle: "PHP routing configuration",
icon: "network",
tint: .cyan,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No FastCGI routes detected")
} else {
VStack(alignment: .leading, spacing: 14) {
ForEach(values, id: \.self) { line in
let route = line
.replacingOccurrences(
of: "fastcgi_pass",
with: ""
)
.trimmingCharacters(
in: .whitespacesAndNewlines
)
.trimmingCharacters(
in: CharacterSet(charactersIn: ";")
)
let isSocket = route.hasPrefix("unix:")
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(
isSocket
? "Unix socket"
: "FastCGI endpoint"
)
.font(.subheadline.weight(.semibold))
Spacer()
inventoryBadge(
"Configured",
color: .blue
)
}
Text(
isSocket
? String(route.dropFirst(5))
: route
)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(
horizontal: false,
vertical: true
)
}
}
}
}
}
}
private func packageInventoryCard(_ values: [String]) -> some View {
DisclosureGroup(isExpanded: $showInstalledPackages) {
inventoryCard(
title: "Installed Packages",
subtitle: "PHP package inventory",
icon: "shippingbox.fill",
tint: .orange,
count: values.count
) {
if values.isEmpty {
inventoryEmptyState("No PHP packages detected")
} else {
VStack(spacing: 0) {
ForEach(Array(values.enumerated()), id: \.offset) { index, line in
let parts = line.split(
separator: " ",
maxSplits: 1,
omittingEmptySubsequences: true
)
let name = parts.first.map(String.init) ?? line
let version = parts.count > 1
? String(parts[1])
: "Version not reported"
VStack(alignment: .leading, spacing: 5) {
Text(name)
.font(.subheadline.weight(.semibold))
Text(version)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 9)
if index < values.count - 1 {
Divider()
}
}
}
}
}
} label: {
HStack {
Label("Installed Packages", systemImage: "shippingbox.fill")
.font(.headline)
Spacer()
Text("\(values.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(16)
.background(
.quaternary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 14)
)
}
private func inventoryEmptyState(_ message: String) -> some View {
HStack(spacing: 9) {
Image(systemName: "minus.circle")
.foregroundStyle(.tertiary)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 12)
}
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)
}
}
// MARK: - Security Assessment Progress
struct ScanProgressView: View {
@ObservedObject var store: AuditStore
@State private var pulse = false
private var elapsed: String {
let seconds = max(0, Int(Date().timeIntervalSince(store.scanStartedAt)))
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
}
var body: some View {
VStack(spacing: 0) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.09))
.frame(width: 108, height: 108)
.scaleEffect(pulse ? 1.12 : 0.94)
Circle()
.stroke(Color.accentColor.opacity(0.18), lineWidth: 2)
.frame(width: 94, height: 94)
Image(systemName: "shield.lefthalf.filled")
.font(.system(size: 42, weight: .medium))
.foregroundStyle(Color.accentColor)
.symbolRenderingMode(.hierarchical)
}
.padding(.top, 34)
Text("Security Assessment")
.font(.system(size: 25, weight: .bold))
.padding(.top, 22)
Text("PHPGuard is inspecting your server")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.top, 6)
VStack(alignment: .leading, spacing: 15) {
HStack(spacing: 12) {
Image(systemName: "server.rack")
.font(.title3)
.foregroundStyle(Color.accentColor)
.frame(width: 30)
VStack(alignment: .leading, spacing: 4) {
Text(store.scanServerName)
.font(.headline)
Text(store.scanServerHost)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "checkmark.shield")
.foregroundStyle(.secondary)
}
Divider()
HStack {
VStack(alignment: .leading, spacing: 5) {
Text(store.scanStage)
.font(.subheadline.weight(.semibold))
Text(store.scanDetail)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
ProgressView()
.controlSize(.regular)
}
ProgressView(value: store.scanFraction, total: 1.0)
.progressViewStyle(.linear)
.tint(Color.accentColor)
HStack {
Text("\(Int(store.scanFraction * 100))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
if store.scanTotalChecks > 0 {
Text(
"CVE checks: \(store.scanCompletedChecks)/\(store.scanTotalChecks)"
)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
HStack {
Label(
"\(store.scanning.count) active",
systemImage: "network"
)
Spacer()
TimelineView(.periodic(from: .now, by: 1)) { context in
let seconds = max(
0,
Int(context.date.timeIntervalSince(store.scanStartedAt))
)
Text(
String(
format: "%02d:%02d",
seconds / 60,
seconds % 60
)
)
.monospacedDigit()
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(22)
.background(
Color.primary.opacity(0.035),
in: RoundedRectangle(cornerRadius: 16)
)
.padding(.horizontal, 30)
.padding(.top, 26)
Text("The assessment runs remotely over SSH. No server changes are made.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 35)
.padding(.top, 22)
.padding(.bottom, 30)
}
.frame(width: 540)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
pulse = true
}
.animation(
.easeInOut(duration: 1.7).repeatForever(autoreverses: true),
value: pulse
)
}
}
@@ -0,0 +1,129 @@
import Foundation
struct Server: Codable, Identifiable, Hashable {
var id = UUID()
var name: String
var host: String
var user: String = "mag"
var port: Int = 22
var identityFile: String = ""
}
struct Finding: Codable, Identifiable {
var id = UUID()
var severity: String
var title: String
var detail: String
}
struct AuditResult: Codable, Identifiable {
var id = UUID()
var serverID: UUID
var serverName: String
var scannedAt = Date()
var hostname = ""
var os = ""
var phpVersions: [String] = []
var fpmServices: [String] = []
var nginxUpstreams: [String] = []
var packageVersions: [String] = []
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] = []
var cveAssessments: [CVEServerAssessment] = []
var cveAssessmentStatus = "Not assessed"
var cveAssessedAt: Date? = nil
var recentPHPFiles: [String] = []
var webErrorSignals: [String] = []
var investigationStatus = "Not checked"
var rawOutput = ""
var error: String? = nil
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, cveAssessments, cveAssessmentStatus, cveAssessedAt, 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)
serverID = try container.decode(UUID.self, forKey: .serverID)
serverName = try container.decode(String.self, forKey: .serverName)
scannedAt = try container.decodeIfPresent(Date.self, forKey: .scannedAt) ?? Date()
hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? ""
os = try container.decodeIfPresent(String.self, forKey: .os) ?? ""
phpVersions = try container.decodeIfPresent([String].self, forKey: .phpVersions) ?? []
fpmServices = try container.decodeIfPresent([String].self, forKey: .fpmServices) ?? []
nginxUpstreams = try container.decodeIfPresent([String].self, forKey: .nginxUpstreams) ?? []
packageVersions = try container.decodeIfPresent([String].self, forKey: .packageVersions) ?? []
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) ?? []
cveAssessments = try container.decodeIfPresent([CVEServerAssessment].self, forKey: .cveAssessments) ?? []
cveAssessmentStatus = try container.decodeIfPresent(String.self, forKey: .cveAssessmentStatus) ?? "Not assessed"
cveAssessedAt = try container.decodeIfPresent(Date.self, forKey: .cveAssessedAt)
recentPHPFiles = try container.decodeIfPresent([String].self, forKey: .recentPHPFiles) ?? []
webErrorSignals = try container.decodeIfPresent([String].self, forKey: .webErrorSignals) ?? []
investigationStatus = try container.decodeIfPresent(String.self, forKey: .investigationStatus) ?? "Not checked"
rawOutput = try container.decodeIfPresent(String.self, forKey: .rawOutput) ?? ""
error = try container.decodeIfPresent(String.self, forKey: .error)
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(serverID, forKey: .serverID)
try container.encode(serverName, forKey: .serverName)
try container.encode(scannedAt, forKey: .scannedAt)
try container.encode(hostname, forKey: .hostname)
try container.encode(os, forKey: .os)
try container.encode(phpVersions, forKey: .phpVersions)
try container.encode(fpmServices, forKey: .fpmServices)
try container.encode(nginxUpstreams, forKey: .nginxUpstreams)
try container.encode(packageVersions, forKey: .packageVersions)
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)
try container.encode(cveAssessments, forKey: .cveAssessments)
try container.encode(cveAssessmentStatus, forKey: .cveAssessmentStatus)
try container.encodeIfPresent(cveAssessedAt, forKey: .cveAssessedAt)
try container.encode(recentPHPFiles, forKey: .recentPHPFiles)
try container.encode(webErrorSignals, forKey: .webErrorSignals)
try container.encode(investigationStatus, forKey: .investigationStatus)
try container.encode(rawOutput, forKey: .rawOutput)
try container.encodeIfPresent(error, forKey: .error)
try container.encode(id, forKey: .id)
}
}
struct CVERecord: Codable, Identifiable {
var id: String
var description: String
var published: String
var severity: String
var score: Double?
var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + id }
}
// NVD upstream applicability is not proof of vulnerability in distribution-patched packages.
struct CVEServerAssessment: Codable, Identifiable {
var id: String { cveID + "|" + runtime }
var cveID: String
var runtime: String
var severity: String
var score: Double?
var summary: String
var status: String
var evidence: String
var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + cveID }
}
@@ -0,0 +1,496 @@
import Foundation
enum ScanError: LocalizedError {
case invalidServer, commandFailed(String)
var errorDescription: String? {
switch self {
case .invalidServer: return "Invalid SSH host, username, port or identity file."
case .commandFailed(let message): return message
}
}
}
enum SSHScanner {
// Commands are fixed and read-only. User-supplied strings are passed as SSH arguments, never shell source.
static let script = #"""
printf '===HOST===\n'; hostname
printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s
printf '===PHP===\n'
for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do
if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi
done | sort -u
printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true
printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true
printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)? ' | sort -u || true
printf '===UPDATES===\n'
if command -v apt >/dev/null 2>&1; then
apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true
else
printf 'Package manager not supported\n'
fi
printf '===PACKAGE_ORIGINS===\n'
if command -v apt-cache >/dev/null 2>&1; then
dpkg-query -W -f='${Package}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)?$' | sort -u | while IFS= read -r pkg; do
[ -n "$pkg" ] || continue
installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true)
candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1)
printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}"
apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /'
done
else
printf 'APT metadata unavailable\n'
fi
printf '===DOCKER===\n'
if ! command -v docker >/dev/null 2>&1; then
printf 'STATUS|not-installed\n'
elif ! docker info >/dev/null 2>&1; then
printf 'STATUS|permission-denied\n'
else
ids=$(docker ps -q 2>/dev/null)
if [ -z "$ids" ]; then printf 'STATUS|no-running-containers\n'; fi
for id in $ids; do
name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,')
image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null)
version=$(docker exec "$id" php -r 'echo PHP_VERSION;' 2>/dev/null)
exit_code=$?
if [ "$exit_code" -eq 0 ] &&
printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$'; then
printf 'PHP|%s|%s|%s\n' "$name" "$image" "$version"
else
printf 'NO_PHP_CLI|%s|%s\n' "$name" "$image"
fi
done
fi
printf '===RECENT_PHP_FILES===\n'
if [ -d /var/www ]; then
find /var/www -type f -name '*.php' -mtime -3 -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40
else
printf 'Web root unavailable\n'
fi
printf '===WEB_ERROR_SIGNALS===\n'
# Portable, read-only web-server investigation.
# Never require root access or changes to server permissions.
webservers=""
if command -v nginx >/dev/null 2>&1 ||
[ -d /etc/nginx ]; then
webservers="nginx"
fi
if command -v apache2 >/dev/null 2>&1 ||
command -v httpd >/dev/null 2>&1 ||
[ -d /etc/apache2 ] ||
[ -d /etc/httpd ]; then
webservers="$webservers apache"
fi
webservers=$(printf '%s' "$webservers" | sed 's/^ *//')
if [ -z "$webservers" ]; then
printf 'STATUS|not-applicable|No Nginx or Apache installation detected\n'
else
checked=0
restricted=0
missing=0
total=0
journal_checked=0
for webserver in $webservers; do
case "$webserver" in
nginx)
candidates="/var/log/nginx/error.log"
# Prefer configured log locations when readable.
if command -v nginx >/dev/null 2>&1; then
config=$(nginx -T 2>/dev/null)
if [ -z "$config" ]; then
config=$(sudo -n nginx -T 2>/dev/null)
fi
configured=$(printf '%s\n' "$config" |
sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' |
grep '^/' |
sort -u)
if [ -n "$configured" ]; then
candidates="$configured"
fi
fi
;;
apache)
candidates="/var/log/apache2/error.log /var/log/httpd/error_log"
# Include configured absolute ErrorLog paths.
configured=$(
grep -RhEi '^[[:space:]]*ErrorLog[[:space:]]+/' \
/etc/apache2 /etc/httpd \
2>/dev/null |
awk '{print $2}' |
sort -u
)
if [ -n "$configured" ]; then
candidates="$configured"
fi
;;
esac
found_log=0
for log in $candidates; do
case "$log" in
/*) ;;
*) continue ;;
esac
total=$((total + 1))
if [ ! -e "$log" ]; then
missing=$((missing + 1))
continue
fi
found_log=1
log_data=""
read_ok=0
if [ -r "$log" ]; then
log_data=$(tail -n 1000 "$log" 2>/dev/null)
read_ok=$?
elif command -v sudo >/dev/null 2>&1; then
log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null)
read_ok=$?
else
read_ok=1
fi
if [ "$read_ok" -eq 0 ]; then
checked=$((checked + 1))
printf '%s\n' "$log_data" |
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
tail -20 |
while IFS= read -r entry; do
[ -n "$entry" ] &&
printf 'SIGNAL|%s|%s\n' "$log" "$entry"
done
else
restricted=$((restricted + 1))
fi
done
# Journal access may work even when log files are restricted.
if [ "$webserver" = "nginx" ]; then
units="nginx"
else
units="apache2 httpd"
fi
for unit in $units; do
if ! command -v journalctl >/dev/null 2>&1; then
continue
fi
journal_data=$(journalctl -u "$unit" -n 300 \
--no-pager --output=cat 2>/dev/null)
journal_rc=$?
if [ "$journal_rc" -ne 0 ]; then
journal_data=$(sudo -n journalctl -u "$unit" \
-n 300 --no-pager --output=cat 2>/dev/null)
journal_rc=$?
fi
if [ "$journal_rc" -eq 0 ] &&
[ -n "$journal_data" ] &&
[ "$journal_data" != "-- No entries --" ]; then
journal_checked=$((journal_checked + 1))
printf '%s\n' "$journal_data" |
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
tail -20 |
while IFS= read -r entry; do
[ -n "$entry" ] &&
printf 'SIGNAL|journal:%s|%s\n' "$unit" "$entry"
done
fi
done
done
if [ "$restricted" -gt 0 ]; then
printf 'STATUS|limited-access|servers=%s|logs_checked=%s|restricted=%s|journal_sources=%s\n' \
"$webservers" "$checked" "$restricted" "$journal_checked"
elif [ "$checked" -gt 0 ]; then
printf 'STATUS|complete|servers=%s|logs_checked=%s|journal_sources=%s\n' \
"$webservers" "$checked" "$journal_checked"
elif [ "$journal_checked" -gt 0 ]; then
printf 'STATUS|journal-inspected|servers=%s|journal_sources=%s\n' \
"$webservers" "$journal_checked"
else
printf 'STATUS|no-accessible-logs|servers=%s|logs_found=%s|missing=%s\n' \
"$webservers" "$found_log" "$missing"
fi
fi
printf '===END===\n'
"""#
static func scan(_ server: Server) async throws -> AuditResult {
try await Task.detached(priority: .userInitiated) { try run(server) }.value
}
private static func run(_ server: Server) throws -> AuditResult {
let validName = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/
let validHost = /^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/
guard server.user.wholeMatch(of: validName) != nil,
server.host.wholeMatch(of: validHost) != nil,
(1...65535).contains(server.port),
!server.identityFile.contains("\n") else { throw ScanError.invalidServer }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
var args = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(server.port)]
if !server.identityFile.isEmpty {
args += ["-i", (server.identityFile as NSString).expandingTildeInPath]
}
args += ["--", "\(server.user)@\(server.host)", "sh -s"]
process.arguments = args
let input = Pipe(), output = Pipe(), errors = Pipe()
process.standardInput = input
process.standardOutput = output
process.standardError = errors
try process.run()
input.fileHandleForWriting.write(Data(script.utf8))
try? input.fileHandleForWriting.close()
// Both pipes are drained concurrently to avoid deadlocks on large output.
let group = DispatchGroup()
var stdout = Data(), stderr = Data()
group.enter()
DispatchQueue.global().async { stdout = output.fileHandleForReading.readDataToEndOfFile(); group.leave() }
group.enter()
DispatchQueue.global().async { stderr = errors.fileHandleForReading.readDataToEndOfFile(); group.leave() }
process.waitUntilExit()
group.wait()
guard process.terminationStatus == 0 else {
throw ScanError.commandFailed(String(data: stderr, encoding: .utf8) ?? "SSH failed (\(process.terminationStatus))")
}
return parse(String(decoding: stdout, as: UTF8.self), server: server)
}
static func parse(_ raw: String, server: Server) -> AuditResult {
var result = AuditResult(serverID: server.id, serverName: server.name)
result.rawOutput = raw
var section = ""
var fields: [String: [String]] = [:]
for line in raw.components(separatedBy: .newlines) {
if line.hasPrefix("===") && line.hasSuffix("===") { section = String(line.dropFirst(3).dropLast(3)); continue }
if !line.trimmingCharacters(in: .whitespaces).isEmpty { fields[section, default: []].append(line) }
}
result.hostname = fields["HOST"]?.first ?? "Unknown"
result.os = fields["OS"]?.first ?? "Unknown"
result.phpVersions = fields["PHP"] ?? []
result.fpmServices = fields["FPM"] ?? []
result.nginxUpstreams = fields["NGINX"] ?? []
result.packageVersions = fields["PACKAGES"] ?? []
result.availableUpdates = fields["UPDATES"] ?? []
result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? []
result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" }
let nginxLines = fields["WEB_ERROR_SIGNALS"] ?? []
result.webErrorSignals = nginxLines.compactMap { line in
guard line.hasPrefix("SIGNAL|") else { return nil }
return String(line.dropFirst("SIGNAL|".count))
}
if let status = nginxLines.first(where: { $0.hasPrefix("STATUS|") }) {
let parts = status.split(separator: "|").map(String.init)
let kind = parts.count > 1 ? parts[1] : ""
let details = parts.dropFirst(2).joined(separator: ", ")
switch kind {
case "not-applicable":
result.investigationStatus =
"Not applicable: No Nginx or Apache installation detected"
case "limited-access":
result.investigationStatus =
"Limited access: Some web-server logs require additional permissions (" + details + ")"
case "no-accessible-logs":
result.investigationStatus =
"Limited coverage: No accessible web-server logs (" + details + ")"
case "complete":
result.investigationStatus =
"Complete: Web-server error logs inspected (" + details + ")"
case "journal-inspected":
result.investigationStatus =
"Complete: Web-server systemd journal inspected"
case "partial":
result.investigationStatus =
"Partial: Nginx logs inspected with limitations (" + details + ")"
case "unavailable":
result.investigationStatus =
"Partial: Nginx logging unavailable (" + details + ")"
default:
result.investigationStatus =
"Partial: Nginx inspection status unknown"
}
} else if nginxLines.contains(where: {
$0.hasPrefix("Nginx error log unavailable")
}) {
result.investigationStatus =
"Partial: Nginx error log inaccessible or absent"
} else {
result.investigationStatus =
"Partial: Nginx log inspection status not reported"
}
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
}
let noPHP = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }
let dockerStatus = dockerLines.first(where: { $0.hasPrefix("STATUS|") })
if let dockerStatus {
switch dockerStatus {
case "STATUS|permission-denied": result.dockerAuditStatus = "Docker inaccessible: permission or daemon error"
case "STATUS|not-installed": result.dockerAuditStatus = "Docker not installed"
case "STATUS|no-running-containers": result.dockerAuditStatus = "No running containers"
default: result.dockerAuditStatus = "Docker inspection incomplete"
}
} else {
result.dockerAuditStatus = "\(result.dockerPHP.count) PHP runtimes; \(noPHP.count) containers without PHP CLI (not a security finding)"
}
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"
// Recommendations are derived from this server's collected evidence.
var actions: [String] = []
if !result.availableUpdates.isEmpty {
actions.append(
"PRIORITY: Review \(result.availableUpdates.count) available PHP package update(s). " +
"Check the applicable vendor advisories before applying changes."
)
}
if result.phpVersions.isEmpty {
actions.append(
"COVERAGE: No host PHP CLI was detected in standard locations. " +
"Check custom installations and application-specific runtimes."
)
}
if !result.dockerPHP.isEmpty {
actions.append(
"CONTAINERS: Review \(result.dockerPHP.count) detected PHP runtime(s) " +
"against their image maintainers' current security releases. " +
"Host package updates do not update container images."
)
}
if result.dockerAuditStatus.hasPrefix("Docker inaccessible") {
actions.append(
"COVERAGE: Docker inspection failed. Verify container inventory access " +
"before treating this assessment as complete."
)
}
if result.packageAuditStatus.contains("unavailable") {
actions.append(
"COVERAGE: PHP package provenance could not be established. " +
"Verify package repositories and installed revisions."
)
}
if !result.webErrorSignals.isEmpty {
let blocked = result.webErrorSignals.filter {
$0.localizedCaseInsensitiveContains("access forbidden by rule")
}.count
let other = result.webErrorSignals.count - blocked
if other > 0 {
actions.append(
"INVESTIGATE: Review \(other) web-server error signal(s) " +
"for application failures, PHP errors or abnormal behaviour."
)
}
if blocked > 0 {
actions.append(
"INFORMATION: Nginx blocked \(blocked) suspicious request(s). " +
"Confirm access-control rules remain effective. " +
"Blocked requests alone do not establish a compromise."
)
}
}
if !result.recentPHPFiles.isEmpty {
actions.append(
"VERIFY: \(result.recentPHPFiles.count) recently modified PHP file(s) " +
"were identified. Compare them against expected deployments."
)
}
if actions.isEmpty {
actions.append(
"No immediate follow-up actions were identified by the collected indicators. " +
"Continue monitoring and verify PHP security advisories independently."
)
}
result.securityTriage = actions
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."))
}
for line in result.phpVersions {
let parts = line.split(separator: " ")
guard let version = parts.last, let major = Int(version.split(separator: ".").first ?? "") else { continue }
if major < 8 {
result.findings.append(Finding(severity: "Warning", title: "Legacy PHP detected", detail: "\(line). Verify vendor support and distribution security maintenance."))
}
}
for line in result.packageOrigins where line.contains("installed=") && line.contains("candidate=") {
let parts = line.split(separator: " ")
guard let name = parts.first, name.hasPrefix("php") else { continue }
let installed = parts.first(where: { $0.hasPrefix("installed=") }).map { String($0.dropFirst(10)) } ?? ""
let candidate = parts.first(where: { $0.hasPrefix("candidate=") }).map { String($0.dropFirst(10)) } ?? ""
if !installed.isEmpty && installed == candidate {
// Equal revisions indicate no available APT upgrade, not confirmed CVE remediation.
continue
}
if !installed.isEmpty && !candidate.isEmpty && candidate != "(none)" {
result.findings.append(Finding(severity: "Info", title: "Package revision review", detail: "\(name): installed \(installed), candidate \(candidate). Confirm with vendor advisory and dpkg version ordering."))
}
}
for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") {
result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure."))
}
if result.packageOrigins.contains("APT metadata unavailable") {
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 + ". 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))
}
if !result.webErrorSignals.isEmpty {
result.findings.append(Finding(severity: "Warning", title: "Web server error signals", detail: "\(result.webErrorSignals.count) potentially relevant log entries. Review context; errors alone do not establish an attack."))
}
if !result.recentPHPFiles.isEmpty {
result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments."))
}
result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan."))
return result
}
}
@@ -0,0 +1,180 @@
import Foundation
enum VulnerabilityService {
enum ServiceError: LocalizedError {
case invalidResponse
case httpError(Int)
var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from the vulnerability database."
case .httpError(let code):
return "Vulnerability database returned HTTP \(code)."
}
}
}
private struct NVDResponse: Decodable {
let vulnerabilities: [Entry]
}
private struct Entry: Decodable {
let cve: CVE
}
private struct CVE: Decodable {
let id: String
let published: String
let descriptions: [Description]
let metrics: Metrics?
}
private struct Description: Decodable {
let lang: String
let value: String
}
private struct Metrics: Decodable {
let cvssMetricV31: [Metric]?
let cvssMetricV30: [Metric]?
let cvssMetricV2: [Metric]?
}
private struct Metric: Decodable {
let cvssData: CVSSData
}
private struct CVSSData: Decodable {
let baseScore: Double?
let baseSeverity: String?
}
static func discover() async throws -> [CVERecord] {
var components = URLComponents(
string: "https://services.nvd.nist.gov/rest/json/cves/2.0"
)!
components.queryItems = [
URLQueryItem(name: "keywordSearch", value: "PHP"),
URLQueryItem(name: "resultsPerPage", value: "100")
]
guard let url = components.url else {
throw ServiceError.invalidResponse
}
var request = URLRequest(url: url)
request.timeoutInterval = 30
request.setValue(
"application/json",
forHTTPHeaderField: "Accept"
)
request.setValue(
"PHPGuard/1.4",
forHTTPHeaderField: "User-Agent"
)
let (data, response) = try await URLSession.shared.data(
for: request
)
guard let http = response as? HTTPURLResponse else {
throw ServiceError.invalidResponse
}
guard (200...299).contains(http.statusCode) else {
throw ServiceError.httpError(http.statusCode)
}
let decoded = try JSONDecoder().decode(
NVDResponse.self,
from: data
)
return decoded.vulnerabilities.map { entry in
let cve = entry.cve
let description = cve.descriptions.first {
$0.lang == "en"
}?.value ?? "No description available."
let metric =
cve.metrics?.cvssMetricV31?.first ??
cve.metrics?.cvssMetricV30?.first ??
cve.metrics?.cvssMetricV2?.first
return CVERecord(
id: cve.id,
description: description,
published: cve.published,
severity: metric?.cvssData.baseSeverity ?? "UNKNOWN",
score: metric?.cvssData.baseScore
)
}
.sorted { $0.published > $1.published }
}
}
extension VulnerabilityService {
// Returns upstream PHP CVE candidates, not confirmed Ubuntu/Debian exposures.
static func assessPHPVersion(_ version: String, runtime: String) async throws -> [CVEServerAssessment] {
let parts = version.split(separator: ".")
guard parts.count >= 3,
parts.prefix(3).allSatisfy({ Int($0) != nil }) else { return [] }
let canonical = parts.prefix(3).joined(separator: ".")
var components = URLComponents(string: "https://services.nvd.nist.gov/rest/json/cves/2.0")!
components.queryItems = [
URLQueryItem(name: "cpeName", value: "cpe:2.3:a:php:php:\(canonical):*:*:*:*:*:*:*"),
URLQueryItem(name: "resultsPerPage", value: "2000")
]
guard let url = components.url else { throw ServiceError.invalidResponse }
var request = URLRequest(url: url)
request.timeoutInterval = 45
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("PHPGuard/1.5", forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw ServiceError.invalidResponse }
guard (200...299).contains(http.statusCode) else { throw ServiceError.httpError(http.statusCode) }
let payload = try JSONDecoder().decode(VersionResponse.self, from: data)
// An incomplete NVD response must not be presented as full coverage.
guard payload.totalResults <= payload.vulnerabilities.count else {
throw ServiceError.invalidResponse
}
return payload.vulnerabilities.map { entry in
let cve = entry.cve
let metric = cve.metrics?.cvssMetricV31?.first ?? cve.metrics?.cvssMetricV30?.first ?? cve.metrics?.cvssMetricV2?.first
return CVEServerAssessment(
cveID: cve.id, runtime: runtime,
severity: metric?.cvssData.baseSeverity ?? "UNKNOWN",
score: metric?.cvssData.baseScore,
summary: cve.descriptions.first(where: { $0.lang == "en" })?.value ?? "No English description",
status: "Potentially affected",
evidence: "NVD PHP product CPE matches upstream PHP \(canonical). Distribution package revisions, backported patches, and container image fixes have NOT been verified."
)
}
}
private struct VersionResponse: Decodable {
let totalResults: Int
let vulnerabilities: [VersionEntry]
}
private struct VersionEntry: Decodable { let cve: VersionCVE }
private struct VersionCVE: Decodable {
let id: String
let descriptions: [VersionDescription]
let metrics: VersionMetrics?
}
private struct VersionDescription: Decodable { let lang: String; let value: String }
private struct VersionMetrics: Decodable {
let cvssMetricV31: [VersionMetric]?
let cvssMetricV30: [VersionMetric]?
let cvssMetricV2: [VersionMetric]?
}
private struct VersionMetric: Decodable { let cvssData: VersionCVSS }
private struct VersionCVSS: Decodable { let baseScore: Double?; let baseSeverity: String? }
}