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