413 lines
20 KiB
Swift
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
|
|
}
|
|
}
|