Updates to scanner with CVE saving and auto update
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor final class AuditStore: ObservableObject {
|
||||
@Published var servers: [Server] = [] { didSet { saveServers() } }
|
||||
@Published var results: [AuditResult] = [] { didSet { saveResults() } }
|
||||
@Published var scanning: Set<UUID> = []
|
||||
@Published var scanProgressVisible = false
|
||||
@Published var scanStartedAt = Date()
|
||||
@Published var scanServerName = ""
|
||||
@Published var scanServerHost = ""
|
||||
|
||||
@Published var lastError: String?
|
||||
@Published var discoveredCVEs: [CVERecord] = [] { didSet { saveCVECache() } }
|
||||
@Published var cveLastChecked: Date? { didSet { saveCVECache() } }
|
||||
@Published var cveError: String?
|
||||
@Published var fetchingCVEs = false
|
||||
|
||||
private var folder: URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
let path = base.appendingPathComponent("PHPGuard", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
return path
|
||||
}
|
||||
init() {
|
||||
let defaults = UserDefaults.standard
|
||||
autoRefreshCVEs = defaults.object(forKey: "phpguard.autoRefreshCVEs") as? Bool ?? true
|
||||
showTechnicalDetails = defaults.bool(forKey: "phpguard.showTechnicalDetails")
|
||||
confirmBeforeClearing = defaults.object(forKey: "phpguard.confirmClear") as? Bool ?? true
|
||||
preferredReportAccent = defaults.string(forKey: "phpguard.reportAccent") ?? "Blue"
|
||||
retainHistoryLimit = defaults.object(forKey: "phpguard.historyLimit") as? Int ?? 250
|
||||
servers = load("servers.json") ?? []
|
||||
results = (load("results.json") ?? []).map { Self.normalized($0) }
|
||||
if let cache: CVECache = load("cve-cache.json") {
|
||||
discoveredCVEs = cache.records
|
||||
cveLastChecked = cache.fetchedAt
|
||||
}
|
||||
}
|
||||
private static func normalized(_ source: AuditResult) -> AuditResult {
|
||||
var result = source
|
||||
result.dockerPHP = source.dockerPHP.filter {
|
||||
!$0.contains("OCI runtime exec failed") && !$0.contains("executable file not found")
|
||||
}
|
||||
result.findings.removeAll {
|
||||
$0.title == "PHP runtime in Docker" &&
|
||||
($0.detail.contains("OCI runtime exec failed") || $0.detail.contains("executable file not found"))
|
||||
}
|
||||
if result.dockerPHP.count != source.dockerPHP.count {
|
||||
result.dockerAuditStatus = "Legacy scan: missing PHP executables excluded; rescan for structured Docker coverage"
|
||||
}
|
||||
return result
|
||||
}
|
||||
func importReport() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowedContentTypes = [.json]
|
||||
panel.allowsMultipleSelection = false
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let report = Self.normalized(try decoder.decode(AuditResult.self, from: Data(contentsOf: url)))
|
||||
guard !results.contains(where: { $0.id == report.id }) else {
|
||||
lastError = "This report has already been imported."
|
||||
return
|
||||
}
|
||||
results.insert(report, at: 0)
|
||||
} catch { lastError = "Could not import JSON report: \(error.localizedDescription)" }
|
||||
}
|
||||
private func load<T: Decodable>(_ name: String) -> T? {
|
||||
guard let data = try? Data(contentsOf: folder.appendingPathComponent(name)) else { return nil }
|
||||
return try? JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
private func save<T: Encodable>(_ value: T, as name: String) {
|
||||
guard let data = try? JSONEncoder().encode(value) else { return }
|
||||
try? data.write(to: folder.appendingPathComponent(name), options: .atomic)
|
||||
}
|
||||
private struct CVECache: Codable {
|
||||
let records: [CVERecord]
|
||||
let fetchedAt: Date?
|
||||
}
|
||||
private func saveCVECache() {
|
||||
// Do not overwrite a previously successful download with an empty response.
|
||||
guard !discoveredCVEs.isEmpty else { return }
|
||||
save(CVECache(records: discoveredCVEs, fetchedAt: cveLastChecked), as: "cve-cache.json")
|
||||
}
|
||||
private func saveServers() { save(servers, as: "servers.json") }
|
||||
private func saveResults() { save(Array(results.prefix(max(1, retainHistoryLimit))), as: "results.json") }
|
||||
func latest(for server: Server) -> AuditResult? { results.first { $0.serverID == server.id } }
|
||||
func scan(_ server: Server) {
|
||||
guard !scanning.contains(server.id) else { return }
|
||||
if scanning.isEmpty {
|
||||
scanStartedAt = Date()
|
||||
}
|
||||
scanServerName = server.name
|
||||
scanServerHost = server.host
|
||||
scanProgressVisible = true
|
||||
scanning.insert(server.id)
|
||||
Task {
|
||||
defer {
|
||||
scanning.remove(server.id)
|
||||
if scanning.isEmpty {
|
||||
scanProgressVisible = false
|
||||
}
|
||||
}
|
||||
do { results.insert(try await SSHScanner.scan(server), at: 0) }
|
||||
catch {
|
||||
var failed = AuditResult(serverID: server.id, serverName: server.name)
|
||||
failed.error = error.localizedDescription
|
||||
results.insert(failed, at: 0)
|
||||
lastError = "\(server.name): \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
func scanAll() { servers.forEach { scan($0) } }
|
||||
func refreshCVEsOnLaunch() {
|
||||
// Preserve the local cache while checking NVD for a newer result set.
|
||||
if autoRefreshCVEs { refreshCVEs() }
|
||||
}
|
||||
func refreshCVEs() {
|
||||
guard !fetchingCVEs else { return }
|
||||
fetchingCVEs = true
|
||||
cveError = nil
|
||||
Task {
|
||||
defer { fetchingCVEs = false }
|
||||
do {
|
||||
let downloaded = try await VulnerabilityService.discover()
|
||||
guard !downloaded.isEmpty else {
|
||||
cveError = "NVD returned no records; keeping the previous saved results."
|
||||
return
|
||||
}
|
||||
discoveredCVEs = downloaded
|
||||
cveLastChecked = Date()
|
||||
} catch { cveError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
@Published var showTechnicalDetails = false
|
||||
@Published var confirmBeforeClearing = true
|
||||
@Published var preferredReportAccent = "Blue"
|
||||
@Published var autoRefreshCVEs = true
|
||||
@Published var retainHistoryLimit = 250
|
||||
|
||||
func clearHistory() { results.removeAll() }
|
||||
func removeHistory(_ offsets: IndexSet) { results.remove(atOffsets: offsets) }
|
||||
func exportPDF(_ result: AuditResult) {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.pdf]
|
||||
panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).pdf"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
do { try ReportPDF.write(result, to: url, accent: preferredReportAccent) }
|
||||
catch { lastError = "PDF export failed: \(error.localizedDescription)" }
|
||||
}
|
||||
func export(_ result: AuditResult) {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.json]
|
||||
panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).json"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
try encoder.encode(result).write(to: url)
|
||||
} catch { lastError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var store: AuditStore
|
||||
@State private var selection: UUID?
|
||||
@State private var showingEditor = false
|
||||
@State private var editing: Server?
|
||||
@State private var showClearHistoryConfirmation = false
|
||||
@AppStorage("phpguard.confirmClear") private var confirmClearHistory = true
|
||||
private func addServer() { editing = nil; showingEditor = true }
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selection) {
|
||||
Section("Servers") {
|
||||
ForEach(store.servers) { server in
|
||||
Label {
|
||||
HStack {
|
||||
Text(server.name)
|
||||
Spacer()
|
||||
if store.scanning.contains(server.id) { ProgressView().controlSize(.small) }
|
||||
else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) }
|
||||
}
|
||||
} icon: { Image(systemName: "server.rack") }
|
||||
.tag(server.id)
|
||||
.contextMenu {
|
||||
Button("Scan") { store.scan(server) }
|
||||
Button("Edit") { editing = server; showingEditor = true }
|
||||
Divider()
|
||||
Button("Remove", role: .destructive) {
|
||||
store.servers.removeAll { $0.id == server.id }
|
||||
if selection == server.id { selection = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Navigation") {
|
||||
Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!)
|
||||
Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!)
|
||||
}
|
||||
}
|
||||
.navigationTitle("PHPGuard")
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Button(action: addServer) {
|
||||
Label("Add Server", systemImage: "plus.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(12)
|
||||
.background(.bar)
|
||||
}
|
||||
} detail: {
|
||||
if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") {
|
||||
cveView
|
||||
} else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") {
|
||||
historyView
|
||||
} else if let server = store.servers.first(where: { $0.id == selection }) {
|
||||
serverView(server)
|
||||
} else { dashboard }
|
||||
}
|
||||
.sheet(isPresented: $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 upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.")
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty)
|
||||
Button("Import JSON scan") { store.importReport() }
|
||||
}
|
||||
Spacer()
|
||||
}.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
private func stat(_ title: String, _ value: String, _ icon: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label(title, systemImage: icon).foregroundStyle(.secondary)
|
||||
Text(value).font(.system(size: 36, weight: .bold))
|
||||
}.padding(20).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
private func serverView(_ server: Server) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(server.name).font(.largeTitle.bold())
|
||||
Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Edit") { editing = server; showingEditor = true }
|
||||
Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent)
|
||||
.disabled(store.scanning.contains(server.id))
|
||||
}
|
||||
if let result = store.latest(for: server) {
|
||||
if let error = result.error {
|
||||
Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
Label("Scan completed", systemImage: "checkmark.shield.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(result.scannedAt.formatted()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
.padding(16)
|
||||
.background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14))
|
||||
Text("OS: \(result.os) • Host: \(result.hostname)")
|
||||
inventoryCards(
|
||||
php: result.phpVersions,
|
||||
fpm: result.fpmServices,
|
||||
nginx: result.nginxUpstreams,
|
||||
packages: result.packageVersions
|
||||
)
|
||||
dataSection("Available PHP package upgrades", result.availableUpdates)
|
||||
dataSection("APT package origins and candidate revisions", result.packageOrigins)
|
||||
Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary)
|
||||
dataSection("PHP detected in running Docker containers", result.dockerPHP)
|
||||
Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary)
|
||||
dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage)
|
||||
dataSection("Security triage — follow-up actions", result.securityTriage)
|
||||
HStack {
|
||||
Text("Security findings").font(.title2.bold())
|
||||
Spacer()
|
||||
Text("\(result.findings.count) total").foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(result.findings) { finding in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill")
|
||||
Text(finding.severity.uppercased()).font(.caption.bold())
|
||||
Text(finding.title).font(.headline)
|
||||
}
|
||||
.foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue))
|
||||
Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled)
|
||||
}.padding(12).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
}
|
||||
} else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) }
|
||||
}.padding(28).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
inventoryCard(
|
||||
title: "Installed Packages",
|
||||
subtitle: "PHP package inventory",
|
||||
icon: "shippingbox.fill",
|
||||
tint: .orange,
|
||||
count: values.count
|
||||
) {
|
||||
if values.isEmpty {
|
||||
inventoryEmptyState("No PHP packages detected")
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) {
|
||||
index, line in
|
||||
|
||||
let parts = line.split(
|
||||
separator: " ",
|
||||
maxSplits: 1,
|
||||
omittingEmptySubsequences: true
|
||||
)
|
||||
|
||||
let name = parts.first.map(String.init) ?? line
|
||||
let version = parts.count > 1
|
||||
? String(parts[1])
|
||||
: "Version not reported"
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(version)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(
|
||||
horizontal: false,
|
||||
vertical: true
|
||||
)
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(.vertical, 9)
|
||||
|
||||
if index < values.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func inventoryEmptyState(_ message: String) -> some View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "minus.circle")
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private func dataSection(_ title: String, _ values: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22)
|
||||
Text(title).font(.headline)
|
||||
Spacer()
|
||||
Text("\(values.count)").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if values.isEmpty { Text("None detected").foregroundStyle(.secondary) }
|
||||
else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } }
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
private var cveView: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("PHP CVE research").font(.largeTitle.bold())
|
||||
Spacer()
|
||||
if store.fetchingCVEs { ProgressView() }
|
||||
Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs)
|
||||
}
|
||||
Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.")
|
||||
.foregroundStyle(.secondary)
|
||||
if let date = store.cveLastChecked {
|
||||
Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("No cached CVE research yet · Automatically fetched at launch")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = store.cveError { Text(error).foregroundStyle(.red) }
|
||||
List(store.discoveredCVEs) { cve in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Link(cve.id, destination: URL(string: cve.referenceURL)!)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(cve.severity + (cve.score.map { " (\($0))" } ?? ""))
|
||||
Text(String(cve.published.prefix(10))).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(cve.description).lineLimit(4).textSelection(.enabled)
|
||||
}.padding(.vertical, 5)
|
||||
}
|
||||
}.padding(24)
|
||||
}
|
||||
private var historyView: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Scan history").font(.largeTitle.bold())
|
||||
Text("\(store.results.count) saved scans").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Clear History", role: .destructive) {
|
||||
if confirmClearHistory { showClearHistoryConfirmation = true }
|
||||
else { store.clearHistory() }
|
||||
}.disabled(store.results.isEmpty)
|
||||
}.padding(20)
|
||||
List {
|
||||
ForEach(store.results) { result in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(result.error == nil ? .green : .red)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(result.serverName).font(.headline)
|
||||
Text(result.scannedAt.formatted()).foregroundStyle(.secondary)
|
||||
if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) }
|
||||
else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations")
|
||||
.font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
Spacer()
|
||||
Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil)
|
||||
Button("JSON") { store.export(result) }.disabled(result.error != nil)
|
||||
}.padding(.vertical, 5)
|
||||
}.onDelete(perform: store.removeHistory)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) {
|
||||
Button("Clear History", role: .destructive) { store.clearHistory() }
|
||||
} message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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("Scanning")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text("Collecting PHP, package, Docker and web-server evidence")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
}
|
||||
|
||||
ProgressView()
|
||||
.progressViewStyle(.linear)
|
||||
.tint(Color.accentColor)
|
||||
|
||||
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,107 @@
|
||||
import Foundation
|
||||
|
||||
struct Server: Codable, Identifiable, Hashable {
|
||||
var id = UUID()
|
||||
var name: String
|
||||
var host: String
|
||||
var user: String = "mag"
|
||||
var port: Int = 22
|
||||
var identityFile: String = ""
|
||||
}
|
||||
|
||||
struct Finding: Codable, Identifiable {
|
||||
var id = UUID()
|
||||
var severity: String
|
||||
var title: String
|
||||
var detail: String
|
||||
}
|
||||
|
||||
struct AuditResult: Codable, Identifiable {
|
||||
var id = UUID()
|
||||
var serverID: UUID
|
||||
var serverName: String
|
||||
var scannedAt = Date()
|
||||
var hostname = ""
|
||||
var os = ""
|
||||
var phpVersions: [String] = []
|
||||
var fpmServices: [String] = []
|
||||
var nginxUpstreams: [String] = []
|
||||
var packageVersions: [String] = []
|
||||
var availableUpdates: [String] = []
|
||||
var packageOrigins: [String] = []
|
||||
var dockerPHP: [String] = []
|
||||
var dockerCoverage: [String] = []
|
||||
var securityTriage: [String] = []
|
||||
var packageAuditStatus = "Not checked"
|
||||
var dockerAuditStatus = "Not checked"
|
||||
var findings: [Finding] = []
|
||||
var recentPHPFiles: [String] = []
|
||||
var webErrorSignals: [String] = []
|
||||
var investigationStatus = "Not checked"
|
||||
var rawOutput = ""
|
||||
var error: String? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, availableUpdates, packageOrigins, dockerPHP, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, rawOutput, error, id }
|
||||
init(serverID: UUID, serverName: String) { self.serverID = serverID; self.serverName = serverName }
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
serverID = try container.decode(UUID.self, forKey: .serverID)
|
||||
serverName = try container.decode(String.self, forKey: .serverName)
|
||||
scannedAt = try container.decodeIfPresent(Date.self, forKey: .scannedAt) ?? Date()
|
||||
hostname = try container.decodeIfPresent(String.self, forKey: .hostname) ?? ""
|
||||
os = try container.decodeIfPresent(String.self, forKey: .os) ?? ""
|
||||
phpVersions = try container.decodeIfPresent([String].self, forKey: .phpVersions) ?? []
|
||||
fpmServices = try container.decodeIfPresent([String].self, forKey: .fpmServices) ?? []
|
||||
nginxUpstreams = try container.decodeIfPresent([String].self, forKey: .nginxUpstreams) ?? []
|
||||
packageVersions = try container.decodeIfPresent([String].self, forKey: .packageVersions) ?? []
|
||||
availableUpdates = try container.decodeIfPresent([String].self, forKey: .availableUpdates) ?? []
|
||||
packageOrigins = try container.decodeIfPresent([String].self, forKey: .packageOrigins) ?? []
|
||||
dockerPHP = try container.decodeIfPresent([String].self, forKey: .dockerPHP) ?? []
|
||||
dockerCoverage = try container.decodeIfPresent([String].self, forKey: .dockerCoverage) ?? []
|
||||
securityTriage = try container.decodeIfPresent([String].self, forKey: .securityTriage) ?? []
|
||||
packageAuditStatus = try container.decodeIfPresent(String.self, forKey: .packageAuditStatus) ?? "Not checked"
|
||||
dockerAuditStatus = try container.decodeIfPresent(String.self, forKey: .dockerAuditStatus) ?? "Not checked"
|
||||
findings = try container.decodeIfPresent([Finding].self, forKey: .findings) ?? []
|
||||
recentPHPFiles = try container.decodeIfPresent([String].self, forKey: .recentPHPFiles) ?? []
|
||||
webErrorSignals = try container.decodeIfPresent([String].self, forKey: .webErrorSignals) ?? []
|
||||
investigationStatus = try container.decodeIfPresent(String.self, forKey: .investigationStatus) ?? "Not checked"
|
||||
rawOutput = try container.decodeIfPresent(String.self, forKey: .rawOutput) ?? ""
|
||||
error = try container.decodeIfPresent(String.self, forKey: .error)
|
||||
id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
|
||||
}
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(serverID, forKey: .serverID)
|
||||
try container.encode(serverName, forKey: .serverName)
|
||||
try container.encode(scannedAt, forKey: .scannedAt)
|
||||
try container.encode(hostname, forKey: .hostname)
|
||||
try container.encode(os, forKey: .os)
|
||||
try container.encode(phpVersions, forKey: .phpVersions)
|
||||
try container.encode(fpmServices, forKey: .fpmServices)
|
||||
try container.encode(nginxUpstreams, forKey: .nginxUpstreams)
|
||||
try container.encode(packageVersions, forKey: .packageVersions)
|
||||
try container.encode(availableUpdates, forKey: .availableUpdates)
|
||||
try container.encode(packageOrigins, forKey: .packageOrigins)
|
||||
try container.encode(dockerPHP, forKey: .dockerPHP)
|
||||
try container.encode(dockerCoverage, forKey: .dockerCoverage)
|
||||
try container.encode(securityTriage, forKey: .securityTriage)
|
||||
try container.encode(packageAuditStatus, forKey: .packageAuditStatus)
|
||||
try container.encode(dockerAuditStatus, forKey: .dockerAuditStatus)
|
||||
try container.encode(findings, forKey: .findings)
|
||||
try container.encode(recentPHPFiles, forKey: .recentPHPFiles)
|
||||
try container.encode(webErrorSignals, forKey: .webErrorSignals)
|
||||
try container.encode(investigationStatus, forKey: .investigationStatus)
|
||||
try container.encode(rawOutput, forKey: .rawOutput)
|
||||
try container.encodeIfPresent(error, forKey: .error)
|
||||
try container.encode(id, forKey: .id)
|
||||
}
|
||||
}
|
||||
|
||||
struct CVERecord: Codable, Identifiable {
|
||||
var id: String
|
||||
var description: String
|
||||
var published: String
|
||||
var severity: String
|
||||
var score: Double?
|
||||
var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + id }
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
|
||||
@MainActor
|
||||
enum ReportPDF {
|
||||
static func write(_ report: AuditResult, to url: URL, accent: String) throws {
|
||||
try ProfessionalReport(report: report, accent: accent).write(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ProfessionalReport {
|
||||
private let report: AuditResult
|
||||
private let w: CGFloat = 595
|
||||
private let h: CGFloat = 842
|
||||
private let left: CGFloat = 46
|
||||
private let right: CGFloat = 549
|
||||
private let bottom: CGFloat = 784
|
||||
private let navy = NSColor(calibratedRed: 0.065, green: 0.105, blue: 0.175, alpha: 1)
|
||||
private let ink = NSColor(calibratedRed: 0.13, green: 0.18, blue: 0.26, alpha: 1)
|
||||
private let grey = NSColor(calibratedRed: 0.40, green: 0.45, blue: 0.52, alpha: 1)
|
||||
private let light = NSColor(calibratedRed: 0.955, green: 0.966, blue: 0.98, alpha: 1)
|
||||
private let accent: NSColor
|
||||
private var context: CGContext!
|
||||
private var y: CGFloat = 0
|
||||
private var page = 0
|
||||
private var isOpen = false
|
||||
|
||||
init(report: AuditResult, accent: String) {
|
||||
self.report = report
|
||||
switch accent {
|
||||
case "Teal": self.accent = NSColor(calibratedRed: 0.0, green: 0.58, blue: 0.56, alpha: 1)
|
||||
case "Purple": self.accent = NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.77, alpha: 1)
|
||||
default: self.accent = NSColor(calibratedRed: 0.16, green: 0.40, blue: 0.80, alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var genuineFindings: [Finding] {
|
||||
report.findings.filter { finding in
|
||||
let title = finding.title.lowercased()
|
||||
let detail = finding.detail.lowercased()
|
||||
// A failed attempt to execute PHP is not evidence that PHP is installed.
|
||||
return !(title.contains("container php runtime detected") &&
|
||||
(detail.contains("executable file not found") ||
|
||||
detail.contains("oci runtime exec failed") ||
|
||||
detail.contains("php: not found")))
|
||||
}
|
||||
}
|
||||
|
||||
private var containerResults: [(name: String, state: String, detail: String)] {
|
||||
report.dockerPHP.map { entry in
|
||||
let lower = entry.lowercased()
|
||||
let state: String
|
||||
if lower.contains("executable file not found") || lower.contains("oci runtime exec failed") || lower.contains("php: not found") {
|
||||
state = "PHP CLI not detected"
|
||||
} else if lower.contains("php ") || lower.contains("php/") {
|
||||
state = "PHP reported"
|
||||
} else {
|
||||
state = "Unverified"
|
||||
}
|
||||
let name = entry.components(separatedBy: " [").first ?? entry
|
||||
return (name, state, entry)
|
||||
}
|
||||
}
|
||||
|
||||
private func withCanvas(_ draw: () -> Void) {
|
||||
context.saveGState()
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
context.translateBy(x: 0, y: h)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
|
||||
draw()
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
private func fill(_ rect: CGRect, _ color: NSColor, radius: CGFloat = 0) {
|
||||
color.setFill()
|
||||
NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill()
|
||||
}
|
||||
|
||||
private func attributed(_ string: String, size: CGFloat, bold: Bool = false, color: NSColor? = nil, breakMode: NSLineBreakMode = .byCharWrapping) -> NSAttributedString {
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.lineSpacing = 2.5
|
||||
paragraph.lineBreakMode = breakMode
|
||||
return NSAttributedString(string: string, attributes: [
|
||||
.font: NSFont.systemFont(ofSize: size, weight: bold ? .semibold : .regular),
|
||||
.foregroundColor: color ?? ink,
|
||||
.paragraphStyle: paragraph
|
||||
])
|
||||
}
|
||||
|
||||
private func height(_ string: String, width: CGFloat, size: CGFloat, bold: Bool = false) -> CGFloat {
|
||||
let box = attributed(string, size: size, bold: bold).boundingRect(
|
||||
with: CGSize(width: width, height: 20000),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading]
|
||||
)
|
||||
return max(size + 4, ceil(box.height) + 3)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func text(_ string: String, x: CGFloat, y: CGFloat, width: CGFloat, size: CGFloat = 10, bold: Bool = false, color: NSColor? = nil) -> CGFloat {
|
||||
let lineHeight = height(string, width: width, size: size, bold: bold)
|
||||
attributed(string, size: size, bold: bold, color: color).draw(
|
||||
with: CGRect(x: x, y: y, width: width, height: lineHeight + 1),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading]
|
||||
)
|
||||
return lineHeight
|
||||
}
|
||||
|
||||
private func begin(_ chapter: String) {
|
||||
context.beginPDFPage(nil)
|
||||
isOpen = true
|
||||
page += 1
|
||||
y = 106
|
||||
withCanvas {
|
||||
fill(CGRect(x: 0, y: 0, width: w, height: 7), accent)
|
||||
text("PHPGUARD", x: left, y: 28, width: 190, size: 15, bold: true, color: navy)
|
||||
text(chapter.uppercased(), x: 292, y: 33, width: 257, size: 8, bold: true, color: grey)
|
||||
fill(CGRect(x: left, y: 72, width: right-left, height: 1), NSColor(calibratedWhite: 0.88, alpha: 1))
|
||||
}
|
||||
}
|
||||
|
||||
private func end() {
|
||||
guard isOpen else { return }
|
||||
withCanvas {
|
||||
fill(CGRect(x: left, y: 790, width: right-left, height: 1), NSColor(calibratedWhite: 0.88, alpha: 1))
|
||||
text("PHPGuard | \(report.serverName) | Vendor verification required", x: left, y: 803, width: 395, size: 7.5, color: grey)
|
||||
text("PAGE \(page)", x: 474, y: 803, width: 75, size: 8, bold: true, color: grey)
|
||||
}
|
||||
context.endPDFPage()
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
private func space(_ required: CGFloat, chapter: String) {
|
||||
if y + required > bottom {
|
||||
end()
|
||||
begin(chapter)
|
||||
}
|
||||
}
|
||||
|
||||
private func title(_ string: String, subtitle: String, chapter: String) {
|
||||
space(85, chapter: chapter)
|
||||
withCanvas {
|
||||
fill(CGRect(x: left, y: y+3, width: 4, height: 26), accent, radius: 2)
|
||||
text(string, x: left+15, y: y, width: right-left-15, size: 20, bold: true, color: navy)
|
||||
text(subtitle, x: left+15, y: y+34, width: right-left-15, size: 9, color: grey)
|
||||
}
|
||||
y += 77
|
||||
}
|
||||
|
||||
private func section(_ name: String, chapter: String) {
|
||||
space(36, chapter: chapter)
|
||||
withCanvas { text(name.uppercased(), x: left, y: y, width: right-left, size: 10, bold: true, color: accent) }
|
||||
y += 26
|
||||
}
|
||||
|
||||
private func paragraph(_ string: String, chapter: String, size: CGFloat = 9.5) {
|
||||
let needed = height(string, width: right-left, size: size) + 11
|
||||
space(needed, chapter: chapter)
|
||||
withCanvas { text(string, x: left, y: y, width: right-left, size: size) }
|
||||
y += needed
|
||||
}
|
||||
|
||||
private func tableRow(_ label: String, _ value: String, chapter: String, alternate: Bool = false) {
|
||||
let labelWidth: CGFloat = 145
|
||||
let valueWidth: CGFloat = right-left-labelWidth-32
|
||||
let rowHeight = max(height(label, width: labelWidth, size: 9, bold: true), height(value, width: valueWidth, size: 9)) + 19
|
||||
space(rowHeight+4, chapter: chapter)
|
||||
withCanvas {
|
||||
fill(CGRect(x: left, y: y, width: right-left, height: rowHeight), alternate ? light : NSColor.white, radius: 4)
|
||||
text(label, x: left+12, y: y+9, width: labelWidth, size: 9, bold: true, color: grey)
|
||||
text(value, x: left+labelWidth+17, y: y+9, width: valueWidth, size: 9)
|
||||
}
|
||||
y += rowHeight+4
|
||||
}
|
||||
|
||||
private func items(_ values: [String], chapter: String, empty: String = "No entries recorded") {
|
||||
if values.isEmpty {
|
||||
paragraph(empty, chapter: chapter)
|
||||
return
|
||||
}
|
||||
for (index, value) in values.enumerated() {
|
||||
let needed = height(value, width: right-left-32, size: 8.7) + 18
|
||||
space(needed+3, chapter: chapter)
|
||||
withCanvas {
|
||||
if index.isMultiple(of: 2) { fill(CGRect(x: left, y: y, width: right-left, height: needed), light, radius: 4) }
|
||||
fill(CGRect(x: left+10, y: y+12, width: 4, height: 4), accent, radius: 2)
|
||||
text(value, x: left+24, y: y+8, width: right-left-35, size: 8.7)
|
||||
}
|
||||
y += needed+3
|
||||
}
|
||||
y += 8
|
||||
}
|
||||
|
||||
private func severityColor(_ value: String) -> NSColor {
|
||||
switch value.lowercased() {
|
||||
case "critical", "high": return .systemRed
|
||||
case "medium", "warning": return .systemOrange
|
||||
case "low": return NSColor(calibratedRed: 0.57, green: 0.43, blue: 0.07, alpha: 1)
|
||||
default: return accent
|
||||
}
|
||||
}
|
||||
|
||||
private func finding(_ value: Finding) {
|
||||
let titleH = height(value.title, width: 350, size: 11, bold: true)
|
||||
let detailH = height(value.detail, width: right-left-36, size: 9)
|
||||
let cardH = titleH+detailH+53
|
||||
space(cardH+12, chapter: "Security findings")
|
||||
withCanvas {
|
||||
fill(CGRect(x: left, y: y, width: right-left, height: cardH), light, radius: 8)
|
||||
fill(CGRect(x: left, y: y, width: 5, height: cardH), severityColor(value.severity), radius: 2)
|
||||
text(value.title, x: left+18, y: y+15, width: 350, size: 11, bold: true, color: navy)
|
||||
text(value.severity.uppercased(), x: right-106, y: y+17, width: 92, size: 9, bold: true, color: severityColor(value.severity))
|
||||
text(value.detail, x: left+18, y: y+25+titleH, width: right-left-36, size: 9)
|
||||
}
|
||||
y += cardH+12
|
||||
}
|
||||
|
||||
private func cover() {
|
||||
context.beginPDFPage(nil)
|
||||
page += 1
|
||||
withCanvas {
|
||||
fill(CGRect(x: 0, y: 0, width: w, height: h), navy)
|
||||
fill(CGRect(x: 0, y: 0, width: w, height: 10), accent)
|
||||
text("PHPGUARD", x: left, y: 60, width: 430, size: 25, bold: true, color: .white)
|
||||
text("INFRASTRUCTURE SECURITY ASSESSMENT", x: left, y: 103, width: 470, size: 10, bold: true, color: accent)
|
||||
fill(CGRect(x: left, y: 180, width: 65, height: 4), accent, radius: 2)
|
||||
text("PHP SECURITY\nASSESSMENT", x: left, y: 209, width: 490, size: 38, bold: true, color: .white)
|
||||
text(report.serverName, x: left, y: 338, width: 490, size: 25, bold: true, color: .white)
|
||||
text(report.hostname, x: left, y: 379, width: 490, size: 12, color: NSColor(calibratedWhite: 0.75, alpha: 1))
|
||||
fill(CGRect(x: left, y: 430, width: right-left, height: 46), NSColor(calibratedRed: 0.16, green: 0.22, blue: 0.31, alpha: 1), radius: 7)
|
||||
text("STATUS / VERIFICATION REQUIRED", x: left+17, y: 445, width: 460, size: 12, bold: true, color: .white)
|
||||
let labels = [("\(report.phpVersions.count)", "HOST PHP RUNTIMES"), ("\(genuineFindings.count)", "REVIEW ITEMS"), ("\(report.availableUpdates.count)", "UPDATES RECORDED")]
|
||||
let boxWidth: CGFloat = 160
|
||||
for (index, item) in labels.enumerated() {
|
||||
let x = left + CGFloat(index)*171
|
||||
fill(CGRect(x: x, y: 505, width: boxWidth, height: 110), NSColor(calibratedRed: 0.13, green: 0.19, blue: 0.27, alpha: 1), radius: 8)
|
||||
text(item.0, x: x+15, y: 526, width: 125, size: 29, bold: true, color: .white)
|
||||
text(item.1, x: x+15, y: 580, width: 135, size: 8, bold: true, color: NSColor(calibratedWhite: 0.77, alpha: 1))
|
||||
}
|
||||
text("OPERATING SYSTEM", x: left, y: 665, width: 230, size: 8, bold: true, color: accent)
|
||||
text(report.os.isEmpty ? "Not reported" : report.os, x: left, y: 682, width: 490, size: 11, color: .white)
|
||||
text("SCAN DATE", x: left, y: 722, width: 230, size: 8, bold: true, color: accent)
|
||||
text(report.scannedAt.formatted(date: .abbreviated, time: .standard), x: left, y: 739, width: 490, size: 11, color: .white)
|
||||
text("Evidence-based assessment. CVE exposure requires vendor advisory verification.", x: left, y: 797, width: 490, size: 8, color: NSColor(calibratedWhite: 0.75, alpha: 1))
|
||||
}
|
||||
context.endPDFPage()
|
||||
}
|
||||
|
||||
private func summary() {
|
||||
begin("Executive summary")
|
||||
title("Executive Summary", subtitle: "Scope, key results and verification status", chapter: "Executive summary")
|
||||
paragraph("PHPGuard collected runtime, package, service and investigation evidence from \(report.serverName). These observations describe the checks completed; they are not a certification that the server is secure.", chapter: "Executive summary")
|
||||
section("Asset and assessment", chapter: "Executive summary")
|
||||
for (index, row) in [
|
||||
("Server", report.serverName), ("Hostname", report.hostname), ("Operating system", report.os),
|
||||
("Scan date", report.scannedAt.formatted(date: .abbreviated, time: .standard)),
|
||||
("Host PHP runtimes", "\(report.phpVersions.count)"),
|
||||
("Review items", "\(genuineFindings.count)"),
|
||||
("Updates recorded", "\(report.availableUpdates.count)")
|
||||
].enumerated() { tableRow(row.0, row.1.isEmpty ? "Not reported" : row.1, chapter: "Executive summary", alternate: index.isMultiple(of: 2)) }
|
||||
section("Assessment position", chapter: "Executive summary")
|
||||
paragraph("Verification required. Package version strings and available updates do not prove or disprove CVE exposure. Compare installed revisions and container images with applicable vendor advisories, including distribution backports.", chapter: "Executive summary")
|
||||
section("Coverage limitations", chapter: "Executive summary")
|
||||
tableRow("Package review", report.packageAuditStatus, chapter: "Executive summary", alternate: true)
|
||||
tableRow("Docker review", report.dockerAuditStatus, chapter: "Executive summary")
|
||||
tableRow("Investigation", report.investigationStatus, chapter: "Executive summary", alternate: true)
|
||||
end()
|
||||
}
|
||||
|
||||
private func findingsPage() {
|
||||
begin("Security findings")
|
||||
title("Findings & Observations", subtitle: "Prioritised recorded results; no inferred vulnerabilities", chapter: "Security findings")
|
||||
if genuineFindings.isEmpty { paragraph("No actionable findings were recorded by the completed checks. This does not establish an absence of vulnerabilities.", chapter: "Security findings") }
|
||||
for item in genuineFindings { finding(item) }
|
||||
section("Triage guidance", chapter: "Security findings")
|
||||
items(report.securityTriage, chapter: "Security findings", empty: "No triage guidance recorded")
|
||||
end()
|
||||
}
|
||||
|
||||
private func environment() {
|
||||
begin("PHP environment")
|
||||
title("PHP Environment", subtitle: "Host runtimes, PHP-FPM and web-server integration", chapter: "PHP environment")
|
||||
section("Host PHP installations", chapter: "PHP environment")
|
||||
items(report.phpVersions, chapter: "PHP environment")
|
||||
section("PHP-FPM service observations", chapter: "PHP environment")
|
||||
items(report.fpmServices, chapter: "PHP environment")
|
||||
section("Nginx FastCGI upstreams", chapter: "PHP environment")
|
||||
items(report.nginxUpstreams, chapter: "PHP environment")
|
||||
section("Available package updates", chapter: "PHP environment")
|
||||
items(report.availableUpdates, chapter: "PHP environment", empty: "No package updates recorded by this scan (not proof that every package is current).")
|
||||
end()
|
||||
}
|
||||
|
||||
private func containers() {
|
||||
begin("Container assessment")
|
||||
title("Container Assessment", subtitle: "PHP detection is separated from container execution errors", chapter: "Container assessment")
|
||||
paragraph("An OCI execution failure or missing PHP executable is not evidence of a PHP installation or a vulnerability. Container image security must be evaluated independently of host APT updates.", chapter: "Container assessment")
|
||||
let detected = containerResults.filter { $0.state == "PHP reported" }.count
|
||||
let notDetected = containerResults.filter { $0.state == "PHP CLI not detected" }.count
|
||||
tableRow("PHP reported", "\(detected) container(s)", chapter: "Container assessment", alternate: true)
|
||||
tableRow("PHP CLI not detected", "\(notDetected) container(s)", chapter: "Container assessment")
|
||||
tableRow("Other / unverified", "\(containerResults.count-detected-notDetected) container(s)", chapter: "Container assessment", alternate: true)
|
||||
section("Container inventory", chapter: "Container assessment")
|
||||
if containerResults.isEmpty { paragraph("No container runtime entries recorded.", chapter: "Container assessment") }
|
||||
for item in containerResults { tableRow(item.name, item.state, chapter: "Container assessment") }
|
||||
section("Recorded container evidence", chapter: "Container assessment")
|
||||
items(report.dockerPHP, chapter: "Container assessment")
|
||||
section("Coverage notes", chapter: "Container assessment")
|
||||
items(report.dockerCoverage, chapter: "Container assessment", empty: report.dockerAuditStatus)
|
||||
end()
|
||||
}
|
||||
|
||||
private func actions() {
|
||||
begin("Remediation and coverage")
|
||||
title("Recommended Actions", subtitle: "Follow-up checks based on recorded assessment limitations", chapter: "Remediation and coverage")
|
||||
let recommendations = [
|
||||
("01 Verify advisories", "Match installed PHP package revisions to the applicable Ubuntu or third-party maintainer advisories. Check backported fixes before assigning CVEs."),
|
||||
("02 Review PHP containers", "Verify detected PHP versions against their image maintainers' fixed releases. Update and redeploy images separately from host packages."),
|
||||
("03 Resolve coverage gaps", "Review incomplete or inaccessible checks, including Nginx error logs, before describing the assessment as comprehensive."),
|
||||
("04 Confirm runtime routing", "Check that configured Nginx FastCGI upstreams point to the intended running PHP-FPM services."),
|
||||
("05 Validate change activity", "Compare recently modified PHP files with deployment logs, expected maintenance and application release activity.")
|
||||
]
|
||||
for row in recommendations {
|
||||
section(row.0, chapter: "Remediation and coverage")
|
||||
paragraph(row.1, chapter: "Remediation and coverage")
|
||||
}
|
||||
section("Recorded investigation status", chapter: "Remediation and coverage")
|
||||
tableRow("Package assessment", report.packageAuditStatus, chapter: "Remediation and coverage", alternate: true)
|
||||
tableRow("Docker assessment", report.dockerAuditStatus, chapter: "Remediation and coverage")
|
||||
tableRow("Investigation", report.investigationStatus, chapter: "Remediation and coverage", alternate: true)
|
||||
end()
|
||||
}
|
||||
|
||||
private func appendix() {
|
||||
begin("Technical evidence")
|
||||
title("Technical Evidence", subtitle: "Collected source observations and package details", chapter: "Technical evidence")
|
||||
let groups: [(String, [String], String)] = [
|
||||
("Installed packages", report.packageVersions, "No packages recorded"),
|
||||
("APT package origins", report.packageOrigins, "No package origins recorded"),
|
||||
("Recent PHP file changes", report.recentPHPFiles, "No recent files recorded"),
|
||||
("Web error signals", report.webErrorSignals, "No web error signals recorded")
|
||||
]
|
||||
for group in groups {
|
||||
section(group.0, chapter: "Technical evidence")
|
||||
items(group.1, chapter: "Technical evidence", empty: group.2)
|
||||
}
|
||||
if let error = report.error, !error.isEmpty {
|
||||
section("Scan errors", chapter: "Technical evidence")
|
||||
paragraph(error, chapter: "Technical evidence")
|
||||
}
|
||||
end()
|
||||
}
|
||||
|
||||
func write(to url: URL) throws {
|
||||
let output = NSMutableData()
|
||||
guard let consumer = CGDataConsumer(data: output as CFMutableData) else {
|
||||
throw NSError(domain: "PHPGuardPDF", code: 1)
|
||||
}
|
||||
var mediaBox = CGRect(x: 0, y: 0, width: w, height: h)
|
||||
guard let pdf = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
|
||||
throw NSError(domain: "PHPGuardPDF", code: 2)
|
||||
}
|
||||
context = pdf
|
||||
cover()
|
||||
summary()
|
||||
findingsPage()
|
||||
environment()
|
||||
containers()
|
||||
actions()
|
||||
appendix()
|
||||
context.closePDF()
|
||||
try (output as Data).write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import Foundation
|
||||
|
||||
enum ScanError: LocalizedError {
|
||||
case invalidServer, commandFailed(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidServer: return "Invalid SSH host, username, port or identity file."
|
||||
case .commandFailed(let message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SSHScanner {
|
||||
// Commands are fixed and read-only. User-supplied strings are passed as SSH arguments, never shell source.
|
||||
static let script = #"""
|
||||
printf '===HOST===\n'; hostname
|
||||
printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s
|
||||
printf '===PHP===\n'
|
||||
for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do
|
||||
if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi
|
||||
done | sort -u
|
||||
printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true
|
||||
printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true
|
||||
printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 2>/dev/null || true
|
||||
printf '===UPDATES===\n'
|
||||
if command -v apt >/dev/null 2>&1; then
|
||||
apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true
|
||||
else
|
||||
printf 'Package manager not supported\n'
|
||||
fi
|
||||
printf '===PACKAGE_ORIGINS===\n'
|
||||
if command -v apt-cache >/dev/null 2>&1; then
|
||||
dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true)
|
||||
candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1)
|
||||
printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}"
|
||||
apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /'
|
||||
done
|
||||
else
|
||||
printf 'APT metadata unavailable\n'
|
||||
fi
|
||||
printf '===DOCKER===\n'
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
printf 'STATUS|not-installed\n'
|
||||
elif ! docker info >/dev/null 2>&1; then
|
||||
printf 'STATUS|permission-denied\n'
|
||||
else
|
||||
ids=$(docker ps -q 2>/dev/null)
|
||||
if [ -z "$ids" ]; then printf 'STATUS|no-running-containers\n'; fi
|
||||
for id in $ids; do
|
||||
name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,')
|
||||
image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null)
|
||||
version=$(docker exec "$id" php -r 'echo PHP_VERSION;' 2>/dev/null)
|
||||
exit_code=$?
|
||||
|
||||
if [ "$exit_code" -eq 0 ] &&
|
||||
printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$'; then
|
||||
printf 'PHP|%s|%s|%s\n' "$name" "$image" "$version"
|
||||
else
|
||||
printf 'NO_PHP_CLI|%s|%s\n' "$name" "$image"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
printf '===RECENT_PHP_FILES===\n'
|
||||
if [ -d /var/www ]; then
|
||||
find /var/www -type f -name '*.php' -mtime -3 -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40
|
||||
else
|
||||
printf 'Web root unavailable\n'
|
||||
fi
|
||||
printf '===WEB_ERROR_SIGNALS===\n'
|
||||
|
||||
# Portable, read-only web-server investigation.
|
||||
# Never require root access or changes to server permissions.
|
||||
|
||||
webservers=""
|
||||
if command -v nginx >/dev/null 2>&1 ||
|
||||
[ -d /etc/nginx ]; then
|
||||
webservers="nginx"
|
||||
fi
|
||||
|
||||
if command -v apache2 >/dev/null 2>&1 ||
|
||||
command -v httpd >/dev/null 2>&1 ||
|
||||
[ -d /etc/apache2 ] ||
|
||||
[ -d /etc/httpd ]; then
|
||||
webservers="$webservers apache"
|
||||
fi
|
||||
|
||||
webservers=$(printf '%s' "$webservers" | sed 's/^ *//')
|
||||
|
||||
if [ -z "$webservers" ]; then
|
||||
printf 'STATUS|not-applicable|No Nginx or Apache installation detected\n'
|
||||
else
|
||||
checked=0
|
||||
restricted=0
|
||||
missing=0
|
||||
total=0
|
||||
journal_checked=0
|
||||
|
||||
for webserver in $webservers; do
|
||||
case "$webserver" in
|
||||
nginx)
|
||||
candidates="/var/log/nginx/error.log"
|
||||
|
||||
# Prefer configured log locations when readable.
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
config=$(nginx -T 2>/dev/null)
|
||||
|
||||
if [ -z "$config" ]; then
|
||||
config=$(sudo -n nginx -T 2>/dev/null)
|
||||
fi
|
||||
|
||||
configured=$(printf '%s\n' "$config" |
|
||||
sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' |
|
||||
grep '^/' |
|
||||
sort -u)
|
||||
|
||||
if [ -n "$configured" ]; then
|
||||
candidates="$configured"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
apache)
|
||||
candidates="/var/log/apache2/error.log /var/log/httpd/error_log"
|
||||
|
||||
# Include configured absolute ErrorLog paths.
|
||||
configured=$(
|
||||
grep -RhEi '^[[:space:]]*ErrorLog[[:space:]]+/' \
|
||||
/etc/apache2 /etc/httpd \
|
||||
2>/dev/null |
|
||||
awk '{print $2}' |
|
||||
sort -u
|
||||
)
|
||||
|
||||
if [ -n "$configured" ]; then
|
||||
candidates="$configured"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
found_log=0
|
||||
|
||||
for log in $candidates; do
|
||||
case "$log" in
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
total=$((total + 1))
|
||||
|
||||
if [ ! -e "$log" ]; then
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
found_log=1
|
||||
log_data=""
|
||||
read_ok=0
|
||||
|
||||
if [ -r "$log" ]; then
|
||||
log_data=$(tail -n 1000 "$log" 2>/dev/null)
|
||||
read_ok=$?
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null)
|
||||
read_ok=$?
|
||||
else
|
||||
read_ok=1
|
||||
fi
|
||||
|
||||
if [ "$read_ok" -eq 0 ]; then
|
||||
checked=$((checked + 1))
|
||||
|
||||
printf '%s\n' "$log_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
|
||||
tail -20 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|%s|%s\n' "$log" "$entry"
|
||||
done
|
||||
else
|
||||
restricted=$((restricted + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Journal access may work even when log files are restricted.
|
||||
if [ "$webserver" = "nginx" ]; then
|
||||
units="nginx"
|
||||
else
|
||||
units="apache2 httpd"
|
||||
fi
|
||||
|
||||
for unit in $units; do
|
||||
if ! command -v journalctl >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
|
||||
journal_data=$(journalctl -u "$unit" -n 300 \
|
||||
--no-pager --output=cat 2>/dev/null)
|
||||
journal_rc=$?
|
||||
|
||||
if [ "$journal_rc" -ne 0 ]; then
|
||||
journal_data=$(sudo -n journalctl -u "$unit" \
|
||||
-n 300 --no-pager --output=cat 2>/dev/null)
|
||||
journal_rc=$?
|
||||
fi
|
||||
|
||||
if [ "$journal_rc" -eq 0 ] &&
|
||||
[ -n "$journal_data" ] &&
|
||||
[ "$journal_data" != "-- No entries --" ]; then
|
||||
journal_checked=$((journal_checked + 1))
|
||||
|
||||
printf '%s\n' "$journal_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
|
||||
tail -20 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|journal:%s|%s\n' "$unit" "$entry"
|
||||
done
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$restricted" -gt 0 ]; then
|
||||
printf 'STATUS|limited-access|servers=%s|logs_checked=%s|restricted=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$checked" "$restricted" "$journal_checked"
|
||||
elif [ "$checked" -gt 0 ]; then
|
||||
printf 'STATUS|complete|servers=%s|logs_checked=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$checked" "$journal_checked"
|
||||
elif [ "$journal_checked" -gt 0 ]; then
|
||||
printf 'STATUS|journal-inspected|servers=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$journal_checked"
|
||||
else
|
||||
printf 'STATUS|no-accessible-logs|servers=%s|logs_found=%s|missing=%s\n' \
|
||||
"$webservers" "$found_log" "$missing"
|
||||
fi
|
||||
fi
|
||||
printf '===END===\n'
|
||||
"""#
|
||||
|
||||
static func scan(_ server: Server) async throws -> AuditResult {
|
||||
try await Task.detached(priority: .userInitiated) { try run(server) }.value
|
||||
}
|
||||
|
||||
private static func run(_ server: Server) throws -> AuditResult {
|
||||
let validName = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/
|
||||
let validHost = /^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/
|
||||
guard server.user.wholeMatch(of: validName) != nil,
|
||||
server.host.wholeMatch(of: validHost) != nil,
|
||||
(1...65535).contains(server.port),
|
||||
!server.identityFile.contains("\n") else { throw ScanError.invalidServer }
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
var args = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(server.port)]
|
||||
if !server.identityFile.isEmpty {
|
||||
args += ["-i", (server.identityFile as NSString).expandingTildeInPath]
|
||||
}
|
||||
args += ["--", "\(server.user)@\(server.host)", "sh -s"]
|
||||
process.arguments = args
|
||||
let input = Pipe(), output = Pipe(), errors = Pipe()
|
||||
process.standardInput = input
|
||||
process.standardOutput = output
|
||||
process.standardError = errors
|
||||
try process.run()
|
||||
input.fileHandleForWriting.write(Data(script.utf8))
|
||||
try? input.fileHandleForWriting.close()
|
||||
// Both pipes are drained concurrently to avoid deadlocks on large output.
|
||||
let group = DispatchGroup()
|
||||
var stdout = Data(), stderr = Data()
|
||||
group.enter()
|
||||
DispatchQueue.global().async { stdout = output.fileHandleForReading.readDataToEndOfFile(); group.leave() }
|
||||
group.enter()
|
||||
DispatchQueue.global().async { stderr = errors.fileHandleForReading.readDataToEndOfFile(); group.leave() }
|
||||
process.waitUntilExit()
|
||||
group.wait()
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw ScanError.commandFailed(String(data: stderr, encoding: .utf8) ?? "SSH failed (\(process.terminationStatus))")
|
||||
}
|
||||
return parse(String(decoding: stdout, as: UTF8.self), server: server)
|
||||
}
|
||||
|
||||
static func parse(_ raw: String, server: Server) -> AuditResult {
|
||||
var result = AuditResult(serverID: server.id, serverName: server.name)
|
||||
result.rawOutput = raw
|
||||
var section = ""
|
||||
var fields: [String: [String]] = [:]
|
||||
for line in raw.components(separatedBy: .newlines) {
|
||||
if line.hasPrefix("===") && line.hasSuffix("===") { section = String(line.dropFirst(3).dropLast(3)); continue }
|
||||
if !line.trimmingCharacters(in: .whitespaces).isEmpty { fields[section, default: []].append(line) }
|
||||
}
|
||||
result.hostname = fields["HOST"]?.first ?? "Unknown"
|
||||
result.os = fields["OS"]?.first ?? "Unknown"
|
||||
result.phpVersions = fields["PHP"] ?? []
|
||||
result.fpmServices = fields["FPM"] ?? []
|
||||
result.nginxUpstreams = fields["NGINX"] ?? []
|
||||
result.packageVersions = fields["PACKAGES"] ?? []
|
||||
result.availableUpdates = fields["UPDATES"] ?? []
|
||||
result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? []
|
||||
result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" }
|
||||
let nginxLines = fields["WEB_ERROR_SIGNALS"] ?? []
|
||||
|
||||
result.webErrorSignals = nginxLines.compactMap { line in
|
||||
guard line.hasPrefix("SIGNAL|") else { return nil }
|
||||
return String(line.dropFirst("SIGNAL|".count))
|
||||
}
|
||||
|
||||
if let status = nginxLines.first(where: { $0.hasPrefix("STATUS|") }) {
|
||||
let parts = status.split(separator: "|").map(String.init)
|
||||
let kind = parts.count > 1 ? parts[1] : ""
|
||||
let details = parts.dropFirst(2).joined(separator: ", ")
|
||||
|
||||
switch kind {
|
||||
case "not-applicable":
|
||||
result.investigationStatus =
|
||||
"Not applicable: No Nginx or Apache installation detected"
|
||||
case "limited-access":
|
||||
result.investigationStatus =
|
||||
"Limited access: Some web-server logs require additional permissions (" + details + ")"
|
||||
case "no-accessible-logs":
|
||||
result.investigationStatus =
|
||||
"Limited coverage: No accessible web-server logs (" + details + ")"
|
||||
case "complete":
|
||||
result.investigationStatus =
|
||||
"Complete: Web-server error logs inspected (" + details + ")"
|
||||
case "journal-inspected":
|
||||
result.investigationStatus =
|
||||
"Complete: Web-server systemd journal inspected"
|
||||
case "partial":
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx logs inspected with limitations (" + details + ")"
|
||||
case "unavailable":
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx logging unavailable (" + details + ")"
|
||||
default:
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx inspection status unknown"
|
||||
}
|
||||
} else if nginxLines.contains(where: {
|
||||
$0.hasPrefix("Nginx error log unavailable")
|
||||
}) {
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx error log inaccessible or absent"
|
||||
} else {
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx log inspection status not reported"
|
||||
}
|
||||
let dockerLines = fields["DOCKER"] ?? []
|
||||
result.dockerCoverage = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }.map { line in
|
||||
let parts = line.split(separator: "|", omittingEmptySubsequences: false)
|
||||
return parts.count == 3 ? "\(parts[1]) [\(parts[2])]: PHP CLI not detected (not proof PHP is absent)" : line
|
||||
}
|
||||
result.dockerPHP = dockerLines.filter { $0.hasPrefix("PHP|") }.map { line in
|
||||
let fields = line.split(separator: "|", omittingEmptySubsequences: false)
|
||||
return fields.count == 4 ? "\(fields[1]) [\(fields[2])] PHP \(fields[3])" : line
|
||||
}
|
||||
let noPHP = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }
|
||||
let dockerStatus = dockerLines.first(where: { $0.hasPrefix("STATUS|") })
|
||||
if let dockerStatus {
|
||||
switch dockerStatus {
|
||||
case "STATUS|permission-denied": result.dockerAuditStatus = "Docker inaccessible: permission or daemon error"
|
||||
case "STATUS|not-installed": result.dockerAuditStatus = "Docker not installed"
|
||||
case "STATUS|no-running-containers": result.dockerAuditStatus = "No running containers"
|
||||
default: result.dockerAuditStatus = "Docker inspection incomplete"
|
||||
}
|
||||
} else {
|
||||
result.dockerAuditStatus = "\(result.dockerPHP.count) PHP runtimes; \(noPHP.count) containers without PHP CLI (not a security finding)"
|
||||
}
|
||||
result.packageAuditStatus = result.packageOrigins.contains("APT metadata unavailable")
|
||||
? "APT package provenance unavailable"
|
||||
: "Installed and candidate package versions collected; vendor CVE fix status requires advisory verification"
|
||||
// Recommendations are derived from this server's collected evidence.
|
||||
var actions: [String] = []
|
||||
|
||||
if !result.availableUpdates.isEmpty {
|
||||
actions.append(
|
||||
"PRIORITY: Review \\(result.availableUpdates.count) available PHP package update(s). " +
|
||||
"Check the applicable vendor advisories before applying changes."
|
||||
)
|
||||
}
|
||||
|
||||
if result.phpVersions.isEmpty {
|
||||
actions.append(
|
||||
"COVERAGE: No host PHP CLI was detected in standard locations. " +
|
||||
"Check custom installations and application-specific runtimes."
|
||||
)
|
||||
}
|
||||
|
||||
if !result.dockerPHP.isEmpty {
|
||||
actions.append(
|
||||
"CONTAINERS: Review \\(result.dockerPHP.count) detected PHP runtime(s) " +
|
||||
"against their image maintainers' current security releases. " +
|
||||
"Host package updates do not update container images."
|
||||
)
|
||||
}
|
||||
|
||||
if result.dockerAuditStatus.hasPrefix("Docker inaccessible") {
|
||||
actions.append(
|
||||
"COVERAGE: Docker inspection failed. Verify container inventory access " +
|
||||
"before treating this assessment as complete."
|
||||
)
|
||||
}
|
||||
|
||||
if result.packageAuditStatus.contains("unavailable") {
|
||||
actions.append(
|
||||
"COVERAGE: PHP package provenance could not be established. " +
|
||||
"Verify package repositories and installed revisions."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if !result.webErrorSignals.isEmpty {
|
||||
let blocked = result.webErrorSignals.filter {
|
||||
$0.localizedCaseInsensitiveContains("access forbidden by rule")
|
||||
}.count
|
||||
|
||||
let other = result.webErrorSignals.count - blocked
|
||||
|
||||
if other > 0 {
|
||||
actions.append(
|
||||
"INVESTIGATE: Review \\(other) web-server error signal(s) " +
|
||||
"for application failures, PHP errors or abnormal behaviour."
|
||||
)
|
||||
}
|
||||
|
||||
if blocked > 0 {
|
||||
actions.append(
|
||||
"INFORMATION: Nginx blocked \\(blocked) suspicious request(s). " +
|
||||
"Confirm access-control rules remain effective. " +
|
||||
"Blocked requests alone do not establish a compromise."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !result.recentPHPFiles.isEmpty {
|
||||
actions.append(
|
||||
"VERIFY: \\(result.recentPHPFiles.count) recently modified PHP file(s) " +
|
||||
"were identified. Compare them against expected deployments."
|
||||
)
|
||||
}
|
||||
|
||||
if actions.isEmpty {
|
||||
actions.append(
|
||||
"No immediate follow-up actions were identified by the collected indicators. " +
|
||||
"Continue monitoring and verify PHP security advisories independently."
|
||||
)
|
||||
}
|
||||
|
||||
result.securityTriage = actions
|
||||
|
||||
if result.phpVersions.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "No PHP CLI detected", detail: "No PHP binary found in standard paths; this does not exclude bundled runtimes or containers."))
|
||||
}
|
||||
for line in result.phpVersions {
|
||||
let parts = line.split(separator: " ")
|
||||
guard let version = parts.last, let major = Int(version.split(separator: ".").first ?? "") else { continue }
|
||||
if major < 8 {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Legacy PHP detected", detail: "\(line). Verify vendor support and distribution security maintenance."))
|
||||
}
|
||||
}
|
||||
for line in result.packageOrigins where line.contains("installed=") && line.contains("candidate=") {
|
||||
let parts = line.split(separator: " ")
|
||||
guard let name = parts.first, name.hasPrefix("php") else { continue }
|
||||
let installed = parts.first(where: { $0.hasPrefix("installed=") }).map { String($0.dropFirst(10)) } ?? ""
|
||||
let candidate = parts.first(where: { $0.hasPrefix("candidate=") }).map { String($0.dropFirst(10)) } ?? ""
|
||||
if !installed.isEmpty && installed == candidate {
|
||||
// Equal revisions indicate no available APT upgrade, not confirmed CVE remediation.
|
||||
continue
|
||||
}
|
||||
if !installed.isEmpty && !candidate.isEmpty && candidate != "(none)" {
|
||||
result.findings.append(Finding(severity: "Info", title: "Package revision review", detail: "\(name): installed \(installed), candidate \(candidate). Confirm with vendor advisory and dpkg version ordering."))
|
||||
}
|
||||
}
|
||||
for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure."))
|
||||
}
|
||||
if result.packageOrigins.contains("APT metadata unavailable") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified."))
|
||||
}
|
||||
for line in result.dockerPHP {
|
||||
if line.contains("PHP 8.5.10") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure."))
|
||||
}
|
||||
}
|
||||
for line in result.dockerPHP {
|
||||
result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP."))
|
||||
}
|
||||
if !result.dockerPHP.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "Container update workflow", detail: "PHP container images must be updated and redeployed separately. Review image digests, upstream release notes, and image-specific security fixes before changing production services."))
|
||||
}
|
||||
if result.dockerAuditStatus.hasPrefix("Docker inaccessible") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Docker coverage unavailable", detail: result.dockerAuditStatus))
|
||||
}
|
||||
if !result.webErrorSignals.isEmpty {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Web server error signals", detail: "\(result.webErrorSignals.count) potentially relevant log entries. Review context; errors alone do not establish an attack."))
|
||||
}
|
||||
if !result.recentPHPFiles.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments."))
|
||||
}
|
||||
result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan."))
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,44 @@ import SwiftUI
|
||||
scanProgressVisible = false
|
||||
}
|
||||
}
|
||||
do { results.insert(try await SSHScanner.scan(server), at: 0) }
|
||||
do {
|
||||
var report = try await SSHScanner.scan(server)
|
||||
report.cveAssessmentStatus = "Checking NVD PHP product applicability"
|
||||
let runtimeLines = report.phpVersions + report.dockerPHP
|
||||
var versions: [String: String] = [:]
|
||||
for line in runtimeLines {
|
||||
let tokens = line.split(whereSeparator: { $0.isWhitespace })
|
||||
guard let token = tokens.last else { continue }
|
||||
let version = String(token)
|
||||
let parts = version.split(separator: ".")
|
||||
guard parts.count >= 3,
|
||||
parts.prefix(3).allSatisfy({ Int($0) != nil }) else { continue }
|
||||
let canonical = parts.prefix(3).joined(separator: ".")
|
||||
versions[canonical] = "PHP " + canonical
|
||||
}
|
||||
if versions.isEmpty {
|
||||
report.cveAssessmentStatus = "Not assessed: no parseable PHP runtime version"
|
||||
} else {
|
||||
var all: [CVEServerAssessment] = []
|
||||
var failures: [String] = []
|
||||
// Sequential requests respect unauthenticated NVD API rate limits.
|
||||
for version in versions.keys.sorted() {
|
||||
do {
|
||||
let records = try await VulnerabilityService.assessPHPVersion(
|
||||
version, runtime: versions[version] ?? version)
|
||||
all.append(contentsOf: records)
|
||||
} catch {
|
||||
failures.append("PHP \(version): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
report.cveAssessments = all.sorted { $0.cveID > $1.cveID }
|
||||
report.cveAssessmentStatus = failures.isEmpty
|
||||
? "Upstream CPE assessment complete; distribution fixes unverified"
|
||||
: "Partial/unknown: " + failures.joined(separator: "; ")
|
||||
report.cveAssessedAt = Date()
|
||||
}
|
||||
results.insert(report, at: 0)
|
||||
}
|
||||
catch {
|
||||
var failed = AuditResult(serverID: server.id, serverName: server.name)
|
||||
failed.error = error.localizedDescription
|
||||
|
||||
+365
-5
@@ -81,7 +81,7 @@ struct ContentView: View {
|
||||
stat("Scanned", "\(Set(store.results.filter { $0.error == nil }.map(\.serverID)).count)", "checkmark.shield")
|
||||
stat("Findings", "\(store.results.filter { $0.error == nil }.prefix(store.servers.count).reduce(0) { $0 + $1.findings.count })", "exclamationmark.triangle")
|
||||
}
|
||||
Text("Read-only PHP inventory, APT upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.")
|
||||
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") }
|
||||
@@ -128,10 +128,12 @@ struct ContentView: View {
|
||||
.padding(16)
|
||||
.background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14))
|
||||
Text("OS: \(result.os) • Host: \(result.hostname)")
|
||||
dataSection("PHP installations", result.phpVersions)
|
||||
dataSection("PHP-FPM services", result.fpmServices)
|
||||
dataSection("Nginx FastCGI routes", result.nginxUpstreams)
|
||||
dataSection("Installed packages", result.packageVersions)
|
||||
inventoryCards(
|
||||
php: result.phpVersions,
|
||||
fpm: result.fpmServices,
|
||||
nginx: result.nginxUpstreams,
|
||||
packages: result.packageVersions
|
||||
)
|
||||
dataSection("Available PHP package upgrades", result.availableUpdates)
|
||||
dataSection("APT package origins and candidate revisions", result.packageOrigins)
|
||||
Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary)
|
||||
@@ -139,6 +141,30 @@ struct ContentView: View {
|
||||
Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary)
|
||||
dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage)
|
||||
dataSection("Security triage — follow-up actions", result.securityTriage)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("CVE assessment").font(.title2.bold())
|
||||
Text(result.cveAssessmentStatus).foregroundStyle(.secondary)
|
||||
Text("NVD upstream PHP applicability only. Ubuntu backports, PHP extensions, Composer packages and Docker image security are not verified.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if result.cveAssessments.isEmpty {
|
||||
Text("No upstream PHP CVE candidates recorded. This is not a clean security bill.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(result.cveAssessments) { item in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Link(item.cveID, destination: URL(string: item.referenceURL)!)
|
||||
.font(.headline)
|
||||
Text(item.severity).font(.caption.bold())
|
||||
Spacer()
|
||||
Text(item.status).foregroundStyle(.orange)
|
||||
}
|
||||
Text(item.runtime + " — " + item.summary).lineLimit(3)
|
||||
Text(item.evidence).font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(10)
|
||||
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Text("Security findings").font(.title2.bold())
|
||||
Spacer()
|
||||
@@ -162,6 +188,340 @@ struct ContentView: View {
|
||||
}.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 {
|
||||
inventoryCard(
|
||||
title: "Installed Packages",
|
||||
subtitle: "PHP package inventory",
|
||||
icon: "shippingbox.fill",
|
||||
tint: .orange,
|
||||
count: values.count
|
||||
) {
|
||||
if values.isEmpty {
|
||||
inventoryEmptyState("No PHP packages detected")
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(values.enumerated()), id: \.offset) {
|
||||
index, line in
|
||||
|
||||
let parts = line.split(
|
||||
separator: " ",
|
||||
maxSplits: 1,
|
||||
omittingEmptySubsequences: true
|
||||
)
|
||||
|
||||
let name = parts.first.map(String.init) ?? line
|
||||
let version = parts.count > 1
|
||||
? String(parts[1])
|
||||
: "Version not reported"
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text(version)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(
|
||||
horizontal: false,
|
||||
vertical: true
|
||||
)
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(.vertical, 9)
|
||||
|
||||
if index < values.count - 1 {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func inventoryEmptyState(_ message: String) -> some View {
|
||||
HStack(spacing: 9) {
|
||||
Image(systemName: "minus.circle")
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
private func dataSection(_ title: String, _ values: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var store: AuditStore
|
||||
@State private var selection: UUID?
|
||||
@State private var showingEditor = false
|
||||
@State private var editing: Server?
|
||||
@State private var showClearHistoryConfirmation = false
|
||||
@AppStorage("phpguard.confirmClear") private var confirmClearHistory = true
|
||||
private func addServer() { editing = nil; showingEditor = true }
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $selection) {
|
||||
Section("Servers") {
|
||||
ForEach(store.servers) { server in
|
||||
Label {
|
||||
HStack {
|
||||
Text(server.name)
|
||||
Spacer()
|
||||
if store.scanning.contains(server.id) { ProgressView().controlSize(.small) }
|
||||
else if store.latest(for: server)?.error != nil { Image(systemName: "exclamationmark.triangle").foregroundStyle(.orange) }
|
||||
}
|
||||
} icon: { Image(systemName: "server.rack") }
|
||||
.tag(server.id)
|
||||
.contextMenu {
|
||||
Button("Scan") { store.scan(server) }
|
||||
Button("Edit") { editing = server; showingEditor = true }
|
||||
Divider()
|
||||
Button("Remove", role: .destructive) {
|
||||
store.servers.removeAll { $0.id == server.id }
|
||||
if selection == server.id { selection = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Navigation") {
|
||||
Label("CVE research", systemImage: "shield.lefthalf.filled").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!)
|
||||
Label("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!)
|
||||
}
|
||||
}
|
||||
.navigationTitle("PHPGuard")
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Button(action: addServer) {
|
||||
Label("Add Server", systemImage: "plus.circle.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(12)
|
||||
.background(.bar)
|
||||
}
|
||||
} detail: {
|
||||
if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000002") {
|
||||
cveView
|
||||
} else if selection == UUID(uuidString: "00000000-0000-0000-0000-000000000001") {
|
||||
historyView
|
||||
} else if let server = store.servers.first(where: { $0.id == selection }) {
|
||||
serverView(server)
|
||||
} else { dashboard }
|
||||
}
|
||||
.sheet(isPresented: $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 upgrade checks and running Docker container discovery. Vendor advisory matching remains unimplemented; no server is certified secure.")
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button(action: addServer) { Label("Add Server", systemImage: "plus.circle.fill") }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty)
|
||||
Button("Import JSON scan") { store.importReport() }
|
||||
}
|
||||
Spacer()
|
||||
}.padding(30).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
private func stat(_ title: String, _ value: String, _ icon: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label(title, systemImage: icon).foregroundStyle(.secondary)
|
||||
Text(value).font(.system(size: 36, weight: .bold))
|
||||
}.padding(20).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
private func serverView(_ server: Server) -> some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(server.name).font(.largeTitle.bold())
|
||||
Text("\(server.user)@\(server.host):\(server.port)").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Edit") { editing = server; showingEditor = true }
|
||||
Button("Scan now") { store.scan(server) }.buttonStyle(.borderedProminent)
|
||||
.disabled(store.scanning.contains(server.id))
|
||||
}
|
||||
if let result = store.latest(for: server) {
|
||||
if let error = result.error {
|
||||
Label(error, systemImage: "xmark.circle.fill").foregroundStyle(.red)
|
||||
} else {
|
||||
HStack(spacing: 12) {
|
||||
Label("Scan completed", systemImage: "checkmark.shield.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text(result.scannedAt.formatted()).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button { store.exportPDF(result) } label: { Label("Export PDF", systemImage: "doc.richtext") }
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button { store.export(result) } label: { Label("JSON", systemImage: "square.and.arrow.up") }
|
||||
}
|
||||
.padding(16)
|
||||
.background(.green.opacity(0.08), in: RoundedRectangle(cornerRadius: 14))
|
||||
Text("OS: \(result.os) • Host: \(result.hostname)")
|
||||
dataSection("PHP installations", result.phpVersions)
|
||||
dataSection("PHP-FPM services", result.fpmServices)
|
||||
dataSection("Nginx FastCGI routes", result.nginxUpstreams)
|
||||
dataSection("Installed packages", result.packageVersions)
|
||||
dataSection("Available PHP package upgrades", result.availableUpdates)
|
||||
dataSection("APT package origins and candidate revisions", result.packageOrigins)
|
||||
Text("Package assessment: \(result.packageAuditStatus)").foregroundStyle(.secondary)
|
||||
dataSection("PHP detected in running Docker containers", result.dockerPHP)
|
||||
Text("Docker assessment: \(result.dockerAuditStatus)").foregroundStyle(.secondary)
|
||||
dataSection("Docker coverage (PHP CLI not detected)", result.dockerCoverage)
|
||||
dataSection("Security triage — follow-up actions", result.securityTriage)
|
||||
HStack {
|
||||
Text("Security findings").font(.title2.bold())
|
||||
Spacer()
|
||||
Text("\(result.findings.count) total").foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(result.findings) { finding in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? "exclamationmark.octagon.fill" : "info.circle.fill")
|
||||
Text(finding.severity.uppercased()).font(.caption.bold())
|
||||
Text(finding.title).font(.headline)
|
||||
}
|
||||
.foregroundStyle(finding.severity.lowercased().contains("critical") || finding.severity.lowercased().contains("high") ? Color.red : (finding.severity.lowercased().contains("warn") || finding.severity.lowercased().contains("medium") ? Color.orange : Color.blue))
|
||||
Text(finding.detail).foregroundStyle(.secondary).textSelection(.enabled)
|
||||
}.padding(12).frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
}
|
||||
} else { ContentUnavailableView("Not scanned", systemImage: "shield.lefthalf.filled", description: Text("Run a read-only SSH inventory scan.")) }
|
||||
}.padding(28).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
private func dataSection(_ title: String, _ values: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 4, height: 22)
|
||||
Text(title).font(.headline)
|
||||
Spacer()
|
||||
Text("\(values.count)").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if values.isEmpty { Text("None detected").foregroundStyle(.secondary) }
|
||||
else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } }
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
private var cveView: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("PHP CVE research").font(.largeTitle.bold())
|
||||
Spacer()
|
||||
if store.fetchingCVEs { ProgressView() }
|
||||
Button("Refresh from NVD") { store.refreshCVEs() }.disabled(store.fetchingCVEs)
|
||||
}
|
||||
Text("Research candidates from the NVD keyword index. These records are NOT matched to installed PHP versions, Ubuntu backports or Docker images. Do not interpret them as confirmed server vulnerabilities.")
|
||||
.foregroundStyle(.secondary)
|
||||
if let date = store.cveLastChecked {
|
||||
Text("Last updated: \(date.formatted()) · Saved locally · Automatically refreshed at launch")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("No cached CVE research yet · Automatically fetched at launch")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let error = store.cveError { Text(error).foregroundStyle(.red) }
|
||||
List(store.discoveredCVEs) { cve in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Link(cve.id, destination: URL(string: cve.referenceURL)!)
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(cve.severity + (cve.score.map { " (\($0))" } ?? ""))
|
||||
Text(String(cve.published.prefix(10))).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(cve.description).lineLimit(4).textSelection(.enabled)
|
||||
}.padding(.vertical, 5)
|
||||
}
|
||||
}.padding(24)
|
||||
}
|
||||
private var historyView: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Scan history").font(.largeTitle.bold())
|
||||
Text("\(store.results.count) saved scans").foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Clear History", role: .destructive) {
|
||||
if confirmClearHistory { showClearHistoryConfirmation = true }
|
||||
else { store.clearHistory() }
|
||||
}.disabled(store.results.isEmpty)
|
||||
}.padding(20)
|
||||
List {
|
||||
ForEach(store.results) { result in
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: result.error == nil ? "checkmark.shield.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(result.error == nil ? .green : .red)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(result.serverName).font(.headline)
|
||||
Text(result.scannedAt.formatted()).foregroundStyle(.secondary)
|
||||
if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) }
|
||||
else { Text("\(result.findings.count) findings · \(result.phpVersions.count) PHP installations")
|
||||
.font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
Spacer()
|
||||
Button("PDF") { store.exportPDF(result) }.disabled(result.error != nil)
|
||||
Button("JSON") { store.export(result) }.disabled(result.error != nil)
|
||||
}.padding(.vertical, 5)
|
||||
}.onDelete(perform: store.removeHistory)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Clear all scan history?", isPresented: $showClearHistoryConfirmation) {
|
||||
Button("Clear History", role: .destructive) { store.clearHistory() }
|
||||
} message: { Text("Saved scans will be permanently removed. Servers and CVE downloads will remain.") }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ServerEditor: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let existing: Server?
|
||||
let onSave: (Server) -> Void
|
||||
@State private var server: Server
|
||||
init(existing: Server?, onSave: @escaping (Server) -> Void) {
|
||||
self.existing = existing
|
||||
self.onSave = onSave
|
||||
_server = State(initialValue: existing ?? Server(name: "", host: ""))
|
||||
}
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Label(existing == nil ? "Add a server" : "Edit server", systemImage: "server.rack")
|
||||
.font(.title.bold())
|
||||
Text("Connect using an existing SSH key. No agent installation required.")
|
||||
.foregroundStyle(.secondary)
|
||||
Form {
|
||||
Section("Connection") {
|
||||
TextField("Server name (e.g. Pegasus)", text: $server.name)
|
||||
TextField("Hostname or IP address", text: $server.host)
|
||||
.textContentType(.URL)
|
||||
TextField("SSH username", text: $server.user)
|
||||
TextField("SSH port", value: $server.port, format: .number)
|
||||
}
|
||||
Section("Advanced (optional)") {
|
||||
HStack {
|
||||
TextField("SSH private key path", text: $server.identityFile)
|
||||
Button("Choose…") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
server.identityFile = url.path
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Leave blank to use your existing SSH configuration and keys.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}.formStyle(.grouped)
|
||||
if !isValid {
|
||||
Label("Enter a valid server name, SSH host, username and port (1–65535).", systemImage: "info.circle")
|
||||
.foregroundStyle(.secondary).font(.caption)
|
||||
}
|
||||
Text("Host key verification is strict. Connect in Terminal first if this server is new.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button(existing == nil ? "Add Server" : "Save Changes") {
|
||||
server.name = server.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
server.host = server.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
server.user = server.user.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onSave(server)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}.padding(24).frame(width: 580)
|
||||
}
|
||||
private var isValid: Bool {
|
||||
let host = server.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let user = server.user.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !host.isEmpty && !host.hasPrefix("-")
|
||||
&& host.range(of: "^[A-Za-z0-9][A-Za-z0-9.:-]*$", options: .regularExpression) != nil
|
||||
&& user.range(of: "^[A-Za-z0-9_][A-Za-z0-9_.-]*$", options: .regularExpression) != nil
|
||||
&& (1...65535).contains(server.port)
|
||||
}
|
||||
}
|
||||
|
||||
// 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("Scanning")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
|
||||
Text("Collecting PHP, package, Docker and web-server evidence")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
}
|
||||
|
||||
ProgressView()
|
||||
.progressViewStyle(.linear)
|
||||
.tint(Color.accentColor)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -35,13 +35,16 @@ struct AuditResult: Codable, Identifiable {
|
||||
var packageAuditStatus = "Not checked"
|
||||
var dockerAuditStatus = "Not checked"
|
||||
var findings: [Finding] = []
|
||||
var cveAssessments: [CVEServerAssessment] = []
|
||||
var cveAssessmentStatus = "Not assessed"
|
||||
var cveAssessedAt: Date? = nil
|
||||
var recentPHPFiles: [String] = []
|
||||
var webErrorSignals: [String] = []
|
||||
var investigationStatus = "Not checked"
|
||||
var rawOutput = ""
|
||||
var error: String? = nil
|
||||
|
||||
enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, availableUpdates, packageOrigins, dockerPHP, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, rawOutput, error, id }
|
||||
enum CodingKeys: String, CodingKey { case serverID, serverName, scannedAt, hostname, os, phpVersions, fpmServices, nginxUpstreams, packageVersions, availableUpdates, packageOrigins, dockerPHP, dockerCoverage, securityTriage, packageAuditStatus, dockerAuditStatus, recentPHPFiles, webErrorSignals, investigationStatus, findings, cveAssessments, cveAssessmentStatus, cveAssessedAt, rawOutput, error, id }
|
||||
init(serverID: UUID, serverName: String) { self.serverID = serverID; self.serverName = serverName }
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
@@ -62,6 +65,9 @@ struct AuditResult: Codable, Identifiable {
|
||||
packageAuditStatus = try container.decodeIfPresent(String.self, forKey: .packageAuditStatus) ?? "Not checked"
|
||||
dockerAuditStatus = try container.decodeIfPresent(String.self, forKey: .dockerAuditStatus) ?? "Not checked"
|
||||
findings = try container.decodeIfPresent([Finding].self, forKey: .findings) ?? []
|
||||
cveAssessments = try container.decodeIfPresent([CVEServerAssessment].self, forKey: .cveAssessments) ?? []
|
||||
cveAssessmentStatus = try container.decodeIfPresent(String.self, forKey: .cveAssessmentStatus) ?? "Not assessed"
|
||||
cveAssessedAt = try container.decodeIfPresent(Date.self, forKey: .cveAssessedAt)
|
||||
recentPHPFiles = try container.decodeIfPresent([String].self, forKey: .recentPHPFiles) ?? []
|
||||
webErrorSignals = try container.decodeIfPresent([String].self, forKey: .webErrorSignals) ?? []
|
||||
investigationStatus = try container.decodeIfPresent(String.self, forKey: .investigationStatus) ?? "Not checked"
|
||||
@@ -88,6 +94,9 @@ struct AuditResult: Codable, Identifiable {
|
||||
try container.encode(packageAuditStatus, forKey: .packageAuditStatus)
|
||||
try container.encode(dockerAuditStatus, forKey: .dockerAuditStatus)
|
||||
try container.encode(findings, forKey: .findings)
|
||||
try container.encode(cveAssessments, forKey: .cveAssessments)
|
||||
try container.encode(cveAssessmentStatus, forKey: .cveAssessmentStatus)
|
||||
try container.encodeIfPresent(cveAssessedAt, forKey: .cveAssessedAt)
|
||||
try container.encode(recentPHPFiles, forKey: .recentPHPFiles)
|
||||
try container.encode(webErrorSignals, forKey: .webErrorSignals)
|
||||
try container.encode(investigationStatus, forKey: .investigationStatus)
|
||||
@@ -105,3 +114,16 @@ struct CVERecord: Codable, Identifiable {
|
||||
var score: Double?
|
||||
var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + id }
|
||||
}
|
||||
|
||||
// NVD upstream applicability is not proof of vulnerability in distribution-patched packages.
|
||||
struct CVEServerAssessment: Codable, Identifiable {
|
||||
var id: String { cveID + "|" + runtime }
|
||||
var cveID: String
|
||||
var runtime: String
|
||||
var severity: String
|
||||
var score: Double?
|
||||
var summary: String
|
||||
var status: String
|
||||
var evidence: String
|
||||
var referenceURL: String { "https://nvd.nist.gov/vuln/detail/" + cveID }
|
||||
}
|
||||
|
||||
@@ -279,6 +279,23 @@ private final class ProfessionalReport {
|
||||
end()
|
||||
}
|
||||
|
||||
private func cveAssessmentPage() {
|
||||
begin("CVE assessment")
|
||||
title("Upstream CVE Assessment", subtitle: "NVD PHP product CPE matches; not verified server exposure", chapter: "CVE assessment")
|
||||
paragraph(report.cveAssessmentStatus, chapter: "CVE assessment")
|
||||
paragraph("Upstream version matching does not account for Ubuntu/Debian backported patches, third-party PHP builds, Composer dependencies, PHP extensions or container image security. Treat matches as potentially affected until verified against maintainer advisories.", chapter: "CVE assessment")
|
||||
section("Potential upstream CVE candidates (\(report.cveAssessments.count))", chapter: "CVE assessment")
|
||||
if report.cveAssessments.isEmpty {
|
||||
paragraph("No CVE candidates recorded; scan coverage may be incomplete or unavailable. This is not proof of zero vulnerabilities.", chapter: "CVE assessment")
|
||||
}
|
||||
for item in report.cveAssessments {
|
||||
tableRow(item.cveID + " (" + item.severity + ")", item.runtime + " — " + item.status, chapter: "CVE assessment")
|
||||
paragraph(item.summary, chapter: "CVE assessment", size: 8.5)
|
||||
paragraph(item.evidence, chapter: "CVE assessment", size: 8)
|
||||
}
|
||||
end()
|
||||
}
|
||||
|
||||
private func environment() {
|
||||
begin("PHP environment")
|
||||
title("PHP Environment", subtitle: "Host runtimes, PHP-FPM and web-server integration", chapter: "PHP environment")
|
||||
@@ -366,6 +383,7 @@ private final class ProfessionalReport {
|
||||
cover()
|
||||
summary()
|
||||
findingsPage()
|
||||
cveAssessmentPage()
|
||||
environment()
|
||||
containers()
|
||||
actions()
|
||||
|
||||
+176
-92
@@ -21,7 +21,7 @@ enum SSHScanner {
|
||||
done | sort -u
|
||||
printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true
|
||||
printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true
|
||||
printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 2>/dev/null || true
|
||||
printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)? ' | sort -u || true
|
||||
printf '===UPDATES===\n'
|
||||
if command -v apt >/dev/null 2>&1; then
|
||||
apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true
|
||||
@@ -30,7 +30,7 @@ enum SSHScanner {
|
||||
fi
|
||||
printf '===PACKAGE_ORIGINS===\n'
|
||||
if command -v apt-cache >/dev/null 2>&1; then
|
||||
dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do
|
||||
dpkg-query -W -f='${Package}\n' 'php*' 2>/dev/null | grep -E '^php[0-9.-]*([a-z-]+)?$' | sort -u | while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true)
|
||||
candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1)
|
||||
@@ -70,80 +70,169 @@ enum SSHScanner {
|
||||
fi
|
||||
printf '===WEB_ERROR_SIGNALS===\n'
|
||||
|
||||
nginx_logs=$(
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
if sudo -n true >/dev/null 2>&1; then
|
||||
sudo -n nginx -T 2>/dev/null
|
||||
else
|
||||
nginx -T 2>/dev/null
|
||||
fi
|
||||
fi |
|
||||
sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' |
|
||||
grep '^/' |
|
||||
sort -u
|
||||
)
|
||||
# Portable, read-only web-server investigation.
|
||||
# Never require root access or changes to server permissions.
|
||||
|
||||
[ -n "$nginx_logs" ] || nginx_logs=/var/log/nginx/error.log
|
||||
webservers=""
|
||||
if command -v nginx >/dev/null 2>&1 ||
|
||||
[ -d /etc/nginx ]; then
|
||||
webservers="nginx"
|
||||
fi
|
||||
|
||||
checked=0
|
||||
missing=0
|
||||
inaccessible=0
|
||||
total=0
|
||||
if command -v apache2 >/dev/null 2>&1 ||
|
||||
command -v httpd >/dev/null 2>&1 ||
|
||||
[ -d /etc/apache2 ] ||
|
||||
[ -d /etc/httpd ]; then
|
||||
webservers="$webservers apache"
|
||||
fi
|
||||
|
||||
for log in $nginx_logs; do
|
||||
total=$((total + 1))
|
||||
webservers=$(printf '%s' "$webservers" | sed 's/^ *//')
|
||||
|
||||
if [ ! -e "$log" ]; then
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -r "$log" ]; then
|
||||
log_data=$(tail -n 1000 "$log" 2>/dev/null)
|
||||
checked=$((checked + 1))
|
||||
elif sudo -n test -r "$log" >/dev/null 2>&1; then
|
||||
log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null)
|
||||
checked=$((checked + 1))
|
||||
else
|
||||
inaccessible=$((inaccessible + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$log_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule' |
|
||||
tail -20 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|%s|%s\n' "$log" "$entry"
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$checked" -eq 0 ]; then
|
||||
if sudo -n true >/dev/null 2>&1; then
|
||||
journal_data=$(sudo -n journalctl -u nginx -n 1000 --no-pager 2>/dev/null)
|
||||
else
|
||||
journal_data=$(journalctl -u nginx -n 1000 --no-pager 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -n "$journal_data" ] &&
|
||||
[ "$journal_data" != "-- No entries --" ]; then
|
||||
printf 'STATUS|journal-inspected\n'
|
||||
printf '%s\n' "$journal_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule' |
|
||||
tail -30 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|journal|%s\n' "$entry"
|
||||
done
|
||||
else
|
||||
printf 'STATUS|unavailable|logs=%s|missing=%s|inaccessible=%s\n' \
|
||||
"$total" "$missing" "$inaccessible"
|
||||
fi
|
||||
elif [ "$missing" -gt 0 ] || [ "$inaccessible" -gt 0 ]; then
|
||||
printf 'STATUS|partial|checked=%s|missing=%s|inaccessible=%s\n' \
|
||||
"$checked" "$missing" "$inaccessible"
|
||||
if [ -z "$webservers" ]; then
|
||||
printf 'STATUS|not-applicable|No Nginx or Apache installation detected\n'
|
||||
else
|
||||
printf 'STATUS|complete|checked=%s\n' "$checked"
|
||||
checked=0
|
||||
restricted=0
|
||||
missing=0
|
||||
total=0
|
||||
journal_checked=0
|
||||
|
||||
for webserver in $webservers; do
|
||||
case "$webserver" in
|
||||
nginx)
|
||||
candidates="/var/log/nginx/error.log"
|
||||
|
||||
# Prefer configured log locations when readable.
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
config=$(nginx -T 2>/dev/null)
|
||||
|
||||
if [ -z "$config" ]; then
|
||||
config=$(sudo -n nginx -T 2>/dev/null)
|
||||
fi
|
||||
|
||||
configured=$(printf '%s\n' "$config" |
|
||||
sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' |
|
||||
grep '^/' |
|
||||
sort -u)
|
||||
|
||||
if [ -n "$configured" ]; then
|
||||
candidates="$configured"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
apache)
|
||||
candidates="/var/log/apache2/error.log /var/log/httpd/error_log"
|
||||
|
||||
# Include configured absolute ErrorLog paths.
|
||||
configured=$(
|
||||
grep -RhEi '^[[:space:]]*ErrorLog[[:space:]]+/' \
|
||||
/etc/apache2 /etc/httpd \
|
||||
2>/dev/null |
|
||||
awk '{print $2}' |
|
||||
sort -u
|
||||
)
|
||||
|
||||
if [ -n "$configured" ]; then
|
||||
candidates="$configured"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
found_log=0
|
||||
|
||||
for log in $candidates; do
|
||||
case "$log" in
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
total=$((total + 1))
|
||||
|
||||
if [ ! -e "$log" ]; then
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
found_log=1
|
||||
log_data=""
|
||||
read_ok=0
|
||||
|
||||
if [ -r "$log" ]; then
|
||||
log_data=$(tail -n 1000 "$log" 2>/dev/null)
|
||||
read_ok=$?
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null)
|
||||
read_ok=$?
|
||||
else
|
||||
read_ok=1
|
||||
fi
|
||||
|
||||
if [ "$read_ok" -eq 0 ]; then
|
||||
checked=$((checked + 1))
|
||||
|
||||
printf '%s\n' "$log_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
|
||||
tail -20 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|%s|%s\n' "$log" "$entry"
|
||||
done
|
||||
else
|
||||
restricted=$((restricted + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Journal access may work even when log files are restricted.
|
||||
if [ "$webserver" = "nginx" ]; then
|
||||
units="nginx"
|
||||
else
|
||||
units="apache2 httpd"
|
||||
fi
|
||||
|
||||
for unit in $units; do
|
||||
if ! command -v journalctl >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
|
||||
journal_data=$(journalctl -u "$unit" -n 300 \
|
||||
--no-pager --output=cat 2>/dev/null)
|
||||
journal_rc=$?
|
||||
|
||||
if [ "$journal_rc" -ne 0 ]; then
|
||||
journal_data=$(sudo -n journalctl -u "$unit" \
|
||||
-n 300 --no-pager --output=cat 2>/dev/null)
|
||||
journal_rc=$?
|
||||
fi
|
||||
|
||||
if [ "$journal_rc" -eq 0 ] &&
|
||||
[ -n "$journal_data" ] &&
|
||||
[ "$journal_data" != "-- No entries --" ]; then
|
||||
journal_checked=$((journal_checked + 1))
|
||||
|
||||
printf '%s\n' "$journal_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
|
||||
tail -20 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|journal:%s|%s\n' "$unit" "$entry"
|
||||
done
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$restricted" -gt 0 ]; then
|
||||
printf 'STATUS|limited-access|servers=%s|logs_checked=%s|restricted=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$checked" "$restricted" "$journal_checked"
|
||||
elif [ "$checked" -gt 0 ]; then
|
||||
printf 'STATUS|complete|servers=%s|logs_checked=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$checked" "$journal_checked"
|
||||
elif [ "$journal_checked" -gt 0 ]; then
|
||||
printf 'STATUS|journal-inspected|servers=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$journal_checked"
|
||||
else
|
||||
printf 'STATUS|no-accessible-logs|servers=%s|logs_found=%s|missing=%s\n' \
|
||||
"$webservers" "$found_log" "$missing"
|
||||
fi
|
||||
fi
|
||||
printf '===END===\n'
|
||||
"""#
|
||||
@@ -220,12 +309,21 @@ enum SSHScanner {
|
||||
let details = parts.dropFirst(2).joined(separator: ", ")
|
||||
|
||||
switch kind {
|
||||
case "not-applicable":
|
||||
result.investigationStatus =
|
||||
"Not applicable: No Nginx or Apache installation detected"
|
||||
case "limited-access":
|
||||
result.investigationStatus =
|
||||
"Limited access: Some web-server logs require additional permissions (" + details + ")"
|
||||
case "no-accessible-logs":
|
||||
result.investigationStatus =
|
||||
"Limited coverage: No accessible web-server logs (" + details + ")"
|
||||
case "complete":
|
||||
result.investigationStatus =
|
||||
"Complete: Nginx error logs inspected (" + details + ")"
|
||||
"Complete: Web-server error logs inspected (" + details + ")"
|
||||
case "journal-inspected":
|
||||
result.investigationStatus =
|
||||
"Complete: Nginx systemd journal inspected"
|
||||
"Complete: Web-server systemd journal inspected"
|
||||
case "partial":
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx logs inspected with limitations (" + details + ")"
|
||||
@@ -274,7 +372,7 @@ enum SSHScanner {
|
||||
|
||||
if !result.availableUpdates.isEmpty {
|
||||
actions.append(
|
||||
"PRIORITY: Review \\(result.availableUpdates.count) available PHP package update(s). " +
|
||||
"PRIORITY: Review \(result.availableUpdates.count) available PHP package update(s). " +
|
||||
"Check the applicable vendor advisories before applying changes."
|
||||
)
|
||||
}
|
||||
@@ -288,7 +386,7 @@ enum SSHScanner {
|
||||
|
||||
if !result.dockerPHP.isEmpty {
|
||||
actions.append(
|
||||
"CONTAINERS: Review \\(result.dockerPHP.count) detected PHP runtime(s) " +
|
||||
"CONTAINERS: Review \(result.dockerPHP.count) detected PHP runtime(s) " +
|
||||
"against their image maintainers' current security releases. " +
|
||||
"Host package updates do not update container images."
|
||||
)
|
||||
@@ -308,12 +406,6 @@ enum SSHScanner {
|
||||
)
|
||||
}
|
||||
|
||||
if result.investigationStatus.hasPrefix("Partial") {
|
||||
actions.append(
|
||||
"COVERAGE: Web-server investigation is incomplete. " +
|
||||
result.investigationStatus
|
||||
)
|
||||
}
|
||||
|
||||
if !result.webErrorSignals.isEmpty {
|
||||
let blocked = result.webErrorSignals.filter {
|
||||
@@ -324,14 +416,14 @@ enum SSHScanner {
|
||||
|
||||
if other > 0 {
|
||||
actions.append(
|
||||
"INVESTIGATE: Review \\(other) web-server error signal(s) " +
|
||||
"INVESTIGATE: Review \(other) web-server error signal(s) " +
|
||||
"for application failures, PHP errors or abnormal behaviour."
|
||||
)
|
||||
}
|
||||
|
||||
if blocked > 0 {
|
||||
actions.append(
|
||||
"INFORMATION: Nginx blocked \\(blocked) suspicious request(s). " +
|
||||
"INFORMATION: Nginx blocked \(blocked) suspicious request(s). " +
|
||||
"Confirm access-control rules remain effective. " +
|
||||
"Blocked requests alone do not establish a compromise."
|
||||
)
|
||||
@@ -340,7 +432,7 @@ enum SSHScanner {
|
||||
|
||||
if !result.recentPHPFiles.isEmpty {
|
||||
actions.append(
|
||||
"VERIFY: \\(result.recentPHPFiles.count) recently modified PHP file(s) " +
|
||||
"VERIFY: \(result.recentPHPFiles.count) recently modified PHP file(s) " +
|
||||
"were identified. Compare them against expected deployments."
|
||||
)
|
||||
}
|
||||
@@ -383,11 +475,6 @@ enum SSHScanner {
|
||||
if result.packageOrigins.contains("APT metadata unavailable") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified."))
|
||||
}
|
||||
for line in result.dockerPHP {
|
||||
if line.contains("PHP 8.5.10") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure."))
|
||||
}
|
||||
}
|
||||
for line in result.dockerPHP {
|
||||
result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP."))
|
||||
}
|
||||
@@ -403,9 +490,6 @@ enum SSHScanner {
|
||||
if !result.recentPHPFiles.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments."))
|
||||
}
|
||||
if result.investigationStatus.hasPrefix("Partial") {
|
||||
result.findings.append(Finding(severity: "Info", title: "Investigation coverage limited", detail: result.investigationStatus))
|
||||
}
|
||||
result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan."))
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
import Foundation
|
||||
|
||||
enum ScanError: LocalizedError {
|
||||
case invalidServer, commandFailed(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidServer: return "Invalid SSH host, username, port or identity file."
|
||||
case .commandFailed(let message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SSHScanner {
|
||||
// Commands are fixed and read-only. User-supplied strings are passed as SSH arguments, never shell source.
|
||||
static let script = #"""
|
||||
printf '===HOST===\n'; hostname
|
||||
printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s
|
||||
printf '===PHP===\n'
|
||||
for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do
|
||||
if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi
|
||||
done | sort -u
|
||||
printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true
|
||||
printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true
|
||||
printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 2>/dev/null || true
|
||||
printf '===UPDATES===\n'
|
||||
if command -v apt >/dev/null 2>&1; then
|
||||
apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true
|
||||
else
|
||||
printf 'Package manager not supported\n'
|
||||
fi
|
||||
printf '===PACKAGE_ORIGINS===\n'
|
||||
if command -v apt-cache >/dev/null 2>&1; then
|
||||
dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true)
|
||||
candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1)
|
||||
printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}"
|
||||
apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /'
|
||||
done
|
||||
else
|
||||
printf 'APT metadata unavailable\n'
|
||||
fi
|
||||
printf '===DOCKER===\n'
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
printf 'STATUS|not-installed\n'
|
||||
elif ! docker info >/dev/null 2>&1; then
|
||||
printf 'STATUS|permission-denied\n'
|
||||
else
|
||||
ids=$(docker ps -q 2>/dev/null)
|
||||
if [ -z "$ids" ]; then printf 'STATUS|no-running-containers\n'; fi
|
||||
for id in $ids; do
|
||||
name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,')
|
||||
image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null)
|
||||
version=$(docker exec "$id" php -r 'echo PHP_VERSION;' 2>/dev/null)
|
||||
exit_code=$?
|
||||
|
||||
if [ "$exit_code" -eq 0 ] &&
|
||||
printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$'; then
|
||||
printf 'PHP|%s|%s|%s\n' "$name" "$image" "$version"
|
||||
else
|
||||
printf 'NO_PHP_CLI|%s|%s\n' "$name" "$image"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
printf '===RECENT_PHP_FILES===\n'
|
||||
if [ -d /var/www ]; then
|
||||
find /var/www -type f -name '*.php' -mtime -3 -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40
|
||||
else
|
||||
printf 'Web root unavailable\n'
|
||||
fi
|
||||
printf '===WEB_ERROR_SIGNALS===\n'
|
||||
|
||||
nginx_logs=$(
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
if sudo -n true >/dev/null 2>&1; then
|
||||
sudo -n nginx -T 2>/dev/null
|
||||
else
|
||||
nginx -T 2>/dev/null
|
||||
fi
|
||||
fi |
|
||||
sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' |
|
||||
grep '^/' |
|
||||
sort -u
|
||||
)
|
||||
|
||||
[ -n "$nginx_logs" ] || nginx_logs=/var/log/nginx/error.log
|
||||
|
||||
checked=0
|
||||
missing=0
|
||||
inaccessible=0
|
||||
total=0
|
||||
|
||||
for log in $nginx_logs; do
|
||||
total=$((total + 1))
|
||||
|
||||
if [ ! -e "$log" ]; then
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -r "$log" ]; then
|
||||
log_data=$(tail -n 1000 "$log" 2>/dev/null)
|
||||
checked=$((checked + 1))
|
||||
elif sudo -n test -r "$log" >/dev/null 2>&1; then
|
||||
log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null)
|
||||
checked=$((checked + 1))
|
||||
else
|
||||
inaccessible=$((inaccessible + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
printf '%s\n' "$log_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule' |
|
||||
tail -20 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|%s|%s\n' "$log" "$entry"
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$checked" -eq 0 ]; then
|
||||
if sudo -n true >/dev/null 2>&1; then
|
||||
journal_data=$(sudo -n journalctl -u nginx -n 1000 --no-pager 2>/dev/null)
|
||||
else
|
||||
journal_data=$(journalctl -u nginx -n 1000 --no-pager 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -n "$journal_data" ] &&
|
||||
[ "$journal_data" != "-- No entries --" ]; then
|
||||
printf 'STATUS|journal-inspected\n'
|
||||
printf '%s\n' "$journal_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule' |
|
||||
tail -30 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|journal|%s\n' "$entry"
|
||||
done
|
||||
else
|
||||
printf 'STATUS|unavailable|logs=%s|missing=%s|inaccessible=%s\n' \
|
||||
"$total" "$missing" "$inaccessible"
|
||||
fi
|
||||
elif [ "$missing" -gt 0 ] || [ "$inaccessible" -gt 0 ]; then
|
||||
printf 'STATUS|partial|checked=%s|missing=%s|inaccessible=%s\n' \
|
||||
"$checked" "$missing" "$inaccessible"
|
||||
else
|
||||
printf 'STATUS|complete|checked=%s\n' "$checked"
|
||||
fi
|
||||
printf '===END===\n'
|
||||
"""#
|
||||
|
||||
static func scan(_ server: Server) async throws -> AuditResult {
|
||||
try await Task.detached(priority: .userInitiated) { try run(server) }.value
|
||||
}
|
||||
|
||||
private static func run(_ server: Server) throws -> AuditResult {
|
||||
let validName = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/
|
||||
let validHost = /^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/
|
||||
guard server.user.wholeMatch(of: validName) != nil,
|
||||
server.host.wholeMatch(of: validHost) != nil,
|
||||
(1...65535).contains(server.port),
|
||||
!server.identityFile.contains("\n") else { throw ScanError.invalidServer }
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
var args = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(server.port)]
|
||||
if !server.identityFile.isEmpty {
|
||||
args += ["-i", (server.identityFile as NSString).expandingTildeInPath]
|
||||
}
|
||||
args += ["--", "\(server.user)@\(server.host)", "sh -s"]
|
||||
process.arguments = args
|
||||
let input = Pipe(), output = Pipe(), errors = Pipe()
|
||||
process.standardInput = input
|
||||
process.standardOutput = output
|
||||
process.standardError = errors
|
||||
try process.run()
|
||||
input.fileHandleForWriting.write(Data(script.utf8))
|
||||
try? input.fileHandleForWriting.close()
|
||||
// Both pipes are drained concurrently to avoid deadlocks on large output.
|
||||
let group = DispatchGroup()
|
||||
var stdout = Data(), stderr = Data()
|
||||
group.enter()
|
||||
DispatchQueue.global().async { stdout = output.fileHandleForReading.readDataToEndOfFile(); group.leave() }
|
||||
group.enter()
|
||||
DispatchQueue.global().async { stderr = errors.fileHandleForReading.readDataToEndOfFile(); group.leave() }
|
||||
process.waitUntilExit()
|
||||
group.wait()
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw ScanError.commandFailed(String(data: stderr, encoding: .utf8) ?? "SSH failed (\(process.terminationStatus))")
|
||||
}
|
||||
return parse(String(decoding: stdout, as: UTF8.self), server: server)
|
||||
}
|
||||
|
||||
static func parse(_ raw: String, server: Server) -> AuditResult {
|
||||
var result = AuditResult(serverID: server.id, serverName: server.name)
|
||||
result.rawOutput = raw
|
||||
var section = ""
|
||||
var fields: [String: [String]] = [:]
|
||||
for line in raw.components(separatedBy: .newlines) {
|
||||
if line.hasPrefix("===") && line.hasSuffix("===") { section = String(line.dropFirst(3).dropLast(3)); continue }
|
||||
if !line.trimmingCharacters(in: .whitespaces).isEmpty { fields[section, default: []].append(line) }
|
||||
}
|
||||
result.hostname = fields["HOST"]?.first ?? "Unknown"
|
||||
result.os = fields["OS"]?.first ?? "Unknown"
|
||||
result.phpVersions = fields["PHP"] ?? []
|
||||
result.fpmServices = fields["FPM"] ?? []
|
||||
result.nginxUpstreams = fields["NGINX"] ?? []
|
||||
result.packageVersions = fields["PACKAGES"] ?? []
|
||||
result.availableUpdates = fields["UPDATES"] ?? []
|
||||
result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? []
|
||||
result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" }
|
||||
let nginxLines = fields["WEB_ERROR_SIGNALS"] ?? []
|
||||
|
||||
result.webErrorSignals = nginxLines.compactMap { line in
|
||||
guard line.hasPrefix("SIGNAL|") else { return nil }
|
||||
return String(line.dropFirst("SIGNAL|".count))
|
||||
}
|
||||
|
||||
if let status = nginxLines.first(where: { $0.hasPrefix("STATUS|") }) {
|
||||
let parts = status.split(separator: "|").map(String.init)
|
||||
let kind = parts.count > 1 ? parts[1] : ""
|
||||
let details = parts.dropFirst(2).joined(separator: ", ")
|
||||
|
||||
switch kind {
|
||||
case "complete":
|
||||
result.investigationStatus =
|
||||
"Complete: Nginx error logs inspected (" + details + ")"
|
||||
case "journal-inspected":
|
||||
result.investigationStatus =
|
||||
"Complete: Nginx systemd journal inspected"
|
||||
case "partial":
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx logs inspected with limitations (" + details + ")"
|
||||
case "unavailable":
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx logging unavailable (" + details + ")"
|
||||
default:
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx inspection status unknown"
|
||||
}
|
||||
} else if nginxLines.contains(where: {
|
||||
$0.hasPrefix("Nginx error log unavailable")
|
||||
}) {
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx error log inaccessible or absent"
|
||||
} else {
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx log inspection status not reported"
|
||||
}
|
||||
let dockerLines = fields["DOCKER"] ?? []
|
||||
result.dockerCoverage = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }.map { line in
|
||||
let parts = line.split(separator: "|", omittingEmptySubsequences: false)
|
||||
return parts.count == 3 ? "\(parts[1]) [\(parts[2])]: PHP CLI not detected (not proof PHP is absent)" : line
|
||||
}
|
||||
result.dockerPHP = dockerLines.filter { $0.hasPrefix("PHP|") }.map { line in
|
||||
let fields = line.split(separator: "|", omittingEmptySubsequences: false)
|
||||
return fields.count == 4 ? "\(fields[1]) [\(fields[2])] PHP \(fields[3])" : line
|
||||
}
|
||||
let noPHP = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }
|
||||
let dockerStatus = dockerLines.first(where: { $0.hasPrefix("STATUS|") })
|
||||
if let dockerStatus {
|
||||
switch dockerStatus {
|
||||
case "STATUS|permission-denied": result.dockerAuditStatus = "Docker inaccessible: permission or daemon error"
|
||||
case "STATUS|not-installed": result.dockerAuditStatus = "Docker not installed"
|
||||
case "STATUS|no-running-containers": result.dockerAuditStatus = "No running containers"
|
||||
default: result.dockerAuditStatus = "Docker inspection incomplete"
|
||||
}
|
||||
} else {
|
||||
result.dockerAuditStatus = "\(result.dockerPHP.count) PHP runtimes; \(noPHP.count) containers without PHP CLI (not a security finding)"
|
||||
}
|
||||
result.packageAuditStatus = result.packageOrigins.contains("APT metadata unavailable")
|
||||
? "APT package provenance unavailable"
|
||||
: "Installed and candidate package versions collected; vendor CVE fix status requires advisory verification"
|
||||
// Recommendations are derived from this server's collected evidence.
|
||||
var actions: [String] = []
|
||||
|
||||
if !result.availableUpdates.isEmpty {
|
||||
actions.append(
|
||||
"PRIORITY: Review \\(result.availableUpdates.count) available PHP package update(s). " +
|
||||
"Check the applicable vendor advisories before applying changes."
|
||||
)
|
||||
}
|
||||
|
||||
if result.phpVersions.isEmpty {
|
||||
actions.append(
|
||||
"COVERAGE: No host PHP CLI was detected in standard locations. " +
|
||||
"Check custom installations and application-specific runtimes."
|
||||
)
|
||||
}
|
||||
|
||||
if !result.dockerPHP.isEmpty {
|
||||
actions.append(
|
||||
"CONTAINERS: Review \\(result.dockerPHP.count) detected PHP runtime(s) " +
|
||||
"against their image maintainers' current security releases. " +
|
||||
"Host package updates do not update container images."
|
||||
)
|
||||
}
|
||||
|
||||
if result.dockerAuditStatus.hasPrefix("Docker inaccessible") {
|
||||
actions.append(
|
||||
"COVERAGE: Docker inspection failed. Verify container inventory access " +
|
||||
"before treating this assessment as complete."
|
||||
)
|
||||
}
|
||||
|
||||
if result.packageAuditStatus.contains("unavailable") {
|
||||
actions.append(
|
||||
"COVERAGE: PHP package provenance could not be established. " +
|
||||
"Verify package repositories and installed revisions."
|
||||
)
|
||||
}
|
||||
|
||||
if result.investigationStatus.hasPrefix("Partial") {
|
||||
actions.append(
|
||||
"COVERAGE: Web-server investigation is incomplete. " +
|
||||
result.investigationStatus
|
||||
)
|
||||
}
|
||||
|
||||
if !result.webErrorSignals.isEmpty {
|
||||
let blocked = result.webErrorSignals.filter {
|
||||
$0.localizedCaseInsensitiveContains("access forbidden by rule")
|
||||
}.count
|
||||
|
||||
let other = result.webErrorSignals.count - blocked
|
||||
|
||||
if other > 0 {
|
||||
actions.append(
|
||||
"INVESTIGATE: Review \\(other) web-server error signal(s) " +
|
||||
"for application failures, PHP errors or abnormal behaviour."
|
||||
)
|
||||
}
|
||||
|
||||
if blocked > 0 {
|
||||
actions.append(
|
||||
"INFORMATION: Nginx blocked \\(blocked) suspicious request(s). " +
|
||||
"Confirm access-control rules remain effective. " +
|
||||
"Blocked requests alone do not establish a compromise."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !result.recentPHPFiles.isEmpty {
|
||||
actions.append(
|
||||
"VERIFY: \\(result.recentPHPFiles.count) recently modified PHP file(s) " +
|
||||
"were identified. Compare them against expected deployments."
|
||||
)
|
||||
}
|
||||
|
||||
if actions.isEmpty {
|
||||
actions.append(
|
||||
"No immediate follow-up actions were identified by the collected indicators. " +
|
||||
"Continue monitoring and verify PHP security advisories independently."
|
||||
)
|
||||
}
|
||||
|
||||
result.securityTriage = actions
|
||||
|
||||
if result.phpVersions.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "No PHP CLI detected", detail: "No PHP binary found in standard paths; this does not exclude bundled runtimes or containers."))
|
||||
}
|
||||
for line in result.phpVersions {
|
||||
let parts = line.split(separator: " ")
|
||||
guard let version = parts.last, let major = Int(version.split(separator: ".").first ?? "") else { continue }
|
||||
if major < 8 {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Legacy PHP detected", detail: "\(line). Verify vendor support and distribution security maintenance."))
|
||||
}
|
||||
}
|
||||
for line in result.packageOrigins where line.contains("installed=") && line.contains("candidate=") {
|
||||
let parts = line.split(separator: " ")
|
||||
guard let name = parts.first, name.hasPrefix("php") else { continue }
|
||||
let installed = parts.first(where: { $0.hasPrefix("installed=") }).map { String($0.dropFirst(10)) } ?? ""
|
||||
let candidate = parts.first(where: { $0.hasPrefix("candidate=") }).map { String($0.dropFirst(10)) } ?? ""
|
||||
if !installed.isEmpty && installed == candidate {
|
||||
// Equal revisions indicate no available APT upgrade, not confirmed CVE remediation.
|
||||
continue
|
||||
}
|
||||
if !installed.isEmpty && !candidate.isEmpty && candidate != "(none)" {
|
||||
result.findings.append(Finding(severity: "Info", title: "Package revision review", detail: "\(name): installed \(installed), candidate \(candidate). Confirm with vendor advisory and dpkg version ordering."))
|
||||
}
|
||||
}
|
||||
for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure."))
|
||||
}
|
||||
if result.packageOrigins.contains("APT metadata unavailable") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified."))
|
||||
}
|
||||
for line in result.dockerPHP {
|
||||
if line.contains("PHP 8.5.10") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure."))
|
||||
}
|
||||
}
|
||||
for line in result.dockerPHP {
|
||||
result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP."))
|
||||
}
|
||||
if !result.dockerPHP.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "Container update workflow", detail: "PHP container images must be updated and redeployed separately. Review image digests, upstream release notes, and image-specific security fixes before changing production services."))
|
||||
}
|
||||
if result.dockerAuditStatus.hasPrefix("Docker inaccessible") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Docker coverage unavailable", detail: result.dockerAuditStatus))
|
||||
}
|
||||
if !result.webErrorSignals.isEmpty {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Web server error signals", detail: "\(result.webErrorSignals.count) potentially relevant log entries. Review context; errors alone do not establish an attack."))
|
||||
}
|
||||
if !result.recentPHPFiles.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments."))
|
||||
}
|
||||
if result.investigationStatus.hasPrefix("Partial") {
|
||||
result.findings.append(Finding(severity: "Info", title: "Investigation coverage limited", detail: result.investigationStatus))
|
||||
}
|
||||
result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan."))
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
import Foundation
|
||||
|
||||
enum ScanError: LocalizedError {
|
||||
case invalidServer, commandFailed(String)
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidServer: return "Invalid SSH host, username, port or identity file."
|
||||
case .commandFailed(let message): return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SSHScanner {
|
||||
// Commands are fixed and read-only. User-supplied strings are passed as SSH arguments, never shell source.
|
||||
static let script = #"""
|
||||
printf '===HOST===\n'; hostname
|
||||
printf '===OS===\n'; ( . /etc/os-release && printf '%s\n' "$PRETTY_NAME" ) 2>/dev/null || uname -s
|
||||
printf '===PHP===\n'
|
||||
for p in /usr/bin/php /usr/bin/php[0-9]* /usr/local/bin/php; do
|
||||
if [ -x "$p" ] && [ ! -d "$p" ]; then "$p" -r 'echo PHP_BINARY," ",PHP_VERSION,PHP_EOL;' 2>/dev/null; fi
|
||||
done | sort -u
|
||||
printf '===FPM===\n'; systemctl list-units --all --type=service --no-legend --no-pager 2>/dev/null | grep -E 'php[0-9.]+-fpm' || true
|
||||
printf '===NGINX===\n'; grep -Rh 'fastcgi_pass' /etc/nginx/sites-enabled/ 2>/dev/null | sort -u || true
|
||||
printf '===PACKAGES===\n'; dpkg-query -W -f='${Package} ${Version}\n' 'php*-fpm' 'php*-cli' 2>/dev/null || true
|
||||
printf '===UPDATES===\n'
|
||||
if command -v apt >/dev/null 2>&1; then
|
||||
apt list --upgradable 2>/dev/null | grep -E '^php[^ /]*/' || true
|
||||
else
|
||||
printf 'Package manager not supported\n'
|
||||
fi
|
||||
printf '===PACKAGE_ORIGINS===\n'
|
||||
if command -v apt-cache >/dev/null 2>&1; then
|
||||
dpkg-query -W -f='${Package}\n' 'php*-fpm' 'php*-cli' 2>/dev/null | sort -u | while IFS= read -r pkg; do
|
||||
[ -n "$pkg" ] || continue
|
||||
installed=$(dpkg-query -W -f='${Version}' "$pkg" 2>/dev/null || true)
|
||||
candidate=$(apt-cache policy "$pkg" 2>/dev/null | sed -n 's/^[[:space:]]*Candidate: //p' | head -1)
|
||||
printf '%s installed=%s candidate=%s\n' "$pkg" "$installed" "${candidate:-unknown}"
|
||||
apt-cache policy "$pkg" 2>/dev/null | grep -E 'https?://|/ubuntu |/ubuntu$' | head -3 | sed 's/^[[:space:]]*/ /'
|
||||
done
|
||||
else
|
||||
printf 'APT metadata unavailable\n'
|
||||
fi
|
||||
printf '===DOCKER===\n'
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
printf 'STATUS|not-installed\n'
|
||||
elif ! docker info >/dev/null 2>&1; then
|
||||
printf 'STATUS|permission-denied\n'
|
||||
else
|
||||
ids=$(docker ps -q 2>/dev/null)
|
||||
if [ -z "$ids" ]; then printf 'STATUS|no-running-containers\n'; fi
|
||||
for id in $ids; do
|
||||
name=$(docker inspect --format '{{.Name}}' "$id" 2>/dev/null | sed 's,^/,,')
|
||||
image=$(docker inspect --format '{{.Config.Image}}' "$id" 2>/dev/null)
|
||||
version=$(docker exec "$id" php -r 'echo PHP_VERSION;' 2>/dev/null)
|
||||
exit_code=$?
|
||||
|
||||
if [ "$exit_code" -eq 0 ] &&
|
||||
printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9]+)*$'; then
|
||||
printf 'PHP|%s|%s|%s\n' "$name" "$image" "$version"
|
||||
else
|
||||
printf 'NO_PHP_CLI|%s|%s\n' "$name" "$image"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
printf '===RECENT_PHP_FILES===\n'
|
||||
if [ -d /var/www ]; then
|
||||
find /var/www -type f -name '*.php' -mtime -3 -printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | head -40
|
||||
else
|
||||
printf 'Web root unavailable\n'
|
||||
fi
|
||||
printf '===WEB_ERROR_SIGNALS===\n'
|
||||
|
||||
# Portable, read-only web-server investigation.
|
||||
# Never require root access or changes to server permissions.
|
||||
|
||||
webservers=""
|
||||
if command -v nginx >/dev/null 2>&1 ||
|
||||
[ -d /etc/nginx ]; then
|
||||
webservers="nginx"
|
||||
fi
|
||||
|
||||
if command -v apache2 >/dev/null 2>&1 ||
|
||||
command -v httpd >/dev/null 2>&1 ||
|
||||
[ -d /etc/apache2 ] ||
|
||||
[ -d /etc/httpd ]; then
|
||||
webservers="$webservers apache"
|
||||
fi
|
||||
|
||||
webservers=$(printf '%s' "$webservers" | sed 's/^ *//')
|
||||
|
||||
if [ -z "$webservers" ]; then
|
||||
printf 'STATUS|not-applicable|No Nginx or Apache installation detected\n'
|
||||
else
|
||||
checked=0
|
||||
restricted=0
|
||||
missing=0
|
||||
total=0
|
||||
journal_checked=0
|
||||
|
||||
for webserver in $webservers; do
|
||||
case "$webserver" in
|
||||
nginx)
|
||||
candidates="/var/log/nginx/error.log"
|
||||
|
||||
# Prefer configured log locations when readable.
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
config=$(nginx -T 2>/dev/null)
|
||||
|
||||
if [ -z "$config" ]; then
|
||||
config=$(sudo -n nginx -T 2>/dev/null)
|
||||
fi
|
||||
|
||||
configured=$(printf '%s\n' "$config" |
|
||||
sed -n 's/^[[:space:]]*error_log[[:space:]]\([^; ]*\).*/\1/p' |
|
||||
grep '^/' |
|
||||
sort -u)
|
||||
|
||||
if [ -n "$configured" ]; then
|
||||
candidates="$configured"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
apache)
|
||||
candidates="/var/log/apache2/error.log /var/log/httpd/error_log"
|
||||
|
||||
# Include configured absolute ErrorLog paths.
|
||||
configured=$(
|
||||
grep -RhEi '^[[:space:]]*ErrorLog[[:space:]]+/' \
|
||||
/etc/apache2 /etc/httpd \
|
||||
2>/dev/null |
|
||||
awk '{print $2}' |
|
||||
sort -u
|
||||
)
|
||||
|
||||
if [ -n "$configured" ]; then
|
||||
candidates="$configured"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
found_log=0
|
||||
|
||||
for log in $candidates; do
|
||||
case "$log" in
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
total=$((total + 1))
|
||||
|
||||
if [ ! -e "$log" ]; then
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
found_log=1
|
||||
log_data=""
|
||||
read_ok=0
|
||||
|
||||
if [ -r "$log" ]; then
|
||||
log_data=$(tail -n 1000 "$log" 2>/dev/null)
|
||||
read_ok=$?
|
||||
elif command -v sudo >/dev/null 2>&1; then
|
||||
log_data=$(sudo -n tail -n 1000 "$log" 2>/dev/null)
|
||||
read_ok=$?
|
||||
else
|
||||
read_ok=1
|
||||
fi
|
||||
|
||||
if [ "$read_ok" -eq 0 ]; then
|
||||
checked=$((checked + 1))
|
||||
|
||||
printf '%s\n' "$log_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
|
||||
tail -20 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|%s|%s\n' "$log" "$entry"
|
||||
done
|
||||
else
|
||||
restricted=$((restricted + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
# Journal access may work even when log files are restricted.
|
||||
if [ "$webserver" = "nginx" ]; then
|
||||
units="nginx"
|
||||
else
|
||||
units="apache2 httpd"
|
||||
fi
|
||||
|
||||
for unit in $units; do
|
||||
if ! command -v journalctl >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
|
||||
journal_data=$(journalctl -u "$unit" -n 300 \
|
||||
--no-pager --output=cat 2>/dev/null)
|
||||
journal_rc=$?
|
||||
|
||||
if [ "$journal_rc" -ne 0 ]; then
|
||||
journal_data=$(sudo -n journalctl -u "$unit" \
|
||||
-n 300 --no-pager --output=cat 2>/dev/null)
|
||||
journal_rc=$?
|
||||
fi
|
||||
|
||||
if [ "$journal_rc" -eq 0 ] &&
|
||||
[ -n "$journal_data" ] &&
|
||||
[ "$journal_data" != "-- No entries --" ]; then
|
||||
journal_checked=$((journal_checked + 1))
|
||||
|
||||
printf '%s\n' "$journal_data" |
|
||||
grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory|access forbidden by rule|AH[0-9]+|proxy_fcgi:error' |
|
||||
tail -20 |
|
||||
while IFS= read -r entry; do
|
||||
[ -n "$entry" ] &&
|
||||
printf 'SIGNAL|journal:%s|%s\n' "$unit" "$entry"
|
||||
done
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$restricted" -gt 0 ]; then
|
||||
printf 'STATUS|limited-access|servers=%s|logs_checked=%s|restricted=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$checked" "$restricted" "$journal_checked"
|
||||
elif [ "$checked" -gt 0 ]; then
|
||||
printf 'STATUS|complete|servers=%s|logs_checked=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$checked" "$journal_checked"
|
||||
elif [ "$journal_checked" -gt 0 ]; then
|
||||
printf 'STATUS|journal-inspected|servers=%s|journal_sources=%s\n' \
|
||||
"$webservers" "$journal_checked"
|
||||
else
|
||||
printf 'STATUS|no-accessible-logs|servers=%s|logs_found=%s|missing=%s\n' \
|
||||
"$webservers" "$found_log" "$missing"
|
||||
fi
|
||||
fi
|
||||
printf '===END===\n'
|
||||
"""#
|
||||
|
||||
static func scan(_ server: Server) async throws -> AuditResult {
|
||||
try await Task.detached(priority: .userInitiated) { try run(server) }.value
|
||||
}
|
||||
|
||||
private static func run(_ server: Server) throws -> AuditResult {
|
||||
let validName = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/
|
||||
let validHost = /^[a-zA-Z0-9][a-zA-Z0-9.:-]*$/
|
||||
guard server.user.wholeMatch(of: validName) != nil,
|
||||
server.host.wholeMatch(of: validHost) != nil,
|
||||
(1...65535).contains(server.port),
|
||||
!server.identityFile.contains("\n") else { throw ScanError.invalidServer }
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
||||
var args = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(server.port)]
|
||||
if !server.identityFile.isEmpty {
|
||||
args += ["-i", (server.identityFile as NSString).expandingTildeInPath]
|
||||
}
|
||||
args += ["--", "\(server.user)@\(server.host)", "sh -s"]
|
||||
process.arguments = args
|
||||
let input = Pipe(), output = Pipe(), errors = Pipe()
|
||||
process.standardInput = input
|
||||
process.standardOutput = output
|
||||
process.standardError = errors
|
||||
try process.run()
|
||||
input.fileHandleForWriting.write(Data(script.utf8))
|
||||
try? input.fileHandleForWriting.close()
|
||||
// Both pipes are drained concurrently to avoid deadlocks on large output.
|
||||
let group = DispatchGroup()
|
||||
var stdout = Data(), stderr = Data()
|
||||
group.enter()
|
||||
DispatchQueue.global().async { stdout = output.fileHandleForReading.readDataToEndOfFile(); group.leave() }
|
||||
group.enter()
|
||||
DispatchQueue.global().async { stderr = errors.fileHandleForReading.readDataToEndOfFile(); group.leave() }
|
||||
process.waitUntilExit()
|
||||
group.wait()
|
||||
guard process.terminationStatus == 0 else {
|
||||
throw ScanError.commandFailed(String(data: stderr, encoding: .utf8) ?? "SSH failed (\(process.terminationStatus))")
|
||||
}
|
||||
return parse(String(decoding: stdout, as: UTF8.self), server: server)
|
||||
}
|
||||
|
||||
static func parse(_ raw: String, server: Server) -> AuditResult {
|
||||
var result = AuditResult(serverID: server.id, serverName: server.name)
|
||||
result.rawOutput = raw
|
||||
var section = ""
|
||||
var fields: [String: [String]] = [:]
|
||||
for line in raw.components(separatedBy: .newlines) {
|
||||
if line.hasPrefix("===") && line.hasSuffix("===") { section = String(line.dropFirst(3).dropLast(3)); continue }
|
||||
if !line.trimmingCharacters(in: .whitespaces).isEmpty { fields[section, default: []].append(line) }
|
||||
}
|
||||
result.hostname = fields["HOST"]?.first ?? "Unknown"
|
||||
result.os = fields["OS"]?.first ?? "Unknown"
|
||||
result.phpVersions = fields["PHP"] ?? []
|
||||
result.fpmServices = fields["FPM"] ?? []
|
||||
result.nginxUpstreams = fields["NGINX"] ?? []
|
||||
result.packageVersions = fields["PACKAGES"] ?? []
|
||||
result.availableUpdates = fields["UPDATES"] ?? []
|
||||
result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? []
|
||||
result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" }
|
||||
let nginxLines = fields["WEB_ERROR_SIGNALS"] ?? []
|
||||
|
||||
result.webErrorSignals = nginxLines.compactMap { line in
|
||||
guard line.hasPrefix("SIGNAL|") else { return nil }
|
||||
return String(line.dropFirst("SIGNAL|".count))
|
||||
}
|
||||
|
||||
if let status = nginxLines.first(where: { $0.hasPrefix("STATUS|") }) {
|
||||
let parts = status.split(separator: "|").map(String.init)
|
||||
let kind = parts.count > 1 ? parts[1] : ""
|
||||
let details = parts.dropFirst(2).joined(separator: ", ")
|
||||
|
||||
switch kind {
|
||||
case "not-applicable":
|
||||
result.investigationStatus =
|
||||
"Not applicable: No Nginx or Apache installation detected"
|
||||
case "limited-access":
|
||||
result.investigationStatus =
|
||||
"Limited access: Some web-server logs require additional permissions (" + details + ")"
|
||||
case "no-accessible-logs":
|
||||
result.investigationStatus =
|
||||
"Limited coverage: No accessible web-server logs (" + details + ")"
|
||||
case "complete":
|
||||
result.investigationStatus =
|
||||
"Complete: Web-server error logs inspected (" + details + ")"
|
||||
case "journal-inspected":
|
||||
result.investigationStatus =
|
||||
"Complete: Web-server systemd journal inspected"
|
||||
case "partial":
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx logs inspected with limitations (" + details + ")"
|
||||
case "unavailable":
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx logging unavailable (" + details + ")"
|
||||
default:
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx inspection status unknown"
|
||||
}
|
||||
} else if nginxLines.contains(where: {
|
||||
$0.hasPrefix("Nginx error log unavailable")
|
||||
}) {
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx error log inaccessible or absent"
|
||||
} else {
|
||||
result.investigationStatus =
|
||||
"Partial: Nginx log inspection status not reported"
|
||||
}
|
||||
let dockerLines = fields["DOCKER"] ?? []
|
||||
result.dockerCoverage = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }.map { line in
|
||||
let parts = line.split(separator: "|", omittingEmptySubsequences: false)
|
||||
return parts.count == 3 ? "\(parts[1]) [\(parts[2])]: PHP CLI not detected (not proof PHP is absent)" : line
|
||||
}
|
||||
result.dockerPHP = dockerLines.filter { $0.hasPrefix("PHP|") }.map { line in
|
||||
let fields = line.split(separator: "|", omittingEmptySubsequences: false)
|
||||
return fields.count == 4 ? "\(fields[1]) [\(fields[2])] PHP \(fields[3])" : line
|
||||
}
|
||||
let noPHP = dockerLines.filter { $0.hasPrefix("NO_PHP_CLI|") }
|
||||
let dockerStatus = dockerLines.first(where: { $0.hasPrefix("STATUS|") })
|
||||
if let dockerStatus {
|
||||
switch dockerStatus {
|
||||
case "STATUS|permission-denied": result.dockerAuditStatus = "Docker inaccessible: permission or daemon error"
|
||||
case "STATUS|not-installed": result.dockerAuditStatus = "Docker not installed"
|
||||
case "STATUS|no-running-containers": result.dockerAuditStatus = "No running containers"
|
||||
default: result.dockerAuditStatus = "Docker inspection incomplete"
|
||||
}
|
||||
} else {
|
||||
result.dockerAuditStatus = "\(result.dockerPHP.count) PHP runtimes; \(noPHP.count) containers without PHP CLI (not a security finding)"
|
||||
}
|
||||
result.packageAuditStatus = result.packageOrigins.contains("APT metadata unavailable")
|
||||
? "APT package provenance unavailable"
|
||||
: "Installed and candidate package versions collected; vendor CVE fix status requires advisory verification"
|
||||
// Recommendations are derived from this server's collected evidence.
|
||||
var actions: [String] = []
|
||||
|
||||
if !result.availableUpdates.isEmpty {
|
||||
actions.append(
|
||||
"PRIORITY: Review \\(result.availableUpdates.count) available PHP package update(s). " +
|
||||
"Check the applicable vendor advisories before applying changes."
|
||||
)
|
||||
}
|
||||
|
||||
if result.phpVersions.isEmpty {
|
||||
actions.append(
|
||||
"COVERAGE: No host PHP CLI was detected in standard locations. " +
|
||||
"Check custom installations and application-specific runtimes."
|
||||
)
|
||||
}
|
||||
|
||||
if !result.dockerPHP.isEmpty {
|
||||
actions.append(
|
||||
"CONTAINERS: Review \\(result.dockerPHP.count) detected PHP runtime(s) " +
|
||||
"against their image maintainers' current security releases. " +
|
||||
"Host package updates do not update container images."
|
||||
)
|
||||
}
|
||||
|
||||
if result.dockerAuditStatus.hasPrefix("Docker inaccessible") {
|
||||
actions.append(
|
||||
"COVERAGE: Docker inspection failed. Verify container inventory access " +
|
||||
"before treating this assessment as complete."
|
||||
)
|
||||
}
|
||||
|
||||
if result.packageAuditStatus.contains("unavailable") {
|
||||
actions.append(
|
||||
"COVERAGE: PHP package provenance could not be established. " +
|
||||
"Verify package repositories and installed revisions."
|
||||
)
|
||||
}
|
||||
|
||||
if result.investigationStatus.hasPrefix("Partial") ||
|
||||
result.investigationStatus.hasPrefix("Limited") {
|
||||
actions.append(
|
||||
"COVERAGE: Web-server log visibility is limited. " +
|
||||
result.investigationStatus
|
||||
)
|
||||
}
|
||||
|
||||
if !result.webErrorSignals.isEmpty {
|
||||
let blocked = result.webErrorSignals.filter {
|
||||
$0.localizedCaseInsensitiveContains("access forbidden by rule")
|
||||
}.count
|
||||
|
||||
let other = result.webErrorSignals.count - blocked
|
||||
|
||||
if other > 0 {
|
||||
actions.append(
|
||||
"INVESTIGATE: Review \\(other) web-server error signal(s) " +
|
||||
"for application failures, PHP errors or abnormal behaviour."
|
||||
)
|
||||
}
|
||||
|
||||
if blocked > 0 {
|
||||
actions.append(
|
||||
"INFORMATION: Nginx blocked \\(blocked) suspicious request(s). " +
|
||||
"Confirm access-control rules remain effective. " +
|
||||
"Blocked requests alone do not establish a compromise."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !result.recentPHPFiles.isEmpty {
|
||||
actions.append(
|
||||
"VERIFY: \\(result.recentPHPFiles.count) recently modified PHP file(s) " +
|
||||
"were identified. Compare them against expected deployments."
|
||||
)
|
||||
}
|
||||
|
||||
if actions.isEmpty {
|
||||
actions.append(
|
||||
"No immediate follow-up actions were identified by the collected indicators. " +
|
||||
"Continue monitoring and verify PHP security advisories independently."
|
||||
)
|
||||
}
|
||||
|
||||
result.securityTriage = actions
|
||||
|
||||
if result.phpVersions.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "No PHP CLI detected", detail: "No PHP binary found in standard paths; this does not exclude bundled runtimes or containers."))
|
||||
}
|
||||
for line in result.phpVersions {
|
||||
let parts = line.split(separator: " ")
|
||||
guard let version = parts.last, let major = Int(version.split(separator: ".").first ?? "") else { continue }
|
||||
if major < 8 {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Legacy PHP detected", detail: "\(line). Verify vendor support and distribution security maintenance."))
|
||||
}
|
||||
}
|
||||
for line in result.packageOrigins where line.contains("installed=") && line.contains("candidate=") {
|
||||
let parts = line.split(separator: " ")
|
||||
guard let name = parts.first, name.hasPrefix("php") else { continue }
|
||||
let installed = parts.first(where: { $0.hasPrefix("installed=") }).map { String($0.dropFirst(10)) } ?? ""
|
||||
let candidate = parts.first(where: { $0.hasPrefix("candidate=") }).map { String($0.dropFirst(10)) } ?? ""
|
||||
if !installed.isEmpty && installed == candidate {
|
||||
// Equal revisions indicate no available APT upgrade, not confirmed CVE remediation.
|
||||
continue
|
||||
}
|
||||
if !installed.isEmpty && !candidate.isEmpty && candidate != "(none)" {
|
||||
result.findings.append(Finding(severity: "Info", title: "Package revision review", detail: "\(name): installed \(installed), candidate \(candidate). Confirm with vendor advisory and dpkg version ordering."))
|
||||
}
|
||||
}
|
||||
for line in result.availableUpdates where line.hasPrefix("php") && line.contains("/") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "PHP package update available", detail: line + ". Review the package changelog and vendor security notice before classifying CVE exposure."))
|
||||
}
|
||||
if result.packageOrigins.contains("APT metadata unavailable") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Package provenance unavailable", detail: "Could not inspect APT package origins; vendor security fixes cannot be verified."))
|
||||
}
|
||||
for line in result.dockerPHP {
|
||||
if line.contains("PHP 8.5.10") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Container PHP requires patch review", detail: line + ". Review against the PHP 8.5 security release and the container maintainer's patched image; version alone does not prove exposure."))
|
||||
}
|
||||
}
|
||||
for line in result.dockerPHP {
|
||||
result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Compare the container image against its maintainer security advisories; host APT updates do not patch container PHP."))
|
||||
}
|
||||
if !result.dockerPHP.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "Container update workflow", detail: "PHP container images must be updated and redeployed separately. Review image digests, upstream release notes, and image-specific security fixes before changing production services."))
|
||||
}
|
||||
if result.dockerAuditStatus.hasPrefix("Docker inaccessible") {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Docker coverage unavailable", detail: result.dockerAuditStatus))
|
||||
}
|
||||
if !result.webErrorSignals.isEmpty {
|
||||
result.findings.append(Finding(severity: "Warning", title: "Web server error signals", detail: "\(result.webErrorSignals.count) potentially relevant log entries. Review context; errors alone do not establish an attack."))
|
||||
}
|
||||
if !result.recentPHPFiles.isEmpty {
|
||||
result.findings.append(Finding(severity: "Info", title: "Recently modified PHP files", detail: "\(result.recentPHPFiles.count) PHP files changed in the last 3 days (up to 40 shown). Compare with expected deployments."))
|
||||
}
|
||||
if result.investigationStatus.hasPrefix("Partial") ||
|
||||
result.investigationStatus.hasPrefix("Limited") {
|
||||
result.findings.append(Finding(severity: "Info", title: "Investigation coverage limited", detail: result.investigationStatus))
|
||||
}
|
||||
result.findings.append(Finding(severity: "Info", title: "CVE status unverified", detail: "Version strings and available upgrades do not establish CVE exposure. Ubuntu backports, package origins, and current vendor advisories require independent verification. No server is labelled secure based on this scan."))
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -118,3 +118,63 @@ enum VulnerabilityService {
|
||||
.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? }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user