Files
PHPGuard/PHPGuard/SSHScanner.swift
T

413 lines
20 KiB
Swift

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)
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"
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
}
}