diff --git a/PHPGuard.xcodeproj/project.pbxproj.backup-20261022-133542 b/PHPGuard.xcodeproj/project.pbxproj.backup-20261022-133542 new file mode 100644 index 0000000..1fdb41b --- /dev/null +++ b/PHPGuard.xcodeproj/project.pbxproj.backup-20261022-133542 @@ -0,0 +1,233 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + C10000000000000000000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C10000000000000000000001 /* Assets.xcassets */; }; + A10000000000000000000002 /* VulnerabilityService.swift in Sources */ = { + isa = PBXBuildFile; + fileRef = A10000000000000000000001 /* VulnerabilityService.swift */; + }; + + 0000000000000000000000C8 /* PHPGuardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000064 /* PHPGuardApp.swift */; }; + 0000000000000000000000C9 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000065 /* Models.swift */; }; + 0000000000000000000000CA /* SSHScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000066 /* SSHScanner.swift */; }; + 0000000000000000000000CB /* AuditStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000067 /* AuditStore.swift */; }; + 0000000000000000000000CC /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 000000000000000000000068 /* ContentView.swift */; }; + B20000000000000000000001 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000001 /* SettingsView.swift */; }; + B20000000000000000000002 /* ReportPDF.swift in Sources */ = {isa = PBXBuildFile; fileRef = B10000000000000000000002 /* ReportPDF.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + C10000000000000000000001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A10000000000000000000001 /* VulnerabilityService.swift */ = { + isa = PBXFileReference; + lastKnownFileType = sourcecode.swift; + path = VulnerabilityService.swift; + sourceTree = ""; + }; + + 000000000000000000000030 /* PHPGuard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PHPGuard.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 000000000000000000000064 /* PHPGuardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHPGuardApp.swift; sourceTree = ""; }; + 000000000000000000000065 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 000000000000000000000066 /* SSHScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHScanner.swift; sourceTree = ""; }; + 000000000000000000000067 /* AuditStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditStore.swift; sourceTree = ""; }; + 000000000000000000000068 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B10000000000000000000001 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B10000000000000000000002 /* ReportPDF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportPDF.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 000000000000000000000010 = { + isa = PBXGroup; + children = ( + 000000000000000000000011 /* PHPGuard */, + 000000000000000000000012 /* Products */, + ); + sourceTree = ""; + }; + 000000000000000000000011 /* PHPGuard */ = { + isa = PBXGroup; + children = ( + 000000000000000000000064 /* PHPGuardApp.swift */, + 000000000000000000000065 /* Models.swift */, + 000000000000000000000066 /* SSHScanner.swift */, + 000000000000000000000067 /* AuditStore.swift */, + A10000000000000000000001 /* VulnerabilityService.swift */, + 000000000000000000000068 /* ContentView.swift */, + B10000000000000000000002 /* ReportPDF.swift */, + B10000000000000000000001 /* SettingsView.swift */, + ); + path = PHPGuard; + sourceTree = ""; + }; + 000000000000000000000012 /* Products */ = { + isa = PBXGroup; + children = ( + 000000000000000000000030 /* PHPGuard.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 000000000000000000000020 /* PHPGuard */ = { + isa = PBXNativeTarget; + buildConfigurationList = 000000000000000000000042 /* Build configuration list for PBXNativeTarget "PHPGuard" */; + buildPhases = ( + 000000000000000000000021 /* Sources */, + B10000000000000000000003 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PHPGuard; + productName = PHPGuard; + productReference = 000000000000000000000030 /* PHPGuard.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 000000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1600; + TargetAttributes = { + 000000000000000000000020 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 000000000000000000000041 /* Build configuration list for PBXProject "PHPGuard" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 000000000000000000000010; + productRefGroup = 000000000000000000000012 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 000000000000000000000020 /* PHPGuard */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B10000000000000000000003 /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + C10000000000000000000002 /* Assets.xcassets in Resources */, + ); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 000000000000000000000021 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0000000000000000000000C8 /* PHPGuardApp.swift in Sources */, + 0000000000000000000000C9 /* Models.swift in Sources */, + 0000000000000000000000CA /* SSHScanner.swift in Sources */, + 0000000000000000000000CB /* AuditStore.swift in Sources */, + 0000000000000000000000CC /* ContentView.swift in Sources */, + A10000000000000000000002 /* VulnerabilityService.swift in Sources */, + B20000000000000000000002 /* ReportPDF.swift in Sources */, + B20000000000000000000001 /* SettingsView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 000000000000000000000051 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 000000000000000000000052 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 000000000000000000000053 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; + MARKETING_VERSION = 1.9.0; + CURRENT_PROJECT_VERSION = 10; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PHPGuard; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 000000000000000000000054 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + GENERATE_INFOPLIST_FILE = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + INFOPLIST_KEY_CFBundleDisplayName = PHPGuard; + MARKETING_VERSION = 1.9.0; + CURRENT_PROJECT_VERSION = 10; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.PHPGuard; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 000000000000000000000041 /* Build configuration list for PBXProject "PHPGuard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 000000000000000000000051 /* Debug */, + 000000000000000000000052 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 000000000000000000000042 /* Build configuration list for PBXNativeTarget "PHPGuard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 000000000000000000000053 /* Debug */, + 000000000000000000000054 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 000000000000000000000001 /* Project object */; +} diff --git a/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json b/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json index 6157218..83842e9 100644 --- a/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/PHPGuard/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,14 @@ { "images": [ { - "idiom": "mac", - "size": "16x16", - "scale": "1x", - "filename": "icon_16x16_1x.png" - }, - { - "idiom": "mac", - "size": "16x16", - "scale": "2x", - "filename": "icon_16x16_2x.png" - }, - { - "idiom": "mac", - "size": "32x32", - "scale": "1x", - "filename": "icon_32x32_1x.png" - }, - { - "idiom": "mac", - "size": "32x32", - "scale": "2x", - "filename": "icon_32x32_2x.png" - }, - { - "idiom": "mac", - "size": "128x128", - "scale": "1x", - "filename": "icon_128x128_1x.png" - }, - { - "idiom": "mac", - "size": "128x128", - "scale": "2x", - "filename": "icon_128x128_2x.png" - }, - { - "idiom": "mac", - "size": "256x256", - "scale": "1x", - "filename": "icon_256x256_1x.png" - }, - { - "idiom": "mac", - "size": "256x256", - "scale": "2x", - "filename": "icon_256x256_2x.png" - }, - { - "idiom": "mac", - "size": "512x512", - "scale": "1x", - "filename": "icon_512x512_1x.png" - }, - { - "idiom": "mac", - "size": "512x512", - "scale": "2x", - "filename": "icon_512x512_2x.png" + "filename": "icon-1024.png", + "idiom": "universal", + "platform": "macos", + "size": "1024x1024" } ], "info": { "author": "xcode", "version": 1 } -} \ No newline at end of file +} diff --git a/PHPGuard/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/PHPGuard/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..202b808 Binary files /dev/null and b/PHPGuard/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/PHPGuard/AuditStore.swift b/PHPGuard/AuditStore.swift index 1efbfa7..b5f13cd 100644 --- a/PHPGuard/AuditStore.swift +++ b/PHPGuard/AuditStore.swift @@ -5,6 +5,11 @@ import SwiftUI @Published var servers: [Server] = [] { didSet { saveServers() } } @Published var results: [AuditResult] = [] { didSet { saveResults() } } @Published var scanning: Set = [] + @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() } } @@ -83,9 +88,20 @@ import SwiftUI 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) } + 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) diff --git a/PHPGuard/AuditStore.swift.backup-20261022-134114 b/PHPGuard/AuditStore.swift.backup-20261022-134114 new file mode 100644 index 0000000..1efbfa7 --- /dev/null +++ b/PHPGuard/AuditStore.swift.backup-20261022-134114 @@ -0,0 +1,149 @@ +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 = [] + @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(_ 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(_ 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 } + scanning.insert(server.id) + Task { + defer { scanning.remove(server.id) } + 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 } + } + } +} diff --git a/PHPGuard/ContentView.swift b/PHPGuard/ContentView.swift index f9d3a24..164e934 100644 --- a/PHPGuard/ContentView.swift +++ b/PHPGuard/ContentView.swift @@ -58,6 +58,10 @@ struct ContentView: View { 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 } @@ -317,4 +321,141 @@ struct ServerEditor: View { } } +// 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 + ) + } +} diff --git a/PHPGuard/ContentView.swift.backup-20261022-134114 b/PHPGuard/ContentView.swift.backup-20261022-134114 new file mode 100644 index 0000000..f9d3a24 --- /dev/null +++ b/PHPGuard/ContentView.swift.backup-20261022-134114 @@ -0,0 +1,320 @@ +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: $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) + } +} + + diff --git a/PHPGuard/ContentView.swift.backup-20261022-134422 b/PHPGuard/ContentView.swift.backup-20261022-134422 new file mode 100644 index 0000000..6203618 --- /dev/null +++ b/PHPGuard/ContentView.swift.backup-20261022-134422 @@ -0,0 +1,324 @@ +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) + } +} + + diff --git a/PHPGuard/SSHScanner.swift b/PHPGuard/SSHScanner.swift index ef0d73e..cff3289 100644 --- a/PHPGuard/SSHScanner.swift +++ b/PHPGuard/SSHScanner.swift @@ -51,8 +51,11 @@ enum SSHScanner { 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 || true) - if [ -n "$version" ]; then + 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" @@ -66,10 +69,81 @@ enum SSHScanner { printf 'Web root unavailable\n' fi printf '===WEB_ERROR_SIGNALS===\n' - if [ -r /var/log/nginx/error.log ]; then - tail -n 1000 /var/log/nginx/error.log 2>/dev/null | grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory' | tail -30 || true + + 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 'Nginx error log unavailable (permissions or absent)\n' + printf 'STATUS|complete|checked=%s\n' "$checked" fi printf '===END===\n' """# @@ -133,9 +207,44 @@ enum SSHScanner { result.availableUpdates = fields["UPDATES"] ?? [] result.packageOrigins = fields["PACKAGE_ORIGINS"] ?? [] result.recentPHPFiles = (fields["RECENT_PHP_FILES"] ?? []).filter { $0 != "Web root unavailable" } - result.webErrorSignals = (fields["WEB_ERROR_SIGNALS"] ?? []).filter { !$0.hasPrefix("Nginx error log unavailable") } - result.investigationStatus = (fields["WEB_ERROR_SIGNALS"] ?? []).contains(where: { $0.hasPrefix("Nginx error log unavailable") }) - ? "Partial: Nginx error log inaccessible or absent" : "Read-only indicators collected (not proof of compromise)" + 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) @@ -160,17 +269,91 @@ enum SSHScanner { 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" - result.securityTriage = [ - "Package revisions and available updates are inventory evidence, not verified CVE remediation.", - "Compare installed packages against applicable Ubuntu Security Notices or third-party maintainer advisories.", - "Check PHP containers independently: host package updates do not update container images." - ] + // 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 { - result.securityTriage.append("Review each detected container PHP version against its image maintainer's fixed releases; prioritize older container runtimes.") + 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") { - result.securityTriage.append("Docker scan incomplete: grant appropriate read access or perform an independent container inventory.") + 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.")) } diff --git a/PHPGuard/SSHScanner.swift.backup-20261022-132956 b/PHPGuard/SSHScanner.swift.backup-20261022-132956 new file mode 100644 index 0000000..ef0d73e --- /dev/null +++ b/PHPGuard/SSHScanner.swift.backup-20261022-132956 @@ -0,0 +1,229 @@ +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 || true) + if [ -n "$version" ]; 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' + if [ -r /var/log/nginx/error.log ]; then + tail -n 1000 /var/log/nginx/error.log 2>/dev/null | grep -Ei 'segfault|upstream prematurely closed|PHP Fatal|out of memory' | tail -30 || true + else + printf 'Nginx error log unavailable (permissions or absent)\n' + 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" } + result.webErrorSignals = (fields["WEB_ERROR_SIGNALS"] ?? []).filter { !$0.hasPrefix("Nginx error log unavailable") } + result.investigationStatus = (fields["WEB_ERROR_SIGNALS"] ?? []).contains(where: { $0.hasPrefix("Nginx error log unavailable") }) + ? "Partial: Nginx error log inaccessible or absent" : "Read-only indicators collected (not proof of compromise)" + 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" + result.securityTriage = [ + "Package revisions and available updates are inventory evidence, not verified CVE remediation.", + "Compare installed packages against applicable Ubuntu Security Notices or third-party maintainer advisories.", + "Check PHP containers independently: host package updates do not update container images." + ] + if !result.dockerPHP.isEmpty { + result.securityTriage.append("Review each detected container PHP version against its image maintainer's fixed releases; prioritize older container runtimes.") + } + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + result.securityTriage.append("Docker scan incomplete: grant appropriate read access or perform an independent container inventory.") + } + 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 + } +} diff --git a/PHPGuard/SSHScanner.swift.backup-20261022-133513 b/PHPGuard/SSHScanner.swift.backup-20261022-133513 new file mode 100644 index 0000000..e022b5c --- /dev/null +++ b/PHPGuard/SSHScanner.swift.backup-20261022-133513 @@ -0,0 +1,335 @@ +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 || true) + if [ -n "$version" ]; 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" + result.securityTriage = [ + "Package revisions and available updates are inventory evidence, not verified CVE remediation.", + "Compare installed packages against applicable Ubuntu Security Notices or third-party maintainer advisories.", + "Check PHP containers independently: host package updates do not update container images." + ] + if !result.dockerPHP.isEmpty { + result.securityTriage.append("Review each detected container PHP version against its image maintainer's fixed releases; prioritize older container runtimes.") + } + if result.dockerAuditStatus.hasPrefix("Docker inaccessible") { + result.securityTriage.append("Docker scan incomplete: grant appropriate read access or perform an independent container inventory.") + } + 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 + } +} diff --git a/PHPGuard/SSHScanner.swift.backup-20261022-133939 b/PHPGuard/SSHScanner.swift.backup-20261022-133939 new file mode 100644 index 0000000..1e9389c --- /dev/null +++ b/PHPGuard/SSHScanner.swift.backup-20261022-133939 @@ -0,0 +1,409 @@ +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 || true) + if [ -n "$version" ]; 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 + } +} diff --git a/PHPGuard/ScanProgressView.swift.disabled b/PHPGuard/ScanProgressView.swift.disabled new file mode 100644 index 0000000..c57269f --- /dev/null +++ b/PHPGuard/ScanProgressView.swift.disabled @@ -0,0 +1,138 @@ +import SwiftUI + +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 + ) + } +}