From 702d702ca5b51bdf184618e945889ad1ed3e9eb4 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Thu, 22 Oct 2026 12:37:02 +0100 Subject: [PATCH] Initial Commit --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 4 + PHPGuard.xcodeproj/project.pbxproj | 194 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../UserInterfaceState.xcuserstate | Bin 0 -> 12576 bytes .../xcschemes/xcschememanagement.plist | 14 ++ PHPGuard/AuditStore.swift | 59 ++++++ PHPGuard/ContentView.swift | 181 ++++++++++++++++ PHPGuard/Models.swift | 33 +++ PHPGuard/PHPGuardApp.swift | 11 + PHPGuard/SSHScanner.swift | 97 +++++++++ README.md | 38 ++++ 12 files changed, 638 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 PHPGuard.xcodeproj/project.pbxproj create mode 100644 PHPGuard.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 PHPGuard.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 PHPGuard.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 PHPGuard/AuditStore.swift create mode 100644 PHPGuard/ContentView.swift create mode 100644 PHPGuard/Models.swift create mode 100644 PHPGuard/PHPGuardApp.swift create mode 100644 PHPGuard/SSHScanner.swift create mode 100644 README.md diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e8ba41694188b6fc0226cd16524a50968e59e859 GIT binary patch literal 6148 zcmeHKy-EW?5T4N#2R6Y%EG+g3guKBTE@&k}DmzV16iK*42v}}m<&*gazK-AgC^@_$ zScu3B%zksbGrKz<-0lt$@pRKnh{i-zK?PZi0g>*(p*?pV0a@qR&@DYtL#OmSFwtM^ zlD!|&6C2{$(Yuvi6>&2>Fz`uIGemyywy`AkI z+x$4Td)VG@!IB}tKrj#t1Ovf+&K z(JHW|mXMg_m>r8DMj&jVKnrCrG1$V$mjeu1(MzsEGI#F(9yej{tng gIdZInHlD + + + + diff --git a/PHPGuard.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate b/PHPGuard.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..0f245f4dac05fa97d622b1f0cc2c6811b7430dae GIT binary patch literal 12576 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 AsQ>@~ literal 0 HcmV?d00001 diff --git a/PHPGuard.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist b/PHPGuard.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..d59716a --- /dev/null +++ b/PHPGuard.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + PHPGuard.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/PHPGuard/AuditStore.swift b/PHPGuard/AuditStore.swift new file mode 100644 index 0000000..b79ae38 --- /dev/null +++ b/PHPGuard/AuditStore.swift @@ -0,0 +1,59 @@ +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? + + 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() { + servers = load("servers.json") ?? [] + results = load("results.json") ?? [] + } + 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 func saveServers() { save(servers, as: "servers.json") } + private func saveResults() { save(Array(results.prefix(250)), 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 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 new file mode 100644 index 0000000..17c2cab --- /dev/null +++ b/PHPGuard/ContentView.swift @@ -0,0 +1,181 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var store: AuditStore + @State private var selection: UUID? + @State private var showingEditor = false + @State private var editing: Server? + 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("Scan history", systemImage: "clock.arrow.circlepath").tag(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + } + } + .navigationTitle("PHPGuard") + .toolbar { + Button { editing = nil; showingEditor = true } label: { Image(systemName: "plus") }.help("Add server") + } + } detail: { + 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("Audits are read-only. CVE matching, package backport verification, Docker inspection, and compromise detection are not implemented in this initial build.") + .foregroundStyle(.secondary) + Button("Scan all servers") { store.scanAll() }.buttonStyle(.borderedProminent).disabled(store.servers.isEmpty) + 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 { + Text("Last scan: \(result.scannedAt.formatted())").foregroundStyle(.secondary) + 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) + Text("Findings").font(.title2.bold()) + ForEach(result.findings) { finding in + VStack(alignment: .leading, spacing: 4) { + Text("\(finding.severity): \(finding.title)").font(.headline) + Text(finding.detail).foregroundStyle(.secondary) + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.3), in: RoundedRectangle(cornerRadius: 10)) + } + Button("Export JSON report") { store.export(result) } + } + } 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) { + Text(title).font(.title2.bold()) + if values.isEmpty { Text("None detected").foregroundStyle(.secondary) } + else { ForEach(values, id: \.self) { Text($0).font(.system(.body, design: .monospaced)).textSelection(.enabled) } } + } + } + private var historyView: some View { + List(store.results) { result in + HStack { + VStack(alignment: .leading) { + Text(result.serverName).font(.headline) + Text(result.scannedAt.formatted()).foregroundStyle(.secondary) + if let error = result.error { Text(error).foregroundStyle(.red).lineLimit(2) } + } + Spacer() + Button("Export") { store.export(result) }.disabled(result.error != nil) + }.padding(.vertical, 4) + }.navigationTitle("Scan history") + } +} + +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: 18) { + Text(existing == nil ? "Add server" : "Edit server").font(.title.bold()) + Form { + TextField("Display name", text: $server.name) + TextField("Hostname / IP", text: $server.host) + TextField("SSH username", text: $server.user) + TextField("SSH port", value: $server.port, format: .number) + TextField("SSH private key path (optional)", text: $server.identityFile) + }.formStyle(.grouped) + Text("SSH uses your existing keys and known_hosts. Password prompts are disabled; first connect using Terminal to trust the host key.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Spacer() + Button("Cancel") { dismiss() } + Button("Save") { onSave(server); dismiss() }.buttonStyle(.borderedProminent) + .disabled(server.name.isEmpty || server.host.isEmpty || server.user.isEmpty) + } + }.padding(24).frame(width: 520) + } +} + +struct SettingsView: View { + var body: some View { + Form { + LabeledContent("SSH", value: "System OpenSSH, key-only") + LabeledContent("Host verification", value: "Strict") + LabeledContent("Scan mode", value: "Read-only") + Text("Server inventory and reports are stored locally in Application Support/PHPGuard.") + .foregroundStyle(.secondary) + }.padding(24).frame(width: 500, height: 220) + } +} diff --git a/PHPGuard/Models.swift b/PHPGuard/Models.swift new file mode 100644 index 0000000..18b7eec --- /dev/null +++ b/PHPGuard/Models.swift @@ -0,0 +1,33 @@ +import Foundation + +struct Server: Codable, Identifiable, Hashable { + var id = UUID() + var name: String + var host: String + var user: String = "mag" + var port: Int = 22 + var identityFile: String = "" +} + +struct Finding: Codable, Identifiable { + var id = UUID() + var severity: String + var title: String + var detail: String +} + +struct AuditResult: Codable, Identifiable { + var id = UUID() + var serverID: UUID + var serverName: String + var scannedAt = Date() + var hostname = "" + var os = "" + var phpVersions: [String] = [] + var fpmServices: [String] = [] + var nginxUpstreams: [String] = [] + var packageVersions: [String] = [] + var findings: [Finding] = [] + var rawOutput = "" + var error: String? = nil +} diff --git a/PHPGuard/PHPGuardApp.swift b/PHPGuard/PHPGuardApp.swift new file mode 100644 index 0000000..f43117d --- /dev/null +++ b/PHPGuard/PHPGuardApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct PHPGuardApp: App { + @StateObject private var store = AuditStore() + var body: some Scene { + WindowGroup("PHPGuard") { ContentView().environmentObject(store) } + .defaultSize(width: 1120, height: 720) + Settings { SettingsView().environmentObject(store) } + } +} diff --git a/PHPGuard/SSHScanner.swift b/PHPGuard/SSHScanner.swift new file mode 100644 index 0000000..7a06ad1 --- /dev/null +++ b/PHPGuard/SSHScanner.swift @@ -0,0 +1,97 @@ +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 '===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"] ?? [] + 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.")) + } + } + result.findings.append(Finding(severity: "Info", title: "CVE assessment not performed", detail: "Installed package versions must be checked against current vendor advisories and backported fixes. A version string alone cannot establish vulnerability.")) + return result + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..b93a9f1 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# PHPGuard — macOS PHP Inventory Auditor (Phase 1) + +Native SwiftUI macOS application for **read-only** PHP inventory scans over SSH. + +## Open and run + +1. Unzip and open `PHPGuard.xcodeproj` in Xcode 16 or later. +2. Select the PHPGuard scheme and **My Mac** destination. +3. Set your signing team in Signing & Capabilities if required. +4. Build and Run (⌘R). +5. Add a server (SSH hostname, username, port and optional identity file). +6. Ensure you can first connect from Terminal using SSH and that its host key is in `~/.ssh/known_hosts`. +7. Click **Scan now**. Scan history and JSON export are available. + +The app uses `/usr/bin/ssh` with `BatchMode=yes` and strict host key checking. It does not store passwords or change remote systems. SSH keys must already be configured. For the initial release, the app runs unsandboxed so it can invoke the system SSH executable and access existing SSH configuration. + +## Implemented + +- Native macOS SwiftUI navigation and dashboard +- Add/edit/remove SSH servers; optional private-key path +- Concurrent read-only scans of PHP CLI binaries, PHP-FPM services, Nginx FastCGI routes, Debian/Ubuntu PHP package inventory +- Local JSON persistence and history +- JSON report export +- SSH error reporting and strict host key checking + +## Not yet implemented + +- Live CVE feeds, Ubuntu package backport verification or definitive vulnerability status +- Docker and Apache scans +- Intrusion detection and web-log investigation +- PDF reports, scheduled scans, notifications, automated patching +- Integration with PegasusMonitor + +**Important:** This version is an inventory tool, not a complete vulnerability or compromise scanner. It intentionally does not label a server secure solely from a version string. + +## Privacy + +Server definitions and scan results are stored in `~/Library/Application Support/PHPGuard/`. Scan reports may contain internal hostnames and package inventory. Do not publish them publicly.