Updates to scanner with CVE saving and auto update

This commit is contained in:
Mark Gregory
2026-10-22 15:30:23 +01:00
parent 2478e4f7b3
commit 4fce70fc4c
4 changed files with 1673 additions and 82 deletions
+7 -3
View File
@@ -190,8 +190,11 @@ struct ContentView: View {
Image(
systemName:
vendorStatus == "Not affected"
vendorStatus == "Not affected" ||
vendorStatus == "Patched"
? "checkmark.shield.fill"
: vendorStatus == "Vulnerable"
? "exclamationmark.shield.fill"
: "shield.lefthalf.filled"
)
@@ -199,9 +202,10 @@ struct ContentView: View {
.font(.subheadline.bold())
}
.foregroundStyle(
vendorStatus == "Not affected"
vendorStatus == "Not affected" ||
vendorStatus == "Patched"
? Color.green
: vendorStatus == "Ubuntu fix needed"
: vendorStatus == "Vulnerable"
? Color.red
: Color.orange
)
+306 -79
View File
@@ -210,9 +210,7 @@ extension VulnerabilityService {
private static func installedPackages(
_ lines: [String]
) -> [InstalledPackage] {
lines.compactMap { line in
let fields = line.split(
separator: "|",
omittingEmptySubsequences: false
@@ -231,6 +229,145 @@ extension VulnerabilityService {
}
}
// Debian package-version comparison.
//
// Handles epochs, upstream versions, Debian revisions,
// numeric segments and special '~' ordering.
private static func compareDebianVersions(
_ left: String,
_ right: String
) -> Int {
func components(
_ version: String
) -> (Int, String, String) {
var remaining = version
var epoch = 0
if let colon = remaining.firstIndex(of: ":") {
epoch = Int(remaining[..<colon]) ?? 0
remaining = String(
remaining[remaining.index(after: colon)...]
)
}
if let dash = remaining.lastIndex(of: "-") {
return (
epoch,
String(remaining[..<dash]),
String(
remaining[
remaining.index(after: dash)...
]
)
)
}
return (epoch, remaining, "0")
}
func order(_ character: UInt8?) -> Int {
guard let character else { return 0 }
if character == 126 { return -1 }
if (65...90).contains(character) ||
(97...122).contains(character) {
return Int(character)
}
return Int(character) + 256
}
func comparePart(
_ lhs: String,
_ rhs: String
) -> Int {
let a = Array(lhs.utf8)
let b = Array(rhs.utf8)
var i = 0
var j = 0
func isDigit(_ value: UInt8) -> Bool {
(48...57).contains(value)
}
while i < a.count || j < b.count {
while (i < a.count && !isDigit(a[i])) ||
(j < b.count && !isDigit(b[j])) {
let leftCharacter: UInt8? =
i < a.count && !isDigit(a[i])
? a[i] : nil
let rightCharacter: UInt8? =
j < b.count && !isDigit(b[j])
? b[j] : nil
let difference =
order(leftCharacter) -
order(rightCharacter)
if difference != 0 {
return difference < 0 ? -1 : 1
}
if leftCharacter != nil { i += 1 }
if rightCharacter != nil { j += 1 }
}
while i < a.count && a[i] == 48 { i += 1 }
while j < b.count && b[j] == 48 { j += 1 }
let startI = i
let startJ = j
while i < a.count && isDigit(a[i]) {
i += 1
}
while j < b.count && isDigit(b[j]) {
j += 1
}
let lengthI = i - startI
let lengthJ = j - startJ
if lengthI != lengthJ {
return lengthI < lengthJ ? -1 : 1
}
for offset in 0..<lengthI {
if a[startI + offset] != b[startJ + offset] {
return a[startI + offset] <
b[startJ + offset] ? -1 : 1
}
}
}
return 0
}
let a = components(left)
let b = components(right)
if a.0 != b.0 {
return a.0 < b.0 ? -1 : 1
}
let upstream = comparePart(a.1, b.1)
if upstream != 0 {
return upstream
}
return comparePart(a.2, b.2)
}
static func assessUbuntu(
_ assessments: [CVEServerAssessment],
report: AuditResult
@@ -251,40 +388,41 @@ extension VulnerabilityService {
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."
"No installed PHP source packages collected."
return item
}
}
var output: [CVEServerAssessment] = []
// Group binary packages by their common source package.
let grouped = Dictionary(
grouping: installed,
by: { $0.source }
)
// Cache repeated CVEs across different PHP runtimes.
var output: [CVEServerAssessment] = []
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: "")
// Do not use host Ubuntu packages to certify Docker.
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."
"Runtime not confirmed as host PHP. " +
"Docker security requires separate verification."
output.append(item)
continue
}
@@ -293,18 +431,16 @@ extension VulnerabilityService {
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
if let cached = cache[original.cveID] {
advisory = cached
} else {
let address =
@@ -316,9 +452,7 @@ extension VulnerabilityService {
}
var request = URLRequest(url: url)
request.timeoutInterval = 20
request.setValue(
"application/json",
forHTTPHeaderField: "Accept"
@@ -329,7 +463,6 @@ extension VulnerabilityService {
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw ServiceError.invalidResponse
}
@@ -341,13 +474,21 @@ extension VulnerabilityService {
cache[original.cveID] = advisory
}
var matches: [(String, String)] = []
var findings: [
(
source: String,
status: String,
evidence: String,
fixed: String?,
installed: String?
)
] = []
for package in installed {
for (sourceName, packages) in grouped {
guard let advisoryPackage =
advisory.packages?.first(where: {
$0.name == package.source
$0.name == sourceName
}) else {
continue
}
@@ -359,91 +500,177 @@ extension VulnerabilityService {
report.ubuntuCodename
}
for status in statuses {
guard statuses.count == 1,
let status = statuses.first else {
continue
}
let state = status.status.lowercased()
let installedVersions = Set(
packages.map(\.sourceVersion)
)
let description =
status.description ?? ""
guard installedVersions.count == 1,
let installedVersion =
installedVersions.first else {
let evidence =
"\(package.binary) \(package.version), " +
"source \(package.source) " +
"\(package.sourceVersion), " +
"Ubuntu \(report.ubuntuCodename): " +
"\(status.status)" +
(
description.isEmpty
? ""
: " (\(description))"
)
findings.append((
sourceName,
"Unverified",
"Conflicting installed source revisions.",
nil,
nil
))
continue
}
switch state {
let state = status.status.lowercased()
case "not-affected":
matches.append((
"Not affected",
evidence
))
let fixedVersion = (
status.description ?? ""
).trimmingCharacters(
in: .whitespacesAndNewlines
)
case "released", "fixed":
matches.append((
"Ubuntu fix published",
evidence +
". Installed revision has not " +
"been compared with the fixed revision."
))
let packageNames = packages
.map(\.binary)
.sorted()
.joined(separator: ", ")
case "needed":
matches.append((
"Ubuntu fix needed",
evidence
))
let prefix =
"Source \(sourceName); " +
"installed \(installedVersion); " +
"packages: \(packageNames). "
case "dne":
matches.append((
"Package not in Ubuntu release",
evidence +
". Check third-party repositories."
))
switch state {
default:
matches.append((
case "not-affected":
findings.append((
sourceName,
"Not affected",
prefix +
"Ubuntu confirms this source package " +
"is not affected.",
nil,
installedVersion
))
case "released", "fixed":
guard !fixedVersion.isEmpty,
!fixedVersion.contains(" "),
fixedVersion.range(
of: #"^[0-9A-Za-z.+:~\-]+$"#,
options: .regularExpression
) != nil else {
findings.append((
sourceName,
"Unverified",
evidence
prefix +
"Ubuntu fix published, but fixed " +
"revision could not be parsed.",
nil,
installedVersion
))
continue
}
let comparison = compareDebianVersions(
installedVersion,
fixedVersion
)
let patched = comparison >= 0
findings.append((
sourceName,
patched
? "Patched"
: "Vulnerable",
prefix +
"Ubuntu fixed revision: " +
fixedVersion +
(patched
? ". Installed revision meets " +
"the fixed-version threshold."
: ". Installed revision is older " +
"than the fixed version."),
fixedVersion,
installedVersion
))
case "needed":
findings.append((
sourceName,
"Vulnerable",
prefix +
"Ubuntu reports that a fix is needed.",
nil,
installedVersion
))
case "dne":
findings.append((
sourceName,
"Unverified",
prefix +
"Ubuntu reports package not in release. " +
"Check repository provenance.",
nil,
installedVersion
))
default:
findings.append((
sourceName,
"Unverified",
prefix +
"Ubuntu status: \(status.status).",
nil,
installedVersion
))
}
}
if matches.count == 1 {
item.vendorStatus = matches[0].0
item.vendorEvidence = matches[0].1
} else if matches.isEmpty {
if findings.isEmpty {
item.vendorStatus = "Unverified"
item.vendorEvidence =
"No matching Ubuntu security advisory " +
"for installed PHP source packages."
"No matching Ubuntu source-package advisory."
} else {
// Different PHP packages can have different
// security states. Avoid a false patched verdict.
item.vendorStatus = "Multiple package states"
// An affected source takes precedence.
// Unknown sources prevent a clean verdict.
let statuses = findings.map(\.status)
item.vendorEvidence = matches
.map { $0.1 }
.joined(separator: "; ")
if statuses.contains("Vulnerable") {
item.vendorStatus = "Vulnerable"
} else if statuses.contains("Unverified") {
item.vendorStatus = "Unverified"
} else if statuses.allSatisfy({
$0 == "Not affected"
}) {
item.vendorStatus = "Not affected"
} else {
item.vendorStatus = "Patched"
}
item.vendorEvidence = findings
.map(\.evidence)
.joined(separator: "\n")
if findings.count == 1,
let finding = findings.first {
item.vendorFixedVersion = finding.fixed
item.vendorInstalledVersion =
finding.installed
}
}
} catch {
item.vendorStatus = "Unverified"
item.vendorEvidence =
"Ubuntu advisory lookup failed: " +
error.localizedDescription
@@ -0,0 +1,903 @@
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 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.")
.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)
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))
}
}
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,457 @@
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? }
}
// 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
}
}