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