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.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" 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 { result.findings.append(Finding(severity: "Info", title: "Container PHP runtime detected", detail: line + ". Verify the container image and upstream PHP security release independently.")) } 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 } }