272 lines
12 KiB
Swift
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 }
|
|
}
|
|
}
|
|
}
|