Files
PHPGuard/PHPGuard/AuditStore.swift
T

272 lines
12 KiB
Swift

import Foundation
import SwiftUI
@MainActor final class AuditStore: ObservableObject {
@Published var servers: [Server] = [] { didSet { saveServers() } }
@Published var results: [AuditResult] = [] { didSet { saveResults() } }
@Published var scanning: Set<UUID> = []
@Published var scanProgressVisible = false
@Published var scanStartedAt = Date()
@Published var scanServerName = ""
@Published var scanServerHost = ""
@Published var scanStage = "Preparing scan"
@Published var scanDetail = ""
@Published var scanFraction = 0.0
@Published var scanCompletedChecks = 0
@Published var scanTotalChecks = 0
@Published var lastError: String?
@Published var discoveredCVEs: [CVERecord] = [] { didSet { saveCVECache() } }
@Published var cveLastChecked: Date? { didSet { saveCVECache() } }
@Published var cveError: String?
@Published var fetchingCVEs = false
private var folder: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let path = base.appendingPathComponent("PHPGuard", isDirectory: true)
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
return path
}
init() {
let defaults = UserDefaults.standard
autoRefreshCVEs = defaults.object(forKey: "phpguard.autoRefreshCVEs") as? Bool ?? true
showTechnicalDetails = defaults.bool(forKey: "phpguard.showTechnicalDetails")
confirmBeforeClearing = defaults.object(forKey: "phpguard.confirmClear") as? Bool ?? true
preferredReportAccent = defaults.string(forKey: "phpguard.reportAccent") ?? "Blue"
retainHistoryLimit = defaults.object(forKey: "phpguard.historyLimit") as? Int ?? 250
servers = load("servers.json") ?? []
results = (load("results.json") ?? []).map { Self.normalized($0) }
if let cache: CVECache = load("cve-cache.json") {
discoveredCVEs = cache.records
cveLastChecked = cache.fetchedAt
}
}
private static func normalized(_ source: AuditResult) -> AuditResult {
var result = source
result.dockerPHP = source.dockerPHP.filter {
!$0.contains("OCI runtime exec failed") && !$0.contains("executable file not found")
}
result.findings.removeAll {
$0.title == "PHP runtime in Docker" &&
($0.detail.contains("OCI runtime exec failed") || $0.detail.contains("executable file not found"))
}
if result.dockerPHP.count != source.dockerPHP.count {
result.dockerAuditStatus = "Legacy scan: missing PHP executables excluded; rescan for structured Docker coverage"
}
return result
}
func importReport() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let report = Self.normalized(try decoder.decode(AuditResult.self, from: Data(contentsOf: url)))
guard !results.contains(where: { $0.id == report.id }) else {
lastError = "This report has already been imported."
return
}
results.insert(report, at: 0)
} catch { lastError = "Could not import JSON report: \(error.localizedDescription)" }
}
private func load<T: Decodable>(_ name: String) -> T? {
guard let data = try? Data(contentsOf: folder.appendingPathComponent(name)) else { return nil }
return try? JSONDecoder().decode(T.self, from: data)
}
private func save<T: Encodable>(_ value: T, as name: String) {
guard let data = try? JSONEncoder().encode(value) else { return }
try? data.write(to: folder.appendingPathComponent(name), options: .atomic)
}
private struct CVECache: Codable {
let records: [CVERecord]
let fetchedAt: Date?
}
private func saveCVECache() {
// Do not overwrite a previously successful download with an empty response.
guard !discoveredCVEs.isEmpty else { return }
save(CVECache(records: discoveredCVEs, fetchedAt: cveLastChecked), as: "cve-cache.json")
}
private func saveServers() { save(servers, as: "servers.json") }
private func saveResults() { save(Array(results.prefix(max(1, retainHistoryLimit))), as: "results.json") }
func latest(for server: Server) -> AuditResult? { results.first { $0.serverID == server.id } }
func scan(_ server: Server) {
guard !scanning.contains(server.id) else { return }
if scanning.isEmpty {
scanStartedAt = Date()
}
scanServerName = server.name
scanServerHost = server.host
scanProgressVisible = true
scanStage = "Connecting to server"
scanDetail = "Establishing SSH connection and collecting evidence"
scanFraction = 0.05
scanCompletedChecks = 0
scanTotalChecks = 0
scanning.insert(server.id)
Task {
defer {
scanning.remove(server.id)
if scanning.isEmpty {
scanProgressVisible = false
}
}
do {
var report = try await SSHScanner.scan(server)
scanStage = "SSH assessment complete"
scanDetail = "Analysing PHP runtimes and preparing vulnerability checks"
scanFraction = 0.60
report.cveAssessmentStatus = "Checking NVD PHP product applicability"
let runtimeLines = report.phpVersions + report.dockerPHP
var versions: [String: String] = [:]
for line in runtimeLines {
let tokens = line.split(whereSeparator: { $0.isWhitespace })
guard let token = tokens.last else { continue }
let version = String(token)
let parts = version.split(separator: ".")
guard parts.count >= 3,
parts.prefix(3).allSatisfy({ Int($0) != nil }) else { continue }
let canonical = parts.prefix(3).joined(separator: ".")
versions[canonical] = "PHP " + canonical
}
scanTotalChecks = versions.count
scanCompletedChecks = 0
if versions.isEmpty {
scanStage = "No PHP versions detected"
scanDetail = "Skipping NVD vulnerability assessment"
scanFraction = 0.95
report.cveAssessmentStatus = "Not assessed: no parseable PHP runtime version"
} else {
var all: [CVEServerAssessment] = []
var failures: [String] = []
// Sequential requests respect unauthenticated NVD API rate limits.
for version in versions.keys.sorted() {
scanStage = "Checking PHP vulnerabilities"
scanDetail = "Querying NVD for PHP " + version
do {
let records = try await VulnerabilityService.assessPHPVersion(
version, runtime: versions[version] ?? version)
all.append(contentsOf: records)
} catch {
failures.append("PHP \(version): \(error.localizedDescription)")
}
scanCompletedChecks += 1
scanFraction = 0.60 + (
0.35 * Double(scanCompletedChecks) /
Double(max(1, scanTotalChecks))
)
}
scanStage = "Checking vendor advisories"
scanDetail = "Verifying available vendor evidence; unsupported distributions remain unverified"
scanFraction = 0.95
if report.ubuntuReleaseID == "ubuntu" {
all = await VulnerabilityService.assessUbuntu(
all,
report: report
)
}
// NVD CPE matches are candidates, never proof of host exposure.
// Only Ubuntu has a vendor advisory adapter at present.
if report.ubuntuReleaseID != "ubuntu" {
all = all.map { candidate in
var item = candidate
item.vendorStatus = "Unverified"
item.vendorEvidence =
"No vendor advisory adapter for this distribution. " +
"NVD upstream version matches cannot establish package exposure."
return item
}
}
report.cveAssessments = all.sorted { $0.cveID > $1.cveID }
let verifiedCount = all.filter {
["Vulnerable", "Patched", "Not affected"].contains($0.vendorStatus ?? "")
}.count
report.cveAssessmentStatus = failures.isEmpty
? "NVD candidates: \(all.count); vendor-verified: \(verifiedCount). " +
"Unverified entries are not security clearance."
: "Partial/unknown: " + failures.joined(separator: "; ")
report.cveAssessedAt = Date()
}
scanStage = "Saving assessment"
scanDetail = "Writing the completed security report"
scanFraction = 0.98
results.insert(report, at: 0)
scanStage = "Assessment complete"
scanDetail = "Security report saved"
scanFraction = 1.0
}
catch {
var failed = AuditResult(serverID: server.id, serverName: server.name)
failed.error = error.localizedDescription
results.insert(failed, at: 0)
lastError = "\(server.name): \(error.localizedDescription)"
}
}
}
func scanAll() { servers.forEach { scan($0) } }
func refreshCVEsOnLaunch() {
// Preserve the local cache while checking NVD for a newer result set.
if autoRefreshCVEs { refreshCVEs() }
}
func refreshCVEs() {
guard !fetchingCVEs else { return }
fetchingCVEs = true
cveError = nil
Task {
defer { fetchingCVEs = false }
do {
let downloaded = try await VulnerabilityService.discover()
guard !downloaded.isEmpty else {
cveError = "NVD returned no records; keeping the previous saved results."
return
}
discoveredCVEs = downloaded
cveLastChecked = Date()
} catch { cveError = error.localizedDescription }
}
}
@Published var showTechnicalDetails = false
@Published var confirmBeforeClearing = true
@Published var preferredReportAccent = "Blue"
@Published var autoRefreshCVEs = true
@Published var retainHistoryLimit = 250
func clearHistory() { results.removeAll() }
func removeHistory(_ offsets: IndexSet) { results.remove(atOffsets: offsets) }
func exportPDF(_ result: AuditResult) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.pdf]
panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).pdf"
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try ReportPDF.write(result, to: url, accent: preferredReportAccent) }
catch { lastError = "PDF export failed: \(error.localizedDescription)" }
}
func export(_ result: AuditResult) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "PHPGuard-\(result.serverName)-\(ISO8601DateFormatter().string(from: result.scannedAt)).json"
if panel.runModal() == .OK, let url = panel.url {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(result).write(to: url)
} catch { lastError = error.localizedDescription }
}
}
}