769 lines
20 KiB
PHP
769 lines
20 KiB
PHP
<?php
|
|
|
|
namespace Kirby\Cms\System;
|
|
|
|
use Composer\Semver\Semver;
|
|
use Exception;
|
|
use Kirby\Cms\App;
|
|
use Kirby\Cms\Plugin;
|
|
use Kirby\Exception\Exception as KirbyException;
|
|
use Kirby\Http\Remote;
|
|
use Kirby\Toolkit\I18n;
|
|
use Kirby\Toolkit\Str;
|
|
|
|
/**
|
|
* Checks for updates and affected vulnerabilities
|
|
* @since 3.8.0
|
|
*
|
|
* @package Kirby Cms
|
|
* @author Lukas Bestle <lukas@getkirby.com>
|
|
* @link https://getkirby.com
|
|
* @copyright Bastian Allgeier
|
|
* @license https://getkirby.com/license
|
|
*/
|
|
class UpdateStatus
|
|
{
|
|
/**
|
|
* Host to request the update data from
|
|
*/
|
|
public static string $host = 'https://assets.getkirby.com';
|
|
|
|
/**
|
|
* Marker that stores whether a previous remote
|
|
* request timed out
|
|
*/
|
|
protected static bool $timedOut = false;
|
|
|
|
// props set in constructor
|
|
protected App $app;
|
|
protected string|null $currentVersion;
|
|
protected array|null $data;
|
|
protected string|null $pluginName;
|
|
protected bool $securityOnly;
|
|
|
|
// props updated throughout the class
|
|
protected array $exceptions = [];
|
|
protected bool|null $noVulns = null;
|
|
|
|
// caches
|
|
protected array $messages;
|
|
protected array $targetData;
|
|
protected array|bool $versionEntry;
|
|
protected array $vulnerabilities;
|
|
|
|
/**
|
|
* @param array|null $data Custom override for the getkirby.com update data
|
|
*/
|
|
public function __construct(
|
|
App|Plugin $package,
|
|
bool $securityOnly = false,
|
|
array|null $data = null
|
|
) {
|
|
if ($package instanceof App) {
|
|
$this->app = $package;
|
|
$this->pluginName = null;
|
|
} else {
|
|
$this->app = $package->kirby();
|
|
$this->pluginName = $package->name();
|
|
}
|
|
|
|
$this->securityOnly = $securityOnly;
|
|
$this->currentVersion = $package->version();
|
|
|
|
$this->data = $data ?? $this->loadData();
|
|
}
|
|
|
|
/**
|
|
* Returns the currently installed version
|
|
*/
|
|
public function currentVersion(): string|null
|
|
{
|
|
return $this->currentVersion;
|
|
}
|
|
|
|
/**
|
|
* Returns the list of exception objects that were
|
|
* collected during data fetching and processing
|
|
*/
|
|
public function exceptions(): array
|
|
{
|
|
return $this->exceptions;
|
|
}
|
|
|
|
/**
|
|
* Returns the list of exception message strings that
|
|
* were collected during data fetching and processing
|
|
*/
|
|
public function exceptionMessages(): array
|
|
{
|
|
return array_map(fn ($e) => $e->getMessage(), $this->exceptions());
|
|
}
|
|
|
|
/**
|
|
* Returns the Panel icon for the status value
|
|
*
|
|
* @return string 'check'|'alert'|'info'
|
|
*/
|
|
public function icon(): string
|
|
{
|
|
return match ($this->status()) {
|
|
'up-to-date', 'not-vulnerable' => 'check',
|
|
'security-update', 'security-upgrade' => 'alert',
|
|
'update', 'upgrade' => 'info',
|
|
default => 'question'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the human-readable and translated label
|
|
* for the update status
|
|
*/
|
|
public function label(): string
|
|
{
|
|
return I18n::template(
|
|
'system.updateStatus.' . $this->status(),
|
|
'?',
|
|
['version' => $this->targetVersion() ?? '?']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Returns the latest available version
|
|
*/
|
|
public function latestVersion(): string|null
|
|
{
|
|
return $this->data['latest'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Returns all security messages unless no data
|
|
* is available
|
|
*/
|
|
public function messages(): array|null
|
|
{
|
|
if (isset($this->messages) === true) {
|
|
return $this->messages;
|
|
}
|
|
|
|
if (
|
|
$this->data === null ||
|
|
$this->currentVersion === null ||
|
|
$this->currentVersion === ''
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$type = $this->pluginName ? 'plugin' : 'kirby';
|
|
|
|
// collect all matching custom messages
|
|
$filters = [
|
|
'kirby' => $this->app->version(),
|
|
'php' => phpversion()
|
|
];
|
|
|
|
if ($type === 'plugin') {
|
|
$filters['plugin'] = $this->currentVersion;
|
|
}
|
|
|
|
$messages = $this->filterArrayByVersion(
|
|
$this->data['messages'] ?? [],
|
|
$filters,
|
|
'while filtering messages'
|
|
);
|
|
|
|
// add a message for each vulnerability
|
|
// the current version is affected by
|
|
foreach ($this->vulnerabilities() as $vulnerability) {
|
|
if ($type === 'plugin') {
|
|
$vulnerability['plugin'] = $this->pluginName;
|
|
}
|
|
|
|
$messages[] = [
|
|
'text' => I18n::template(
|
|
'system.issues.vulnerability.' . $type,
|
|
null,
|
|
$vulnerability
|
|
),
|
|
'link' => $vulnerability['link'] ?? null,
|
|
'icon' => 'bug'
|
|
];
|
|
}
|
|
|
|
// add special message for end-of-life versions
|
|
$versionEntry = $this->versionEntry();
|
|
if (($versionEntry['status'] ?? null) === 'end-of-life') {
|
|
$messages[] = [
|
|
'text' => match ($type) {
|
|
'plugin' => I18n::template(
|
|
'system.issues.eol.plugin',
|
|
null,
|
|
['plugin' => $this->pluginName]
|
|
),
|
|
default => I18n::translate('system.issues.eol.kirby')
|
|
},
|
|
'link' => $versionEntry['status-link'] ?? 'https://getkirby.com/security/end-of-life',
|
|
'icon' => 'bell'
|
|
];
|
|
}
|
|
|
|
return $this->messages = $messages;
|
|
}
|
|
|
|
/**
|
|
* Returns the raw status value
|
|
*
|
|
* @return string 'up-to-date'|'not-vulnerable'|'security-update'|
|
|
* 'security-upgrade'|'update'|'upgrade'|'unreleased'|'error'
|
|
*/
|
|
public function status(): string
|
|
{
|
|
return $this->targetData()['status'];
|
|
}
|
|
|
|
/**
|
|
* Version that is suggested for the update/upgrade
|
|
*/
|
|
public function targetVersion(): string|null
|
|
{
|
|
return $this->targetData()['version'];
|
|
}
|
|
|
|
/**
|
|
* Returns the Panel theme for the status value
|
|
*
|
|
* @return string 'positive'|'negative'|'info'|'notice'
|
|
*/
|
|
public function theme(): string
|
|
{
|
|
return match ($this->status()) {
|
|
'up-to-date', 'not-vulnerable' => 'positive',
|
|
'security-update', 'security-upgrade' => 'negative',
|
|
'update', 'upgrade' => 'info',
|
|
default => 'notice'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the most important human-readable
|
|
* status information as array
|
|
*/
|
|
public function toArray(): array
|
|
{
|
|
return [
|
|
'currentVersion' => $this->currentVersion() ?? '?',
|
|
'icon' => $this->icon(),
|
|
'label' => $this->label(),
|
|
'latestVersion' => $this->latestVersion() ?? '?',
|
|
'pluginName' => $this->pluginName,
|
|
'theme' => $this->theme(),
|
|
'url' => $this->url(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* URL of the target version with fallback
|
|
* to the URL of the current version;
|
|
* `null` is returned if no URL is known
|
|
*/
|
|
public function url(): string|null
|
|
{
|
|
return $this->targetData()['url'];
|
|
}
|
|
|
|
/**
|
|
* Returns all vulnerabilities the current version
|
|
* is affected by unless no data is available
|
|
*/
|
|
public function vulnerabilities(): array|null
|
|
{
|
|
if (isset($this->vulnerabilities) === true) {
|
|
return $this->vulnerabilities;
|
|
}
|
|
|
|
if (
|
|
$this->data === null ||
|
|
$this->currentVersion === null ||
|
|
$this->currentVersion === ''
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// shortcut for versions without vulnerabilities
|
|
$this->versionEntry();
|
|
if ($this->noVulns === true) {
|
|
return $this->vulnerabilities = [];
|
|
}
|
|
|
|
// unstable releases are released before their respective
|
|
// stable release and would not be matched by the constraints,
|
|
// but they will likely also contain the same vulnerabilities;
|
|
// so we strip off any non-numeric version modifiers from the end
|
|
preg_match('/^([0-9.]+)/', $this->currentVersion, $matches);
|
|
$currentVersion = $matches[1];
|
|
|
|
$vulnerabilities = $this->filterArrayByVersion(
|
|
$this->data['incidents'] ?? [],
|
|
['affected' => $currentVersion],
|
|
'while filtering incidents'
|
|
);
|
|
|
|
// sort the vulnerabilities by severity (with critical first)
|
|
$severities = array_map(
|
|
fn ($vulnerability) => match ($vulnerability['severity'] ?? null) {
|
|
'critical' => 4,
|
|
'high' => 3,
|
|
'medium' => 2,
|
|
'low' => 1,
|
|
default => 0
|
|
},
|
|
$vulnerabilities
|
|
);
|
|
array_multisort($severities, SORT_DESC, $vulnerabilities);
|
|
|
|
return $this->vulnerabilities = $vulnerabilities;
|
|
}
|
|
|
|
/**
|
|
* Compares a version against a Composer version constraint
|
|
* and returns whether the constraint is satisfied
|
|
*
|
|
* @param string $reason Suffix for error messages
|
|
*/
|
|
protected function checkConstraint(string $version, string $constraint, string $reason): bool
|
|
{
|
|
try {
|
|
return Semver::satisfies($version, $constraint);
|
|
} catch (Exception $e) {
|
|
$package = $this->packageName();
|
|
$message = 'Error comparing version constraint for ' . $package . ' ' . $reason . ': ' . $e->getMessage();
|
|
|
|
$exception = new KirbyException([
|
|
'fallback' => $message,
|
|
'previous' => $e
|
|
]);
|
|
$this->exceptions[] = $exception;
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Filters a two-level array with one or multiple version constraints
|
|
* for each value by one or multiple version filters;
|
|
* values that don't contain the filter keys are removed
|
|
*
|
|
* @param array $array Array that contains associative arrays
|
|
* @param array $filters Associative array `field => version`
|
|
* @param string $reason Suffix for error messages
|
|
*/
|
|
protected function filterArrayByVersion(array $array, array $filters, string $reason): array
|
|
{
|
|
return array_filter($array, function ($item) use ($filters, $reason): bool {
|
|
foreach ($filters as $key => $version) {
|
|
if (isset($item[$key]) !== true) {
|
|
$package = $this->packageName();
|
|
$this->exceptions[] = new KirbyException('Missing constraint ' . $key . ' for ' . $package . ' ' . $reason);
|
|
|
|
return false;
|
|
}
|
|
|
|
if ($this->checkConstraint($version, $item[$key], $reason) !== true) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Finds the minimum possible security update
|
|
* to fix all known vulnerabilities
|
|
*
|
|
* @return string|null Version number of the update or
|
|
* `null` if no free update is possible
|
|
*/
|
|
protected function findMinimumSecurityUpdate(): string|null
|
|
{
|
|
$versionEntry = $this->versionEntry();
|
|
if ($versionEntry === null || isset($versionEntry['latest']) !== true) {
|
|
return null; // @codeCoverageIgnore
|
|
}
|
|
|
|
$affected = $this->vulnerabilities();
|
|
$incidents = $this->data['incidents'] ?? [];
|
|
$maxVersion = $versionEntry['latest'];
|
|
|
|
// increase the target version number until there are no vulnerabilities
|
|
$version = $this->currentVersion;
|
|
$iterations = 0;
|
|
while (empty($affected) === false) {
|
|
// protect against infinite loops if the
|
|
// input data is contradicting itself
|
|
$iterations++;
|
|
if ($iterations > 10) {
|
|
return null;
|
|
}
|
|
|
|
// if we arrived at the `$maxVersion` but still haven't found
|
|
// a version without vulnerabilities, we cannot suggest a version
|
|
if ($version === $maxVersion) {
|
|
return null;
|
|
}
|
|
|
|
// find the minimum version that fixes all affected vulnerabilities
|
|
foreach ($affected as $incident) {
|
|
$incidentVersion = null;
|
|
foreach (Str::split($incident['fixed'], ',') as $fixed) {
|
|
// skip versions of other major releases
|
|
if (
|
|
version_compare($fixed, $this->currentVersion, '<') === true ||
|
|
version_compare($fixed, $maxVersion, '>') === true
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
// find the minimum version that fixes this specific vulnerability
|
|
if (
|
|
$incidentVersion === null ||
|
|
version_compare($fixed, $incidentVersion, '<') === true
|
|
) {
|
|
$incidentVersion = $fixed;
|
|
}
|
|
}
|
|
|
|
// verify that we found at least one possible version;
|
|
// otherwise try the `$maxVersion` as a last chance before
|
|
// concluding at the top that we cannot solve the task
|
|
$incidentVersion ??= $maxVersion;
|
|
|
|
// we need a version that fixes all vulnerabilities, so use the
|
|
// "largest of the smallest" fixed versions
|
|
if (version_compare($incidentVersion, $version, '>') === true) {
|
|
$version = $incidentVersion;
|
|
}
|
|
}
|
|
|
|
// run another loop to verify that the suggested version
|
|
// doesn't have any known vulnerabilities on its own
|
|
$affected = $this->filterArrayByVersion(
|
|
$incidents,
|
|
['affected' => $version],
|
|
'while filtering incidents'
|
|
);
|
|
}
|
|
|
|
return $version;
|
|
}
|
|
|
|
/**
|
|
* Loads the getkirby.com update data
|
|
* from cache or via HTTP
|
|
*/
|
|
protected function loadData(): array|null
|
|
{
|
|
// try to get the data from cache
|
|
$cache = $this->app->cache('updates');
|
|
$key = (
|
|
$this->pluginName ?
|
|
'plugins/' . $this->pluginName :
|
|
'security'
|
|
);
|
|
|
|
// try to return from cache;
|
|
// invalidate the cache after updates
|
|
$data = $cache->get($key);
|
|
if (
|
|
is_array($data) === true &&
|
|
$data['_version'] === $this->currentVersion
|
|
) {
|
|
return $data;
|
|
}
|
|
|
|
// exception message (on previous request error)
|
|
if (is_string($data) === true) {
|
|
// restore the exception to make it visible when debugging
|
|
$this->exceptions[] = new KirbyException($data);
|
|
|
|
return null;
|
|
}
|
|
|
|
// before we request the data, ensure we have a writable cache;
|
|
// this reduces strain on the CDN from repeated requests
|
|
if ($cache->enabled() === false) {
|
|
$this->exceptions[] = new KirbyException('Cannot check for updates without a working "updates" cache');
|
|
|
|
return null;
|
|
}
|
|
|
|
// first catch every exception;
|
|
// we collect it below for debugging
|
|
try {
|
|
if (static::$timedOut === true) {
|
|
throw new Exception('Previous remote request timed out'); // @codeCoverageIgnore
|
|
}
|
|
|
|
$response = Remote::get(
|
|
static::$host . '/' . $key . '.json',
|
|
['timeout' => 2]
|
|
);
|
|
|
|
// allow status code HTTP 200 or 0 (e.g. for the file:// protocol)
|
|
if (in_array($response->code(), [0, 200], true) !== true) {
|
|
throw new Exception('HTTP error ' . $response->code()); // @codeCoverageIgnore
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
if (is_array($data) !== true) {
|
|
throw new Exception('Invalid JSON data');
|
|
}
|
|
} catch (Exception $e) {
|
|
$package = $this->packageName();
|
|
$message = 'Could not load update data for ' . $package . ': ' . $e->getMessage();
|
|
|
|
$exception = new KirbyException([
|
|
'fallback' => $message,
|
|
'previous' => $e
|
|
]);
|
|
$this->exceptions[] = $exception;
|
|
|
|
// if the request timed out, prevent additional
|
|
// requests for other packages (e.g. plugins)
|
|
// to avoid long Panel hangs
|
|
if ($e->getCode() === 28) {
|
|
static::$timedOut = true; // @codeCoverageIgnore
|
|
} elseif (static::$timedOut === false) {
|
|
// different error than timeout;
|
|
// prevent additional requests in the
|
|
// next three days (e.g. if a plugin
|
|
// does not have a page on getkirby.com)
|
|
// by caching the exception message
|
|
// instead of the data array
|
|
$cache->set($key, $exception->getMessage(), 3 * 24 * 60);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// also cache the current version to
|
|
// invalidate the cache after updates
|
|
// (ensures that the update status is
|
|
// fresh directly after the update to
|
|
// avoid confusion with outdated info)
|
|
$data['_version'] = $this->currentVersion;
|
|
|
|
// cache the retrieved data for three days
|
|
$cache->set($key, $data, 3 * 24 * 60);
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Returns the human-readable package name for error messages
|
|
*/
|
|
protected function packageName(): string
|
|
{
|
|
return $this->pluginName ? 'plugin ' . $this->pluginName : 'Kirby';
|
|
}
|
|
|
|
/**
|
|
* Performs the update check and returns data for the
|
|
* target version (with fallback and error handling)
|
|
*/
|
|
protected function targetData(): array
|
|
{
|
|
if (isset($this->targetData) === true) {
|
|
return $this->targetData;
|
|
}
|
|
|
|
// check if we have valid data to compare to
|
|
$versionEntry = $this->versionEntry();
|
|
if ($versionEntry === null) {
|
|
$version = $this->currentVersion ?? $this->data['latest'] ?? null;
|
|
|
|
return $this->targetData = [
|
|
'status' => 'error',
|
|
'url' => $version ? $this->urlFor($version, 'changes') : null,
|
|
'version' => null
|
|
];
|
|
}
|
|
|
|
// check if the current version is the latest available
|
|
if (($versionEntry['status'] ?? null) === 'latest') {
|
|
return $this->targetData = [
|
|
'status' => 'up-to-date',
|
|
'url' => $this->urlFor($this->currentVersion, 'changes'),
|
|
'version' => null
|
|
];
|
|
}
|
|
|
|
// check if the current version is unreleased
|
|
if (($versionEntry['status'] ?? null) === 'unreleased') {
|
|
return $this->targetData = [
|
|
'status' => 'unreleased',
|
|
'url' => null,
|
|
'version' => null
|
|
];
|
|
}
|
|
|
|
// check if the installation is vulnerable;
|
|
// minimum possible security fixes are preferred
|
|
// over all other updates and upgrades
|
|
if (count($this->vulnerabilities()) > 0) {
|
|
$update = $this->findMinimumSecurityUpdate();
|
|
|
|
if ($update !== null) {
|
|
// a free security update was found
|
|
return $this->targetData = [
|
|
'status' => 'security-update',
|
|
'url' => $this->urlFor($update, 'changes'),
|
|
'version' => $update
|
|
];
|
|
}
|
|
|
|
// only a paid security upgrade is possible
|
|
return $this->targetData = [
|
|
'status' => 'security-upgrade',
|
|
'url' => $this->urlFor($this->currentVersion, 'upgrade'),
|
|
'version' => $this->data['latest'] ?? null
|
|
];
|
|
}
|
|
|
|
// check if the user limited update checking to security updates
|
|
if ($this->securityOnly === true) {
|
|
return $this->targetData = [
|
|
'status' => 'not-vulnerable',
|
|
'url' => $this->urlFor($this->currentVersion, 'changes'),
|
|
'version' => null
|
|
];
|
|
}
|
|
|
|
// check if free updates are possible from the current version
|
|
$latest = $versionEntry['latest'] ?? null;
|
|
if (is_string($latest) === true && $latest !== $this->currentVersion) {
|
|
return $this->targetData = [
|
|
'status' => 'update',
|
|
'url' => $this->urlFor($latest, 'changes'),
|
|
'version' => $latest
|
|
];
|
|
}
|
|
|
|
// no free update is possible, but we are not on the latest version,
|
|
// so the overall latest version must be an upgrade
|
|
return $this->targetData = [
|
|
'status' => 'upgrade',
|
|
'url' => $this->urlFor($this->currentVersion, 'upgrade'),
|
|
'version' => $this->data['latest'] ?? null
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Returns the URL for a specific version and purpose
|
|
*/
|
|
protected function urlFor(string $version, string $purpose): string|null
|
|
{
|
|
if ($this->data === null) {
|
|
return null;
|
|
}
|
|
|
|
// find the first matching entry
|
|
$url = null;
|
|
foreach ($this->data['urls'] ?? [] as $constraint => $entry) {
|
|
// filter out every entry that does not match the version
|
|
if ($this->checkConstraint($version, $constraint, 'while finding URL') !== true) {
|
|
continue;
|
|
}
|
|
|
|
// we found a result
|
|
$url = $entry[$purpose] ?? null;
|
|
if ($url !== null) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($url === null) {
|
|
$package = $this->packageName();
|
|
$message = 'No matching URL found for ' . $package . '@' . $version;
|
|
|
|
$this->exceptions[] = new KirbyException($message);
|
|
|
|
return null;
|
|
}
|
|
|
|
// insert the URL template placeholders
|
|
return Str::template($url, [
|
|
'current' => $this->currentVersion,
|
|
'version' => $version
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Extracts the first matching version entry from
|
|
* the data array unless no data is available
|
|
*/
|
|
protected function versionEntry(): array|null
|
|
{
|
|
if (isset($this->versionEntry) === true) {
|
|
// no version entry found on last call
|
|
if ($this->versionEntry === false) {
|
|
return null;
|
|
}
|
|
|
|
return $this->versionEntry;
|
|
}
|
|
|
|
if (
|
|
$this->data === null ||
|
|
$this->currentVersion === null ||
|
|
$this->currentVersion === ''
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// special check for unreleased versions
|
|
$latest = $this->data['latest'] ?? null;
|
|
if (
|
|
$latest !== null &&
|
|
version_compare($this->currentVersion, $latest, '>') === true
|
|
) {
|
|
return [
|
|
'status' => 'unreleased'
|
|
];
|
|
}
|
|
|
|
$versionEntry = null;
|
|
foreach ($this->data['versions'] ?? [] as $constraint => $entry) {
|
|
// filter out every entry that does not match the current version
|
|
if ($this->checkConstraint($this->currentVersion, $constraint, 'while finding version entry') !== true) {
|
|
continue;
|
|
}
|
|
|
|
if (($entry['status'] ?? null) === 'no-vulnerabilities') {
|
|
$this->noVulns = true;
|
|
|
|
// use the next matching version entry with
|
|
// more specific update information
|
|
continue;
|
|
}
|
|
|
|
if (($entry['status'] ?? null) === 'latest') {
|
|
$this->noVulns = true;
|
|
}
|
|
|
|
// we found a result
|
|
$versionEntry = $entry;
|
|
break;
|
|
}
|
|
|
|
if ($versionEntry === null) {
|
|
$package = $this->packageName();
|
|
$message = 'No matching version entry found for ' . $package . '@' . $this->currentVersion;
|
|
|
|
$this->exceptions[] = new KirbyException($message);
|
|
}
|
|
|
|
$this->versionEntry = $versionEntry ?? false;
|
|
return $versionEntry;
|
|
}
|
|
}
|