diff --git a/README.md b/README.md index 42c1a3dc..3adc3949 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ secure: - automatic updates, package list age, held packages, failed system services, time synchronisation, SELinux and AppArmor -Debian and Ubuntu are supported. Inside Docker, Podman, Kubernetes or a snap the status says +Debian, Ubuntu, RHEL and its rebuilds, Fedora, openSUSE and SLES, Arch and +Alpine are supported. Inside Docker, Podman, Kubernetes or a snap the status says what can and cannot be known from inside; LXC and systemd-nspawn guests are treated like machines without a kernel of their own. @@ -45,6 +46,16 @@ installed kernel modules. It runs the package manager's query commands (updates only in simulation mode), `systemctl`, `timedatectl`, `chronyc` and `systemd-detect-virt`. Anything it may not read or run is reported as unknown. +These queries stay offline, with one exception: on Arch, `checkupdates` +downloads the package databases from the mirrors in +`/etc/pacman.d/mirrorlist` into a temporary directory of the web server user, +and `arch-audit` fetches the security advisories from security.archlinux.org. +These are the only outbound connections the operating system status makes. To keep Arch offline as well, run +`occ config:app:set serverinfo os_allow_network --type=boolean --value=false`. +Pending updates then come from the databases root last synced, the age of +those databases counts as the package list age, and security fixes are not +told apart. + The status is collected by a background job once an hour, when background jobs run from system cron; under AJAX or webcron the commands would hold a web request for minutes. A status older than three job intervals (at least six @@ -69,8 +80,10 @@ MIT License); the app never looks them up online. ### Root helper The web server cannot see everything: only root can tell which services still -run code an update replaced, and only root can refresh the package lists. The -optional script `bin/serverinfo-os-report` adds that. Run as root, it writes a +run code an update replaced, and only root can refresh the package lists. On +Fedora and other systems with dnf 5, only root can list pending updates at all: +dnf 5 would first copy its whole cache into the web server's home. The +optional script `bin/serverinfo-os-report` adds all of that. Run as root, it writes a small JSON report that the app reads. It only reads the system; it never installs, upgrades, restarts or reboots anything. diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 465dee5f..7c018135 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -41,6 +41,7 @@ class ConfigLexicon implements ILexicon { public const OS_EOL_WARNING_DAYS = 'os_eol_warning_days'; public const OS_LISTS_WARNING_DAYS = 'os_lists_warning_days'; public const OS_LISTS_ERROR_DAYS = 'os_lists_error_days'; + public const OS_ALLOW_NETWORK = 'os_allow_network'; #[\Override] public function getStrictness(): Strictness { @@ -127,6 +128,13 @@ public function getAppConfigs(): array { new Entry(self::OS_EOL_WARNING_DAYS, ValueType::INT, 90, 'Days before the end of security support that a warning starts', lazy: true), new Entry(self::OS_LISTS_WARNING_DAYS, ValueType::INT, 7, 'Days without a package list refresh before a warning', lazy: true), new Entry(self::OS_LISTS_ERROR_DAYS, ValueType::INT, 30, 'Days without a package list refresh before an error', lazy: true), + new Entry( + self::OS_ALLOW_NETWORK, + ValueType::BOOL, + true, + 'Whether the operating system status may use the network as the web server user: on Arch, checkupdates syncs its own package databases from the pacman mirrors and arch-audit fetches the security advisories from security.archlinux.org', + lazy: true, + ), ]; } diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index f062a7bf..da64efc2 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -19,6 +19,8 @@ use OCA\ServerInfo\OsStatus\Host\IHost; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\IPackageManager; +use OCA\ServerInfo\OsStatus\Packages\IReportsDiagnostics; +use OCA\ServerInfo\OsStatus\Packages\NeedsRootException; use OCA\ServerInfo\OsStatus\Packages\PackageManagerFactory; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Packages\ProbeException; @@ -117,10 +119,13 @@ public function collect(): OsStatus { $updates = null; $updatesSource = null; + $needsRoot = null; if ($packageManager !== null) { try { $updates = $packageManager->getPendingUpdates(); $updatesSource = OsStatus::SOURCE_SYSTEM; + } catch (NeedsRootException $e) { + $needsRoot = $e->getMessage(); } catch (ProbeException $e) { $diagnostics[] = $e->getMessage(); } catch (\Throwable $e) { @@ -133,6 +138,9 @@ public function collect(): OsStatus { $updates = $reportedUpdates; $updatesSource = OsStatus::SOURCE_HELPER; } + if ($needsRoot !== null && $updatesSource !== OsStatus::SOURCE_HELPER) { + $diagnostics[] = $needsRoot; + } $listsRefreshedAt = $this->latest( $packageManager !== null ? $this->probe('The package list age', fn (): ?int => $packageManager->getListsRefreshedAt(), null, $diagnostics) : null, @@ -165,6 +173,10 @@ public function collect(): OsStatus { $updates = array_slice($updates, 0, self::MAX_LISTED_UPDATES); } + if ($packageManager instanceof IReportsDiagnostics) { + array_push($diagnostics, ...$packageManager->getDiagnostics()); + } + $timeSync = $this->probe('The time synchronisation', fn (): array => $this->timeSync->probe(), ['synchronized' => null, 'source' => null], $diagnostics); return new OsStatus( diff --git a/lib/OsStatus/Packages/Apk.php b/lib/OsStatus/Packages/Apk.php new file mode 100644 index 00000000..7f4382b0 --- /dev/null +++ b/lib/OsStatus/Packages/Apk.php @@ -0,0 +1,228 @@ +host->binaryState('apk') !== false; + } + + #[\Override] + public function classifiesSecurity(): bool { + return false; + } + + #[\Override] + public function getPendingUpdates(): array { + $result = $this->host->run('apk', ['--no-network', 'list', '--upgradable'], 120); + if ($result === null) { + throw new ProbeException('apk could not be run.'); + } + if (!$result->succeeded()) { + throw new ProbeException('apk list --upgradable failed: ' . $result->failure(120)); + } + + // apk lists every newer version it knows of, including those the + // constraints in /etc/apk/world keep it from installing. + $constraints = []; + foreach (self::parseWorld($this->host->readFile(self::WORLD, 262144) ?? '') as [$name, $operator, $version]) { + if ($operator !== null) { + $constraints[$name][] = [$operator, $version]; + } + } + + return array_values(array_filter( + self::parseUpgradable($result->stdout), + static function (PendingUpdate $update) use ($constraints): bool { + foreach ($constraints[$update->name] ?? [] as [$operator, $version]) { + if (!self::satisfies($update->candidateVersion, $operator, $version)) { + return false; + } + } + + return true; + }, + )); + } + + /** + * Parses `apk list --upgradable`, e.g. + * busybox-1.36.1-r16 x86_64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.36.1-r15] + * + * @return list + */ + public static function parseUpgradable(string $output): array { + $updates = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^(\S+)-(\d\S*-r\d+) \S+ \{[^}]*\} \([^)]*\) \[upgradable from: (\S+)\]$/', trim($line), $matches) !== 1) { + continue; + } + + $name = $matches[1]; + $installed = str_starts_with($matches[3], $name . '-') ? substr($matches[3], strlen($name) + 1) : null; + $updates[] = new PendingUpdate($name, $installed, $matches[2], false); + } + + return $updates; + } + + /** + * Packages whose version /etc/apk/world restricts in a way that can hold + * back an upgrade: "=", "<", "<=" and the fuzzy "~". A repository tag + * alone ("curl@edge") only lets the package come from the tagged + * repository as well, and upgrades go on from there. + */ + #[\Override] + public function getHeldPackages(): ?array { + $world = $this->host->readFile(self::WORLD, 262144); + if ($world === null) { + return null; + } + + $held = []; + foreach (self::parseWorld($world) as [$name, $operator, $version, $tag]) { + if ($operator !== null && !in_array($operator, ['>', '>='], true)) { + $held[] = new HeldPackage($name, 'apk world', ($tag ?? '') . $operator . $version); + } + } + + return $held; + } + + /** + * Parses the dependencies in /etc/apk/world: name[@tag][operator version]. + * Conflicts ("!name") are left out. + * + * @return list [name, operator, version, tag] + */ + public static function parseWorld(string $world): array { + $entries = []; + foreach (preg_split('/\s+/', trim($world)) ?: [] as $entry) { + if (preg_match('/^([A-Za-z0-9][A-Za-z0-9._+-]*?)(@[A-Za-z0-9._-]+)?(?:(<=|>=|~=|=~|[<>=~])(\S+))?$/', $entry, $matches) !== 1) { + continue; + } + $operator = ($matches[3] ?? '') !== '' ? $matches[3] : null; + $entries[] = [ + $matches[1], + $operator === '~=' || $operator === '=~' ? '~' : $operator, + $matches[4] ?? '', + ($matches[2] ?? '') !== '' ? $matches[2] : null, + ]; + } + + return $entries; + } + + /** + * Whether a version meets a world constraint. "~1.2" matches 1.2 and + * every 1.2.x or 1.2-rN. + */ + public static function satisfies(string $version, string $operator, string $constraint): bool { + if ($operator === '~') { + return $version === $constraint + || (str_starts_with($version, $constraint) && in_array($version[strlen($constraint)], ['.', '-', '_'], true)); + } + + $order = self::compareVersions($version, $constraint); + + return match ($operator) { + '=' => $order === 0, + '<' => $order < 0, + '<=' => $order <= 0, + '>' => $order > 0, + '>=' => $order >= 0, + default => true, + }; + } + + /** + * Compares apk versions such as "3.5.8-r0" or "1.2_rc1-r3": the + * upstream version as PHP's version_compare() orders it, then the -rN + * package release. + */ + public static function compareVersions(string $a, string $b): int { + [$baseA, $releaseA] = self::splitRelease($a); + [$baseB, $releaseB] = self::splitRelease($b); + + return version_compare($baseA, $baseB) ?: $releaseA <=> $releaseB; + } + + /** + * @return array{0: string, 1: int} + */ + private static function splitRelease(string $version): array { + $release = 0; + if (preg_match('/^(.*)-r(\d+)$/', $version, $matches) === 1) { + $version = $matches[1]; + $release = (int)$matches[2]; + } + + // apk's _pre sorts like a release candidate; version_compare() does not know it. + return [str_replace(['_pre', '_'], ['.rc', '.'], $version), $release]; + } + + #[\Override] + public function getListsRefreshedAt(): ?int { + return ProbeHelper::newestModified($this->host, ['/var/cache/apk/APKINDEX.*.tar.gz']); + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + return $this->host->modifiedAt('/lib/apk/db/installed'); + } + + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + return AutomaticUpdates::unknown(); + } + + #[\Override] + public function getRebootReasons(): array { + return []; + } + + #[\Override] + public function hasOwnRebootCheck(): bool { + return false; + } + + #[\Override] + public function getUpgradeCommands(): array { + return ['all' => 'sudo apk upgrade --update-cache', 'security' => null]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return '/lib/apk/db/installed'; + } +} diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php new file mode 100644 index 00000000..147af5c4 --- /dev/null +++ b/lib/OsStatus/Packages/Dnf.php @@ -0,0 +1,469 @@ + true, + 'dnf5-automatic.timer' => null, + 'dnf-automatic.timer' => null, + 'dnf-automatic-download.timer' => false, + 'dnf-automatic-notifyonly.timer' => false, + ]; + + /** dnf 4's own binary; `dnf` is only a link to it, or to dnf 5 */ + private const DNF4 = 'dnf-3'; + + private ?bool $isDnf4 = null; + /** Whether the last getRebootReasons() got an answer from needs-restarting */ + private ?bool $rebootChecked = null; + /** Whether the last getPendingUpdates() could not read the advisories */ + private bool $advisoriesFailed = false; + /** @var list */ + private array $diagnostics = []; + private ?OsRelease $release = null; + + public function __construct( + private IHost $host, + private Rpm $rpm, + private SystemdProbe $systemd, + ) { + } + + #[\Override] + public function getId(): string { + return 'dnf'; + } + + #[\Override] + public function isAvailable(): bool { + return $this->host->hasBinary(self::DNF4) || $this->host->hasBinary('dnf5') || $this->host->hasBinary('dnf'); + } + + /** + * The same package manager, for the given distribution. + */ + public function withRelease(OsRelease $release): self { + $clone = clone $this; + $clone->release = $release; + + return $clone; + } + + /** + * Security fixes are told apart by the repositories' updateinfo metadata. + * CentOS Stream publishes none, and neither do many third-party + * repositories; without it every update would look like a non-security one. + */ + #[\Override] + public function classifiesSecurity(): bool { + return $this->release?->id !== 'centos' && !$this->advisoriesFailed && $this->hasUpdateinfo(); + } + + /** + * Whether any repository in the system cache came with updateinfo. + */ + private function hasUpdateinfo(): bool { + foreach (self::UPDATEINFO as $pattern) { + if ($this->host->glob($pattern) !== []) { + return true; + } + } + + return false; + } + + #[\Override] + public function getDiagnostics(): array { + return $this->diagnostics; + } + + #[\Override] + public function getPendingUpdates(): array { + $this->advisoriesFailed = false; + $this->diagnostics = []; + if (!$this->isDnf4()) { + throw new NeedsRootException($this->host->hasBinary('dnf5') + ? 'dnf 5 can only be asked about pending updates as root, so the root helper reports them on this system.' + : 'dnf could not be identified as dnf 4, so it is not run as the web server; the root helper reports pending updates on this system.'); + } + + $result = $this->dnf(['-q', '--cacheonly', 'check-update'], 180); + if ($result === null) { + throw new ProbeException('dnf could not be run.'); + } + if (($result->exitCode !== 0 && $result->exitCode !== self::EXIT_UPDATES) || $result->timedOut || $result->truncated) { + throw new ProbeException('dnf check-update failed: ' . $result->failure(180)); + } + + $available = self::parseCheckUpdate($result->stdout); + if ($available === []) { + return []; + } + + $advisories = $this->classifiesSecurity() ? $this->securityAdvisories() : []; + $installed = $this->rpm->getInstalledVersions(); + + $updates = []; + foreach ($available as $name => [$version, $repository]) { + $advisory = $advisories[$name] ?? null; + $updates[] = new PendingUpdate( + $name, + $installed[$name] ?? null, + $version, + $advisory !== null, + $repository, + $advisory['ids'] ?? [], + $advisory['severity'] ?? null, + ); + } + + return $updates; + } + + /** + * Parses `dnf check-update`: "name.arch version-release repository", + * up to the "Obsoleting Packages" section. + * + * Without a terminal dnf 4 lays the columns out in 80 characters and + * moves whatever follows a column that is too wide onto the next line, + * indented. Such continuation lines are joined to the line they continue. + * + * @return array name => [version, repository] + */ + public static function parseCheckUpdate(string $output): array { + $rows = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^Obsoleting|^Security:/i', $line) === 1) { + break; + } + if (trim($line) === '') { + continue; + } + + $columns = preg_split('/\s+/', trim($line)) ?: []; + $last = array_key_last($rows); + if ($last !== null && count($rows[$last]) < 3 && preg_match('/^\s/', $line) === 1) { + $rows[$last] = array_merge($rows[$last], $columns); + } else { + $rows[] = $columns; + } + } + + $updates = []; + foreach ($rows as $columns) { + if (count($columns) === 3 && preg_match('/^(\S+)\.' . Rpm::ARCH . '$/', $columns[0], $matches) === 1) { + $updates[$matches[1]] = [$columns[1], $columns[2]]; + } + } + + return $updates; + } + + /** + * Security advisories for the available updates, by package name. When + * they cannot be listed, the updates stay listed but unclassified. + * + * @return array, severity: ?string}> + */ + private function securityAdvisories(): array { + $result = $this->dnf(['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], 180); + if ($result === null || !$result->succeeded()) { + $this->advisoriesFailed = true; + $this->diagnostics[] = 'dnf could not list security advisories, so pending updates are not told apart into security fixes and others: ' + . ($result?->failure(180) ?? 'dnf could not be run'); + + return []; + } + + return self::parseAdvisories($result->stdout); + } + + /** + * Parses the advisory list of dnf 4 ("RHSA-2026:1234 Important/Sec. nevra") + * and dnf 5 ("FEDORA-2026-abc security Moderate nevra date"). + * + * @return array, severity: ?string}> + */ + public static function parseAdvisories(string $output): array { + $advisories = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + $columns = preg_split('/\s+/', trim($line)) ?: []; + if (count($columns) < 3 || preg_match('/^[A-Z][A-Za-z0-9]*-[\w:.-]+$/', $columns[0]) !== 1) { + continue; + } + + $severity = null; + $nevra = null; + if (preg_match('#^(\w+)/Sec\.$#', $columns[1], $matches) === 1) { + // dnf 4 + $severity = $matches[1] === 'None' ? null : $matches[1]; + $nevra = $columns[2]; + } elseif ($columns[1] === 'security' && isset($columns[3])) { + // dnf 5 + $severity = in_array($columns[2], ['None', 'none', 'Unknown'], true) ? null : $columns[2]; + $nevra = $columns[3]; + } + + $split = $nevra !== null ? Rpm::splitNevra($nevra) : null; + if ($split === null) { + continue; + } + + $name = $split[0]; + $advisories[$name] ??= ['ids' => [], 'severity' => null]; + if (!in_array($columns[0], $advisories[$name]['ids'], true)) { + $advisories[$name]['ids'][] = $columns[0]; + } + $advisories[$name]['severity'] = PendingUpdate::higherSeverity($advisories[$name]['severity'], $severity); + } + + return $advisories; + } + + #[\Override] + public function getHeldPackages(): ?array { + if (!$this->isDnf4()) { + $locks = $this->host->readFile('/etc/dnf/versionlock.toml', 262144); + + return $locks === null ? ($this->host->fileExists('/etc/dnf') ? [] : null) : self::parseVersionlockToml($locks); + } + + // Without --cacheonly, dnf 4 downloads the full repository metadata + // into a per-user cache just to list the locks. + $result = $this->dnf(['-q', '--cacheonly', 'versionlock', 'list'], 60); + if ($result === null || !$result->succeeded()) { + return null; + } + + return self::parseVersionlock($result->stdout); + } + + /** + * dnf 4 prints one locked NEVRA pattern per line, dnf 5 a block per + * package with a "Package name:" line. + * + * @return list + */ + public static function parseVersionlock(string $output): array { + $held = []; + if (preg_match_all('/^Package name:\s*(\S+)/mi', $output, $matches) > 0) { + foreach ($matches[1] as $name) { + $held[$name] = new HeldPackage($name, 'dnf versionlock'); + } + + return array_values($held); + } + + foreach (preg_split('/\R/', $output) ?: [] as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#' || str_starts_with($line, 'Last metadata')) { + continue; + } + $pattern = preg_replace('/\.\*$/', '', $line) ?? $line; + $split = Rpm::splitNevra($pattern, false); + $name = $split[0] ?? $pattern; + if (preg_match('/^[A-Za-z0-9._+-]+$/', $name) === 1) { + $held[$name] = new HeldPackage($name, 'dnf versionlock', $split[1] ?? null); + } + } + + return array_values($held); + } + + /** + * dnf 5 keeps its locks in /etc/dnf/versionlock.toml, readable by everyone. + * + * @return list + */ + public static function parseVersionlockToml(string $toml): array { + $held = []; + $current = null; + foreach (preg_split('/\R/', $toml) ?: [] as $line) { + $line = trim($line); + if ($line === '[[packages]]') { + $current = null; + } elseif ($current === null && preg_match('/^name\s*=\s*"([A-Za-z0-9._+-]+)"$/', $line, $matches) === 1) { + $current = $matches[1]; + $held[$current] = new HeldPackage($current, 'dnf versionlock'); + } elseif ($current !== null && preg_match('/^value\s*=\s*"([^"]{1,100})"$/', $line, $matches) === 1) { + $held[$current] = new HeldPackage($current, 'dnf versionlock', $matches[1]); + } + } + + return array_values($held); + } + + /** + * Both dnf versions rewrite each repository's metalink.xml when they + * refresh it, whether or not the metadata changed. + */ + #[\Override] + public function getListsRefreshedAt(): ?int { + return ProbeHelper::newestModified($this->host, [ + '/var/cache/dnf/*/metalink.xml', + '/var/cache/libdnf5/*/metalink.xml', + '/var/cache/dnf/*/repodata/repomd.xml', + '/var/cache/libdnf5/*/repodata/repomd.xml', + '/var/cache/dnf/last_makecache', + ]); + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + return $this->rpm->getLastInstallTime(); + } + + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + $config = $this->host->readFile('/etc/dnf/automatic.conf', 65536) + ?? $this->host->readFile('/etc/dnf/dnf5-plugins/automatic.conf', 65536) + ?? ''; + $applies = preg_match('/^\s*apply_updates\s*=\s*(yes|true|1)\s*$/mi', $config) === 1; + $securityOnly = preg_match('/^\s*upgrade_type\s*=\s*security\s*$/mi', $config) === 1; + + $known = false; + foreach (self::AUTOMATIC_TIMERS as $timer => $installs) { + $state = $this->systemd->getUnitFileState($timer); + if ($state === null || $state === 'not-found') { + continue; + } + $known = true; + if ($state !== 'enabled') { + continue; + } + + $installs ??= $applies; + if (!$installs) { + return new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED, 'dnf-automatic', AutomaticUpdates::SCOPE_DOWNLOAD); + } + + return new AutomaticUpdates( + AutomaticUpdates::STATE_ENABLED, + 'dnf-automatic', + $securityOnly ? AutomaticUpdates::SCOPE_SECURITY : AutomaticUpdates::SCOPE_ALL, + ); + } + + return $known || $this->systemd->isRunning() + ? new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED) + : AutomaticUpdates::unknown(); + } + + /** + * `needs-restarting -r` exits 1 and says "Reboot is required" when core + * packages were updated since boot. dnf exits 1 for its own errors too, + * e.g. "No such command" where the plugin is not installed, so the exit + * code alone means nothing. + */ + #[\Override] + public function getRebootReasons(): array { + $this->rebootChecked = false; + if (!$this->isDnf4()) { + return []; + } + + $result = $this->dnf(['-q', '--cacheonly', 'needs-restarting', '-r'], 120); + if ($result === null || $result->timedOut || $result->truncated) { + return []; + } + if ($result->exitCode === 0) { + $this->rebootChecked = true; + + return []; + } + if ($result->exitCode !== 1 || preg_match('/^Reboot is required/m', $result->stdout) !== 1) { + return []; + } + + $this->rebootChecked = true; + $packages = []; + if (preg_match_all('/^\s*\*\s*(\S+)/m', $result->stdout, $matches) > 0) { + $packages = array_values(array_unique($matches[1])); + } + + return [new RebootReason(RebootReason::KIND_DISTRIBUTION, null, $packages)]; + } + + /** + * Whether needs-restarting gave an answer. dnf 5's cannot run + * unprivileged; there, and wherever it failed, the generic kernel and core + * library checks answer instead. + */ + #[\Override] + public function hasOwnRebootCheck(): bool { + if ($this->rebootChecked === null) { + $this->getRebootReasons(); + } + + return $this->rebootChecked === true; + } + + #[\Override] + public function getUpgradeCommands(): array { + return [ + 'all' => 'sudo dnf upgrade --refresh', + 'security' => 'sudo dnf upgrade --refresh --security', + ]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return $this->rpm->getDatabasePath(); + } + + /** + * Told apart by the binaries rather than by running dnf, because running + * dnf 5 as the web server is what has to be avoided. Where both are + * installed, dnf 5 keeps the system cache and dnf 4's would be stale. + */ + private function isDnf4(): bool { + return $this->isDnf4 ??= $this->host->hasBinary(self::DNF4) && !$this->host->hasBinary('dnf5'); + } + + /** + * @param list $arguments + */ + private function dnf(array $arguments, int $timeout): ?CommandResult { + return $this->host->run(self::DNF4, $arguments, $timeout); + } +} diff --git a/lib/OsStatus/Packages/IReportsDiagnostics.php b/lib/OsStatus/Packages/IReportsDiagnostics.php new file mode 100644 index 00000000..dfd455a2 --- /dev/null +++ b/lib/OsStatus/Packages/IReportsDiagnostics.php @@ -0,0 +1,23 @@ + + */ + public function getDiagnostics(): array; +} diff --git a/lib/OsStatus/Packages/NeedsRootException.php b/lib/OsStatus/Packages/NeedsRootException.php new file mode 100644 index 00000000..8edc65e8 --- /dev/null +++ b/lib/OsStatus/Packages/NeedsRootException.php @@ -0,0 +1,17 @@ +isLike('debian') || $release->isLike('ubuntu') => $this->apt->withRelease($release), + $release->isLike('suse') || str_starts_with($release->id, 'opensuse') || $release->id === 'sles' || $release->id === 'sled' => $this->zypper->withRelease($release), + $release->isLike('fedora') || $release->isLike('rhel') || $release->isLike('centos') || in_array($release->id, ['amzn', 'ol', 'almalinux', 'rocky'], true) => $this->dnf->withRelease($release), + $release->isLike('arch') => $this->pacman, + $release->isLike('alpine') => $this->apk, default => null, }; diff --git a/lib/OsStatus/Packages/Pacman.php b/lib/OsStatus/Packages/Pacman.php new file mode 100644 index 00000000..89edfb83 --- /dev/null +++ b/lib/OsStatus/Packages/Pacman.php @@ -0,0 +1,241 @@ + */ + private array $diagnostics = []; + + public function __construct( + private IHost $host, + private ITimeFactory $timeFactory, + private IAppConfig $appConfig, + ) { + } + + #[\Override] + public function getId(): string { + return 'pacman'; + } + + #[\Override] + public function isAvailable(): bool { + return $this->host->binaryState('pacman') !== false; + } + + #[\Override] + public function classifiesSecurity(): bool { + return !$this->auditFailed && $this->networkAllowed() && $this->host->hasBinary('arch-audit'); + } + + private function networkAllowed(): bool { + return $this->appConfig->getAppValueBool(ConfigLexicon::OS_ALLOW_NETWORK, true, true); + } + + #[\Override] + public function getDiagnostics(): array { + return $this->diagnostics; + } + + #[\Override] + public function getPendingUpdates(): array { + $this->syncedAt = null; + $this->auditFailed = false; + $this->diagnostics = []; + + if ($this->networkAllowed() && $this->host->hasBinary('checkupdates')) { + $result = $this->host->run('checkupdates', [], 180); + if ($result !== null && !$result->timedOut && !$result->truncated + && ($result->exitCode === 0 || $result->exitCode === self::EXIT_NO_UPDATES)) { + $this->syncedAt = $this->timeFactory->getTime(); + + return $this->withSecurity(self::parseUpdates($result->stdout)); + } + $this->diagnostics[] = 'checkupdates could not sync the package databases, so pending updates come from the databases root last synced' + . ($result !== null && trim($result->stderr) !== '' ? ': ' . ProbeHelper::firstLine($result->stderr) : '.'); + } + + $result = $this->host->run('pacman', ['-Qu'], 60); + if ($result === null) { + throw new ProbeException('pacman could not be run.'); + } + // pacman -Qu exits 1 without a word when nothing is upgradable. + $nothing = $result->exitCode === 1 && trim($result->stdout) === '' && trim($result->stderr) === ''; + if (!$nothing && !$result->succeeded()) { + throw new ProbeException('pacman -Qu failed: ' . $result->failure(60)); + } + + return $this->withSecurity(self::parseUpdates($result->stdout)); + } + + /** + * Parses "name old -> new" lines, as printed by checkupdates and pacman -Qu. + * Packages in IgnorePkg are held, not pending: pacman -Qu marks them + * "[ignored]", checkupdates leaves them out. + * + * @return list + */ + public static function parseUpdates(string $output): array { + $updates = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^(\S+) (\S+) -> (\S+)$/', trim($line), $matches) === 1) { + $updates[] = new PendingUpdate($matches[1], $matches[2], $matches[3], false); + } + } + + return $updates; + } + + /** + * Marks the updates that fix a known vulnerability, according to arch-audit. + * + * @param list $updates + * @return list + */ + private function withSecurity(array $updates): array { + if ($updates === [] || !$this->classifiesSecurity()) { + return $updates; + } + + $result = $this->host->run('arch-audit', ['--upgradable', '--quiet'], 120); + if ($result === null || !$result->succeeded()) { + $this->auditFailed = true; + $this->diagnostics[] = 'arch-audit could not be run, so pending updates are not told apart into security fixes and others' + . ($result !== null && trim($result->stderr) !== '' ? ': ' . ProbeHelper::firstLine($result->stderr) : '.'); + + return $updates; + } + + // Each line starts with the package name, followed by a description + // unless --quiet was honoured. + $vulnerable = []; + foreach (preg_split('/\R/', $result->stdout) ?: [] as $line) { + if (preg_match('/^([A-Za-z0-9@._+-]+)/', trim($line), $matches) === 1) { + $vulnerable[$matches[1]] = true; + } + } + + return array_map( + static fn (PendingUpdate $u): PendingUpdate => isset($vulnerable[$u->name]) + ? new PendingUpdate($u->name, $u->installedVersion, $u->candidateVersion, true, $u->origin) + : $u, + $updates, + ); + } + + #[\Override] + public function getHeldPackages(): ?array { + $config = $this->host->readFile('/etc/pacman.conf', 262144); + if ($config === null) { + return null; + } + + return self::parseIgnored($config); + } + + /** + * @return list + */ + public static function parseIgnored(string $config): array { + $held = []; + if (preg_match_all('/^\s*IgnorePkg\s*=\s*(.+)$/m', $config, $matches) > 0) { + foreach ($matches[1] as $list) { + foreach (preg_split('/\s+/', trim(preg_replace('/#.*$/', '', $list) ?? '')) ?: [] as $name) { + if ($name !== '') { + $held[$name] = new HeldPackage($name, 'pacman IgnorePkg'); + } + } + } + } + + return array_values($held); + } + + /** + * checkupdates fetches fresh databases itself. Otherwise the answer came + * from root's databases, which pacman replaces in the sync directory when + * they changed upstream; the files keep the mirror's time, the directory + * the time of the download. + */ + #[\Override] + public function getListsRefreshedAt(): ?int { + return $this->syncedAt ?? ProbeHelper::newestModified($this->host, ['/var/lib/pacman/sync/*.db', '/var/lib/pacman/sync']); + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + $log = $this->host->readFileTail(self::LOG, 512 * 1024); + if ($log === null || preg_match_all('/^\[([0-9T:+-]{19,25})\] \[ALPM\] (?:upgraded|installed) /m', $log, $matches) < 1) { + return $this->host->modifiedAt('/var/lib/pacman/local'); + } + + $time = strtotime((string)end($matches[1])); + + return $time === false ? null : $time; + } + + /** + * Arch has no standard way to upgrade unattended and advises against it. + */ + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + return AutomaticUpdates::unknown(); + } + + /** + * Covered by the generic kernel check: pacman removes the running + * kernel's modules when it upgrades the kernel. + */ + #[\Override] + public function getRebootReasons(): array { + return []; + } + + #[\Override] + public function hasOwnRebootCheck(): bool { + return false; + } + + #[\Override] + public function getUpgradeCommands(): array { + return ['all' => 'sudo pacman -Syu', 'security' => null]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return '/var/lib/pacman/local'; + } +} diff --git a/lib/OsStatus/Packages/PendingUpdate.php b/lib/OsStatus/Packages/PendingUpdate.php index 0ab3ee08..c7156fa6 100644 --- a/lib/OsStatus/Packages/PendingUpdate.php +++ b/lib/OsStatus/Packages/PendingUpdate.php @@ -16,6 +16,10 @@ * A package that has a newer version waiting in the repositories. */ final class PendingUpdate { + public const KIND_PACKAGE = 'package'; + /** A SUSE patch: one advisory covering one or more packages */ + public const KIND_PATCH = 'patch'; + /** * @param list $advisories advisory or CVE identifiers, e.g. "USN-7012-1", "RHSA-2026:1234" */ @@ -31,11 +35,15 @@ public function __construct( public readonly ?string $severity = null, /** When this update was first seen pending, as a unix timestamp */ public readonly ?int $since = null, + /** @var self::KIND_* */ + public readonly string $kind = self::KIND_PACKAGE, + /** What a patch fixes, e.g. "Security update for glibc" */ + public readonly ?string $summary = null, ) { } /** - * @return array{name: string, installed: ?string, candidate: string, security: bool, origin: ?string, advisories: list, severity: ?string, since: ?int, stack: bool} + * @return array{name: string, installed: ?string, candidate: string, security: bool, origin: ?string, advisories: list, severity: ?string, since: ?int, kind: string, stack: bool, summary: ?string} */ public function toArray(): array { return [ @@ -47,7 +55,9 @@ public function toArray(): array { 'advisories' => $this->advisories, 'severity' => $this->severity, 'since' => $this->since, + 'kind' => $this->kind, 'stack' => $this->isStack(), + 'summary' => $this->summary, ]; } @@ -65,14 +75,40 @@ public static function fromArray(mixed $data): ?self { Values::strings($data['advisories'] ?? null), Values::string($data['severity'] ?? null), Values::int($data['since'] ?? null), + ($data['kind'] ?? null) === self::KIND_PATCH ? self::KIND_PATCH : self::KIND_PACKAGE, + Values::string($data['summary'] ?? null), ); } /** - * Whether this updates something Nextcloud runs on. + * The more severe of two advisory severities, as distributions name them. + */ + public static function higherSeverity(?string $a, ?string $b): ?string { + $rank = ['low' => 1, 'moderate' => 2, 'important' => 3, 'critical' => 4]; + if ($a === null) { + return $b; + } + if ($b === null) { + return $a; + } + + return ($rank[strtolower($b)] ?? 0) > ($rank[strtolower($a)] ?? 0) ? $b : $a; + } + + /** + * Whether this updates something Nextcloud runs on. A patch names the + * package it fixes in its summary, e.g. "Security update for openssl-3". */ public function isStack(): bool { - return NextcloudStack::isStackPackage($this->name); + if (NextcloudStack::isStackPackage($this->name)) { + return true; + } + + if ($this->kind !== self::KIND_PATCH || preg_match('/update for ([A-Za-z0-9._+-]+)/i', $this->summary ?? '', $matches) !== 1) { + return false; + } + + return NextcloudStack::isStackPackage($matches[1]); } /** @@ -84,6 +120,6 @@ public function key(): string { } public function withSince(?int $since): self { - return new self($this->name, $this->installedVersion, $this->candidateVersion, $this->security, $this->origin, $this->advisories, $this->severity, $since); + return new self($this->name, $this->installedVersion, $this->candidateVersion, $this->security, $this->origin, $this->advisories, $this->severity, $since, $this->kind, $this->summary); } } diff --git a/lib/OsStatus/Packages/ProbeHelper.php b/lib/OsStatus/Packages/ProbeHelper.php new file mode 100644 index 00000000..a4009823 --- /dev/null +++ b/lib/OsStatus/Packages/ProbeHelper.php @@ -0,0 +1,44 @@ + $patterns + */ + public static function newestModified(IHost $host, array $patterns): ?int { + $newest = null; + foreach ($patterns as $pattern) { + foreach (str_contains($pattern, '*') ? $host->glob($pattern) : [$pattern] as $file) { + $time = $host->modifiedAt($file); + if ($time !== null && ($newest === null || $time > $newest)) { + $newest = $time; + } + } + } + + return $newest; + } + + /** + * The first line of a command's output, short enough for a diagnostic. + */ + public static function firstLine(string $text): string { + return mb_substr(trim((string)strtok(trim($text), "\n")), 0, 200); + } +} diff --git a/lib/OsStatus/Packages/Rpm.php b/lib/OsStatus/Packages/Rpm.php new file mode 100644 index 00000000..67ad3ba3 --- /dev/null +++ b/lib/OsStatus/Packages/Rpm.php @@ -0,0 +1,111 @@ +|null */ + private ?array $installed = null; + private ?int $lastInstallTime = null; + + public function __construct( + private IHost $host, + ) { + } + + public function getDatabasePath(): ?string { + foreach (self::DATABASES as $path) { + if ($this->host->fileExists($path)) { + return $path; + } + } + + return null; + } + + /** + * Installed version (with epoch when it has one) by package name. + * + * @return array + */ + public function getInstalledVersions(): array { + $this->query(); + + return $this->installed ?? []; + } + + public function getLastInstallTime(): ?int { + $this->query(); + + return $this->lastInstallTime; + } + + /** + * Reads every installed package with its install time in one rpm run. + */ + private function query(): void { + if ($this->installed !== null) { + return; + } + + $this->installed = []; + $result = $this->host->run('rpm', ['-qa', '--qf', self::QUERY_FORMAT], 60); + if ($result === null || !$result->succeeded()) { + return; + } + + foreach (preg_split('/\R/', $result->stdout) ?: [] as $line) { + if (preg_match('/^(\S+) (\d+):(\S+) (\d+)$/', trim($line), $matches) !== 1) { + continue; + } + // Several versions of a name (kernels) keep the last listed. + $this->installed[$matches[1]] = $matches[2] === '0' ? $matches[3] : $matches[2] . ':' . $matches[3]; + $this->lastInstallTime = max($this->lastInstallTime ?? 0, (int)$matches[4]); + } + } + + /** + * Splits "name-[epoch:]version-release.arch" into name and version. + * + * @param bool $withArch whether the string ends in an architecture; a + * release such as "1.el9" ends like one + * @return array{0: string, 1: string}|null + */ + public static function splitNevra(string $nevra, bool $withArch = true): ?array { + $withoutArch = $withArch ? (preg_replace('/\.' . self::ARCH . '$/', '', $nevra) ?? $nevra) : $nevra; + if (preg_match('/^(.+)-([^-]+-[^-]+)$/', $withoutArch, $matches) !== 1) { + return null; + } + + return [$matches[1], $matches[2]]; + } +} diff --git a/lib/OsStatus/Packages/Zypper.php b/lib/OsStatus/Packages/Zypper.php new file mode 100644 index 00000000..bdbe9a20 --- /dev/null +++ b/lib/OsStatus/Packages/Zypper.php @@ -0,0 +1,464 @@ + */ + private array $diagnostics = []; + + /** Patches looked up at once; more stay patch rows of their own. */ + private const MAX_PATCH_LOOKUP = 100; + + public function __construct( + private IHost $host, + private Rpm $rpm, + private SystemdProbe $systemd, + ) { + } + + #[\Override] + public function getId(): string { + return 'zypper'; + } + + #[\Override] + public function isAvailable(): bool { + return $this->host->binaryState('zypper') !== false; + } + + /** + * The same package manager, for the given distribution. + */ + public function withRelease(OsRelease $release): self { + $clone = clone $this; + $clone->release = $release; + + return $clone; + } + + /** + * Tumbleweed and MicroOS publish no patches, only new package versions, + * so there is nothing to tell security fixes by. + */ + #[\Override] + public function classifiesSecurity(): bool { + return !$this->isRolling(); + } + + private function isRolling(): bool { + return in_array($this->release?->id, self::ROLLING, true); + } + + #[\Override] + public function getDiagnostics(): array { + return array_values(array_unique($this->diagnostics)); + } + + #[\Override] + public function getPendingUpdates(): array { + $this->diagnostics = []; + if ($this->isRolling()) { + return self::parsePackageUpdates($this->zypperXml(['list-updates'])); + } + + $patches = self::parsePatches($this->zypperXml(['list-patches', '--category', 'security'])); + $packages = self::parsePackageUpdates($this->zypperXml(['list-updates'])); + + return self::mergePatches($patches, $packages, $this->patchPackages($patches)); + } + + /** + * Which packages each patch updates, from the "Conflicts" of + * `zypper info -t patch`, which lists every package older than the fix. + * + * @param list $patches + * @return array> patch name => package names + */ + private function patchPackages(array $patches): array { + $names = array_values(array_filter( + array_map(static fn (PendingUpdate $patch): string => $patch->name, $patches), + static fn (string $name): bool => preg_match('/^[A-Za-z0-9][A-Za-z0-9._:+-]*$/', $name) === 1, + )); + if ($names === []) { + return []; + } + + $result = $this->host->run('zypper', ['--non-interactive', '--no-refresh', 'info', '-t', 'patch', ...array_slice($names, 0, self::MAX_PATCH_LOOKUP)], 180); + if ($result === null || !self::isComplete($result)) { + return []; + } + + return self::parsePatchConflicts($result->stdout); + } + + /** + * @return array> patch name => package names, without architecture + */ + public static function parsePatchConflicts(string $output): array { + $packages = []; + $patch = null; + $inConflicts = false; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^Information for patch (\S+):$/', $line, $matches) === 1) { + $patch = $matches[1]; + $packages[$patch] = []; + $inConflicts = false; + continue; + } + if ($patch === null) { + continue; + } + if (preg_match('/^Conflicts\s*:/', $line) === 1) { + $inConflicts = true; + continue; + } + if ($inConflicts && preg_match('/^\s+([A-Za-z0-9._+-]+?)(?:\.' . Rpm::ARCH . ')?\s*[<=>]/', $line, $matches) === 1) { + $packages[$patch][$matches[1]] = true; + continue; + } + $inConflicts = $inConflicts && trim($line) !== '' && preg_match('/^\s/', $line) === 1; + } + + return array_map(static fn (array $names): array => array_keys($names), $packages); + } + + /** + * Marks the package updates a security patch covers as security updates + * with its advisories; patches covering no listed package stay rows of + * their own, so no security fix goes unreported. + * + * @param list $patches + * @param list $packages + * @param array> $coverage patch name => package names + * @return list + */ + public static function mergePatches(array $patches, array $packages, array $coverage): array { + $byPackage = []; + foreach ($patches as $patch) { + foreach ($coverage[$patch->name] ?? [] as $package) { + $byPackage[$package][] = $patch; + } + } + + $covered = []; + $merged = []; + foreach ($packages as $package) { + $fixes = $byPackage[$package->name] ?? []; + if ($fixes === []) { + $merged[] = $package; + continue; + } + + $advisories = []; + $severity = null; + foreach ($fixes as $patch) { + $covered[$patch->name] = true; + $advisories = array_merge($advisories, $patch->advisories); + $severity = PendingUpdate::higherSeverity($severity, $patch->severity); + } + $merged[] = new PendingUpdate( + $package->name, + $package->installedVersion, + $package->candidateVersion, + true, + $package->origin, + array_values(array_unique($advisories)), + $severity, + ); + } + + foreach ($patches as $patch) { + if (!isset($covered[$patch->name])) { + $merged[] = $patch; + } + } + + return $merged; + } + + /** + * @return list + */ + public static function parsePatches(string $xml): array { + $updates = []; + foreach (self::updateElements($xml) as $update) { + if ((string)$update['kind'] !== 'patch' || (string)$update['category'] !== 'security') { + continue; + } + // Patches already applied, or not applicable to this system, are listed too. + if (!in_array((string)$update['status'], ['', 'needed'], true)) { + continue; + } + + $name = (string)$update['name']; + $severity = (string)$update['severity']; + $advisories = [$name]; + foreach ($update->xpath('issue-list/issue[@type="cve"]') ?: [] as $issue) { + $id = (string)$issue['id']; + if (preg_match('/^CVE-\d{4}-\d+$/', $id) === 1 && !in_array($id, $advisories, true)) { + $advisories[] = $id; + } + } + $summary = trim((string)$update->summary); + + $updates[] = new PendingUpdate( + $name, + null, + (string)$update['edition'], + true, + null, + $advisories, + $severity !== '' && $severity !== 'unspecified' ? ucfirst($severity) : null, + null, + PendingUpdate::KIND_PATCH, + $summary !== '' ? mb_substr($summary, 0, 200) : null, + ); + } + + return $updates; + } + + /** + * @return list + */ + public static function parsePackageUpdates(string $xml): array { + $updates = []; + foreach (self::updateElements($xml) as $update) { + if ((string)$update['kind'] !== 'package') { + continue; + } + + $old = (string)$update['edition-old']; + $source = $update->source['alias'] ?? $update->source['url'] ?? null; + $updates[] = new PendingUpdate( + (string)$update['name'], + $old !== '' ? $old : null, + (string)$update['edition'], + false, + $source !== null ? (string)$source : null, + ); + } + + return $updates; + } + + /** + * @return list<\SimpleXMLElement> + */ + private static function updateElements(string $xml): array { + $document = self::parseXml($xml); + if ($document === null) { + return []; + } + + $elements = $document->xpath('//update-list/update') ?: []; + + return array_values(array_filter($elements, static fn (mixed $e): bool => $e instanceof \SimpleXMLElement)); + } + + /** + * Parses zypper's XML without network access or entity expansion. + */ + private static function parseXml(string $xml): ?\SimpleXMLElement { + $start = strpos($xml, ' $arguments + */ + private function zypperXml(array $arguments): string { + $result = $this->host->run('zypper', ['--non-interactive', '--no-refresh', '--xmlout', ...$arguments], 180); + if ($result === null) { + throw new ProbeException('zypper could not be run.'); + } + if ($result->exitCode === self::EXIT_LOCKED) { + throw new ProbeException('zypper is busy: another program holds the package manager lock, so pending updates could not be listed this time.'); + } + + // zypper reports trouble inside the XML stream as . + $xmlError = preg_match('#([^<]*)#', $result->stdout, $matches) === 1 + ? html_entity_decode($matches[1], ENT_QUOTES | ENT_XML1) + : null; + $skipped = $result->exitCode === self::EXIT_REPOS_SKIPPED; + if (!self::isComplete($result) || self::parseXml($result->stdout) === null || ($xmlError !== null && !$skipped)) { + throw new ProbeException('zypper ' . $arguments[0] . ' failed: ' . ($xmlError !== null ? ProbeHelper::firstLine($xmlError) : $result->failure(180))); + } + if ($skipped) { + $this->diagnostics[] = 'zypper skipped repositories it could not read, so pending updates may be incomplete' + . ($xmlError !== null ? ': ' . ProbeHelper::firstLine($xmlError) : '.'); + } + + return $result->stdout; + } + + /** + * Whether zypper finished with its whole output. + */ + private static function isComplete(CommandResult $result): bool { + return ($result->exitCode === 0 || in_array($result->exitCode, self::EXIT_INFORMATIONAL, true)) + && !$result->timedOut && !$result->truncated; + } + + #[\Override] + public function getHeldPackages(): ?array { + $result = $this->host->run('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'locks'], 60); + if ($result === null || !self::isComplete($result)) { + return null; + } + + return self::parseLocks($result->stdout); + } + + /** + * @return list + */ + public static function parseLocks(string $xml): array { + $document = self::parseXml($xml); + if ($document === null) { + return []; + } + + $held = []; + foreach ($document->xpath('//locks/lock') ?: [] as $lock) { + $name = trim((string)$lock->name); + if ($name !== '') { + $held[$name] = new HeldPackage($name, 'zypper addlock'); + } + } + + return array_values($held); + } + + /** + * zypper writes a cookie next to the raw metadata of each repository on + * every refresh. + */ + #[\Override] + public function getListsRefreshedAt(): ?int { + return ProbeHelper::newestModified($this->host, ['/var/cache/zypp/raw/*/cookie', '/var/cache/zypp/solv/*/cookie', '/var/cache/zypp/raw/*/repodata/repomd.xml']); + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + return $this->rpm->getLastInstallTime(); + } + + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + $transactional = $this->systemd->getUnitFileState('transactional-update.timer'); + if ($transactional === 'enabled') { + return new AutomaticUpdates(AutomaticUpdates::STATE_ENABLED, 'transactional-update', AutomaticUpdates::SCOPE_ALL); + } + + $yast = $this->host->readFile('/etc/sysconfig/automatic_online_update', 65536); + if ($yast !== null && preg_match('/^AOU_ENABLE_CRONJOB="?(true|yes)"?/mi', $yast) === 1) { + $securityOnly = preg_match('/^AOU_PATCH_CATEGORIES="?security"?/mi', $yast) === 1; + + return new AutomaticUpdates( + AutomaticUpdates::STATE_ENABLED, + 'yast2-online-update', + $securityOnly ? AutomaticUpdates::SCOPE_SECURITY : AutomaticUpdates::SCOPE_ALL, + ); + } + + return $this->systemd->isRunning() ? new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED) : AutomaticUpdates::unknown(); + } + + /** + * `zypper needs-rebooting` only knows what /etc/zypp/needreboot lists, + * which is nothing at all on some images, so its "no" is not a complete + * answer and the generic core library check still runs. + */ + #[\Override] + public function getRebootReasons(): array { + $result = $this->host->run('zypper', ['--non-interactive', 'needs-rebooting'], 60); + if (($result !== null && $result->exitCode === self::EXIT_REBOOT_NEEDED) + || ($result === null && $this->host->fileExists('/run/reboot-needed'))) { + return [new RebootReason(RebootReason::KIND_DISTRIBUTION, null, [], $this->host->modifiedAt('/run/reboot-needed'))]; + } + + return []; + } + + #[\Override] + public function hasOwnRebootCheck(): bool { + return false; + } + + #[\Override] + public function getUpgradeCommands(): array { + if ($this->isRolling()) { + return [ + 'all' => $this->host->hasBinary('transactional-update') ? 'sudo transactional-update dup' : 'sudo zypper refresh && sudo zypper dup', + 'security' => null, + ]; + } + + return [ + 'all' => 'sudo zypper refresh && sudo zypper patch', + 'security' => 'sudo zypper refresh && sudo zypper patch --category security', + ]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return $this->rpm->getDatabasePath(); + } +} diff --git a/lib/OsStatus/Verdict/OsVerdict.php b/lib/OsStatus/Verdict/OsVerdict.php index 519a229b..cb9a03a3 100644 --- a/lib/OsStatus/Verdict/OsVerdict.php +++ b/lib/OsStatus/Verdict/OsVerdict.php @@ -27,7 +27,7 @@ */ class OsVerdict { /** Package managers whose metadata is refreshed in place and can go stale */ - public const LISTS_AGE_MATTERS = ['apt', 'dnf', 'zypper', 'apk']; + public const LISTS_AGE_MATTERS = ['apt', 'dnf', 'zypper', 'apk', 'pacman']; /** A status older than this many collection intervals is stale, */ public const STALE_AFTER_INTERVALS = 3; diff --git a/tests/data/os/alma-9-dnf-automatic.conf b/tests/data/os/alma-9-dnf-automatic.conf new file mode 100644 index 00000000..7b3b1616 --- /dev/null +++ b/tests/data/os/alma-9-dnf-automatic.conf @@ -0,0 +1,97 @@ +### /etc/dnf/automatic.conf +[commands] +# What kind of upgrade to perform: +# default = all available upgrades +# security = only the security upgrades +upgrade_type = default +random_sleep = 0 + +# Maximum time in seconds to wait until the system is on-line and able to +# connect to remote repositories. +network_online_timeout = 60 + +# To just receive updates use dnf-automatic-notifyonly.timer + +# Whether updates should be downloaded when they are available, by +# dnf-automatic.timer. notifyonly.timer, download.timer and +# install.timer override this setting. +download_updates = yes + +# Whether updates should be applied when they are available, by +# dnf-automatic.timer. notifyonly.timer, download.timer and +# install.timer override this setting. +apply_updates = no + +# When the system should reboot following upgrades: +# never = don't reboot after upgrades +# when-changed = reboot after any changes +# when-needed = reboot when necessary to apply changes +reboot = never + +# The command that is run to trigger a system reboot. +reboot_command = "shutdown -r +5 'Rebooting after applying package updates'" + + +[emitters] +# Name to use for this system in messages that are emitted. Default is the +# hostname. +# system_name = my-host + +# How to send messages. Valid options are stdio, email and motd. If +# emit_via includes stdio, messages will be sent to stdout; this is useful +# to have cron send the messages. If emit_via includes email, this +# program will send email itself according to the configured options. +# If emit_via includes motd, /etc/motd file will have the messages. if +# emit_via includes command_email, then messages will be send via a shell +# command compatible with sendmail. +# Default is email,stdio. +# If emit_via is None or left blank, no messages will be sent. +emit_via = stdio + + +[email] +# The address to send email messages from. +email_from = root@example.com + +# List of addresses to send messages to. +email_to = root + +# Name of the host to connect to to send email messages. +email_host = localhost + + +[command] +# The shell command to execute. This is a Python format string, as used in +# str.format(). The format function will pass a shell-quoted argument called +# `body`. +# command_format = "cat" + +# The contents of stdin to pass to the command. It is a format string with the +# same arguments as `command_format`. +# stdin_format = "{body}" + + +[command_email] +# The shell command to use to send email. This is a Python format string, +# as used in str.format(). The format function will pass shell-quoted arguments +# called body, subject, email_from, email_to. +# command_format = "mail -Ssendwait -s {subject} -r {email_from} {email_to}" + +# The contents of stdin to pass to the command. It is a format string with the +# same arguments as `command_format`. +# stdin_format = "{body}" + +# The address to send email messages from. +email_from = root@example.com + +# List of addresses to send messages to. +email_to = root + + +[base] +# This section overrides dnf.conf + +# Use this to filter DNF core messages +debuglevel = 1 +### /usr/share/dnf5/dnf5-plugins/automatic.conf +cat: /usr/share/dnf5/dnf5-plugins/automatic.conf: No such file or directory diff --git a/tests/data/os/alma-9-dnf-check-update-no-cache.stderr b/tests/data/os/alma-9-dnf-check-update-no-cache.stderr new file mode 100644 index 00000000..009d7bb3 --- /dev/null +++ b/tests/data/os/alma-9-dnf-check-update-no-cache.stderr @@ -0,0 +1 @@ +Error: Cache-only enabled but no cache for 'appstream' diff --git a/tests/data/os/alma-9-dnf-needs-restarting-no-plugin.stderr b/tests/data/os/alma-9-dnf-needs-restarting-no-plugin.stderr new file mode 100644 index 00000000..e8c2f845 --- /dev/null +++ b/tests/data/os/alma-9-dnf-needs-restarting-no-plugin.stderr @@ -0,0 +1,2 @@ +No such command: needs-restarting. Please use /usr/bin/dnf --help +It could be a DNF plugin command, try: "dnf install 'dnf-command(needs-restarting)'" diff --git a/tests/data/os/alma-9-dnf-needs-restarting-r b/tests/data/os/alma-9-dnf-needs-restarting-r new file mode 100644 index 00000000..7fc1aaa0 --- /dev/null +++ b/tests/data/os/alma-9-dnf-needs-restarting-r @@ -0,0 +1,5 @@ +Core libraries or services have been updated since boot-up: + * glibc + +Reboot is required to fully utilize these updates. +More information: https://access.redhat.com/solutions/27943 diff --git a/tests/data/os/alma-9-dnf-versionlock b/tests/data/os/alma-9-dnf-versionlock new file mode 100644 index 00000000..57c031a2 --- /dev/null +++ b/tests/data/os/alma-9-dnf-versionlock @@ -0,0 +1,2 @@ +Last metadata expiration check: 0:11:05 ago on Mon Oct 19 18:12:41 2026. +sudo-0:1.9.17p2-3.el9_8.1.* diff --git a/tests/data/os/alpine-apk-list-upgradable b/tests/data/os/alpine-apk-list-upgradable new file mode 100644 index 00000000..50702263 --- /dev/null +++ b/tests/data/os/alpine-apk-list-upgradable @@ -0,0 +1,3 @@ +libcrypto3-3.5.9-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.5.8-r0] +libssl3-3.5.9-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libssl3-3.5.8-r0] +zlib-1.3.2-r1 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.2-r0] diff --git a/tests/data/os/alpine-apk-world-pinned b/tests/data/os/alpine-apk-world-pinned new file mode 100644 index 00000000..1f180b42 --- /dev/null +++ b/tests/data/os/alpine-apk-world-pinned @@ -0,0 +1,7 @@ +alpine-baselayout +alpine-keys +alpine-release +apk-tools +busybox +musl-utils +zlib=1.3.2-r0 diff --git a/tests/data/os/arch-audit-u-pending b/tests/data/os/arch-audit-u-pending new file mode 100644 index 00000000..c3fbc880 --- /dev/null +++ b/tests/data/os/arch-audit-u-pending @@ -0,0 +1 @@ +python-urllib3 is affected by certificate verification bypass, denial of service. High risk! Update to at least 1.26.5-1! diff --git a/tests/data/os/arch-checkupdates-pending b/tests/data/os/arch-checkupdates-pending new file mode 100644 index 00000000..d27febbc --- /dev/null +++ b/tests/data/os/arch-checkupdates-pending @@ -0,0 +1 @@ +python-urllib3 1.26.3-1 -> 2.8.0-1 diff --git a/tests/data/os/arch-pacman-conf-ignorepkg b/tests/data/os/arch-pacman-conf-ignorepkg new file mode 100644 index 00000000..328bef6a --- /dev/null +++ b/tests/data/os/arch-pacman-conf-ignorepkg @@ -0,0 +1 @@ +IgnorePkg = python-requests diff --git a/tests/data/os/arch-pacman-qu-ignored b/tests/data/os/arch-pacman-qu-ignored new file mode 100644 index 00000000..aed801e5 --- /dev/null +++ b/tests/data/os/arch-pacman-qu-ignored @@ -0,0 +1 @@ +python-requests 2.31.0-3 -> 2.34.2-1 [ignored] diff --git a/tests/data/os/fedora-44-dnf-unwritable-home.stderr b/tests/data/os/fedora-44-dnf-unwritable-home.stderr new file mode 100644 index 00000000..9350a89d --- /dev/null +++ b/tests/data/os/fedora-44-dnf-unwritable-home.stderr @@ -0,0 +1 @@ +filesystem error: cannot create directories: Permission denied [/var/www/.local/state] diff --git a/tests/data/os/fedora-44-dnf-versionlock b/tests/data/os/fedora-44-dnf-versionlock new file mode 100644 index 00000000..c0e9d175 --- /dev/null +++ b/tests/data/os/fedora-44-dnf-versionlock @@ -0,0 +1,3 @@ +# Added by 'versionlock add' command on 2026-10-19 18:33:45 +Package name: sudo +evr = 1.9.17-7.p2.fc44 diff --git a/tests/data/os/fedora-44-versionlock.toml b/tests/data/os/fedora-44-versionlock.toml new file mode 100644 index 00000000..4e08791b --- /dev/null +++ b/tests/data/os/fedora-44-versionlock.toml @@ -0,0 +1,19 @@ +version = "1.0" + +[[packages]] +name = "sudo" +comment = "Added by 'versionlock add' command on 2026-10-19 19:33:30" + +[[packages.conditions]] +key = "evr" +comparator = "=" +value = "1.9.17-10.p2.fc44" + +[[packages]] +name = "curl" +comment = "Added by 'versionlock add' command on 2026-10-19 19:33:30" + +[[packages.conditions]] +key = "evr" +comparator = "=" +value = "8.18.0-14.fc44" diff --git a/tests/data/os/leap-16-zypper-info-patch b/tests/data/os/leap-16-zypper-info-patch new file mode 100644 index 00000000..55c0b87a --- /dev/null +++ b/tests/data/os/leap-16-zypper-info-patch @@ -0,0 +1,127 @@ +Loading repository data... +Reading installed packages... + + +Information for patch openSUSE-Leap-16.0-1157: +---------------------------------------------- +Repository : repo-oss (16.0) +Name : openSUSE-Leap-16.0-1157 +Version : 1 +Arch : noarch +Vendor : maintenance@opensuse.org +Status : applied +Category : security +Severity : moderate +Created On : Fri Jul 3 13:19:16 2026 +Interactive : --- +Summary : Security update for glibc +Description : + This update for glibc fixes the following issues + + - CVE-2026-5435: unchecked buffer writing in TSIG handling can lead to an out-of-bounds write (bsc#1263656). + - CVE-2026-6238: insufficient RDATA length validation can lead to application crashes or uninitialized memory disclosure + (bsc#1263658). +Provides : patch:openSUSE-Leap-16.0-1157 = 1 +Conflicts : [53] + glibc.aarch64 < 2.40-160000.6.1 + glibc.noarch < 2.40-160000.6.1 + glibc-devel.aarch64 < 2.40-160000.6.1 + glibc-devel.noarch < 2.40-160000.6.1 + glibc-devel-static.aarch64 < 2.40-160000.6.1 + glibc-devel-static.noarch < 2.40-160000.6.1 + glibc-extra.aarch64 < 2.40-160000.6.1 + glibc-extra.noarch < 2.40-160000.6.1 + glibc-gconv-modules-extra.aarch64 < 2.40-160000.6.1 + glibc-gconv-modules-extra.noarch < 2.40-160000.6.1 + glibc-html < 2.40-160000.6.1 + glibc-i18ndata < 2.40-160000.6.1 + glibc-info < 2.40-160000.6.1 + glibc-lang < 2.40-160000.6.1 + glibc-locale.aarch64 < 2.40-160000.6.1 + glibc-locale.noarch < 2.40-160000.6.1 + glibc-locale-base.aarch64 < 2.40-160000.6.1 + glibc-locale-base.noarch < 2.40-160000.6.1 + glibc-profile.aarch64 < 2.40-160000.6.1 + glibc-profile.noarch < 2.40-160000.6.1 + cross-ppc64le-glibc-devel < 2.40-160000.6.1 + cross-riscv64-glibc-devel < 2.40-160000.6.1 + cross-s390x-glibc-devel < 2.40-160000.6.1 + glibc-utils.aarch64 < 2.40-160000.6.1 + glibc-utils.noarch < 2.40-160000.6.1 + glibc.ppc64le < 2.40-160000.6.1 + glibc-devel.ppc64le < 2.40-160000.6.1 + glibc-devel-static.ppc64le < 2.40-160000.6.1 + glibc-extra.ppc64le < 2.40-160000.6.1 + glibc-gconv-modules-extra.ppc64le < 2.40-160000.6.1 + glibc-locale.ppc64le < 2.40-160000.6.1 + glibc-locale-base.ppc64le < 2.40-160000.6.1 + glibc-profile.ppc64le < 2.40-160000.6.1 + cross-aarch64-glibc-devel < 2.40-160000.6.1 + glibc-utils.ppc64le < 2.40-160000.6.1 + glibc.s390x < 2.40-160000.6.1 + glibc-devel.s390x < 2.40-160000.6.1 + glibc-devel-static.s390x < 2.40-160000.6.1 + glibc-extra.s390x < 2.40-160000.6.1 + glibc-gconv-modules-extra.s390x < 2.40-160000.6.1 + glibc-locale.s390x < 2.40-160000.6.1 + glibc-locale-base.s390x < 2.40-160000.6.1 + glibc-profile.s390x < 2.40-160000.6.1 + glibc-utils.s390x < 2.40-160000.6.1 + glibc.x86_64 < 2.40-160000.6.1 + glibc-devel.x86_64 < 2.40-160000.6.1 + glibc-devel-static.x86_64 < 2.40-160000.6.1 + glibc-extra.x86_64 < 2.40-160000.6.1 + glibc-gconv-modules-extra.x86_64 < 2.40-160000.6.1 + glibc-locale.x86_64 < 2.40-160000.6.1 + glibc-locale-base.x86_64 < 2.40-160000.6.1 + glibc-profile.x86_64 < 2.40-160000.6.1 + glibc-utils.x86_64 < 2.40-160000.6.1 + + +Information for patch openSUSE-Leap-16.0-1187: +---------------------------------------------- +Repository : repo-oss (16.0) +Name : openSUSE-Leap-16.0-1187 +Version : 1 +Arch : noarch +Vendor : maintenance@opensuse.org +Status : applied +Category : security +Severity : important +Created On : Wed Jul 8 13:46:40 2026 +Interactive : --- +Summary : Security update for curl +Description : + This update for curl fixes the following issues + + - CVE-2026-8286: wrong STARTTLS connection reuse (bsc#1268402). + - CVE-2026-8458: wrong reuse for different services (bsc#1268407). + - CVE-2026-8924: traling dot domain super cookie (bsc#1268409). + - CVE-2026-8927: env-set cross-proxy Digest auth state leak (bsc#1268413). + - CVE-2026-9079: stale proxy password leak (bsc#1268415). + - CVE-2026-9080: UAF after pause in socket callback (bsc#1268416). + - CVE-2026-9545: exposing HTTP/3 early data (bsc#1268417). + - CVE-2026-9547: SSH improper host validation (bsc#1268420). + - CVE-2026-10536: HTTP/2 stream-dependency tree UAF (bsc#1268422). + - CVE-2026-12064: proto-default skips SSH verification (bsc#1268427). +Provides : patch:openSUSE-Leap-16.0-1187 = 1 +Conflicts : [18] + curl.aarch64 < 8.14.1-160000.8.1 + curl.noarch < 8.14.1-160000.8.1 + curl-fish-completion < 8.14.1-160000.8.1 + curl-zsh-completion < 8.14.1-160000.8.1 + libcurl-devel.aarch64 < 8.14.1-160000.8.1 + libcurl-devel.noarch < 8.14.1-160000.8.1 + libcurl-devel-doc < 8.14.1-160000.8.1 + libcurl4.aarch64 < 8.14.1-160000.8.1 + libcurl4.noarch < 8.14.1-160000.8.1 + curl.ppc64le < 8.14.1-160000.8.1 + libcurl-devel.ppc64le < 8.14.1-160000.8.1 + libcurl4.ppc64le < 8.14.1-160000.8.1 + curl.s390x < 8.14.1-160000.8.1 + libcurl-devel.s390x < 8.14.1-160000.8.1 + libcurl4.s390x < 8.14.1-160000.8.1 + curl.x86_64 < 8.14.1-160000.8.1 + libcurl-devel.x86_64 < 8.14.1-160000.8.1 + libcurl4.x86_64 < 8.14.1-160000.8.1 + diff --git a/tests/data/os/leap-16-zypper-locks.xml b/tests/data/os/leap-16-zypper-locks.xml new file mode 100644 index 00000000..03f7d740 --- /dev/null +++ b/tests/data/os/leap-16-zypper-locks.xml @@ -0,0 +1,4 @@ + + +curlpackage + diff --git a/tests/data/os/leap-16-zypper-patches-security.xml b/tests/data/os/leap-16-zypper-patches-security.xml new file mode 100644 index 00000000..82ebe745 --- /dev/null +++ b/tests/data/os/leap-16-zypper-patches-security.xml @@ -0,0 +1,90 @@ + + +Loading repository data... +Reading installed packages... + + +Security update for glibcThis update for glibc fixes the following issues + +- CVE-2026-5435: unchecked buffer writing in TSIG handling can lead to an out-of-bounds write (bsc#1263656). +- CVE-2026-6238: insufficient RDATA length validation can lead to application crashes or uninitialized memory disclosure + (bsc#1263658). +VUL-0: CVE-2026-5435: glibc: unchecked buffer writing in TSIG handling can lead to an out-of-bounds writehttps://bugzilla.suse.com/show_bug.cgi?id=1263656VUL-0: CVE-2026-6238: glibc: insufficient RDATA length validation can lead to application crashes or uninitialized memory disclosurehttps://bugzilla.suse.com/show_bug.cgi?id=1263658http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-5435http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-5435http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6238http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6238Security update for curlThis update for curl fixes the following issues + +- CVE-2026-8286: wrong STARTTLS connection reuse (bsc#1268402). +- CVE-2026-8458: wrong reuse for different services (bsc#1268407). +- CVE-2026-8924: traling dot domain super cookie (bsc#1268409). +- CVE-2026-8927: env-set cross-proxy Digest auth state leak (bsc#1268413). +- CVE-2026-9079: stale proxy password leak (bsc#1268415). +- CVE-2026-9080: UAF after pause in socket callback (bsc#1268416). +- CVE-2026-9545: exposing HTTP/3 early data (bsc#1268417). +- CVE-2026-9547: SSH improper host validation (bsc#1268420). +- CVE-2026-10536: HTTP/2 stream-dependency tree UAF (bsc#1268422). +- CVE-2026-12064: proto-default skips SSH verification (bsc#1268427). +VUL-0: CVE-2026-8286: curl: wrong STARTTLS connection reusehttps://bugzilla.suse.com/show_bug.cgi?id=1268402VUL-0: CVE-2026-8458: curl: wrong reuse for different serviceshttps://bugzilla.suse.com/show_bug.cgi?id=1268407VUL-0: CVE-2026-8924: curl: traling dot domain super cookiehttps://bugzilla.suse.com/show_bug.cgi?id=1268409VUL-0: CVE-2026-8927: curl: env-set cross-proxy Digest auth state leakhttps://bugzilla.suse.com/show_bug.cgi?id=1268413VUL-0: CVE-2026-9079: curl: stale proxy password leakhttps://bugzilla.suse.com/show_bug.cgi?id=1268415VUL-0: CVE-2026-9080: curl: UAF after pause in socket callbackhttps://bugzilla.suse.com/show_bug.cgi?id=1268416VUL-0: CVE-2026-9545: curl: exposing HTTP/3 early datahttps://bugzilla.suse.com/show_bug.cgi?id=1268417VUL-0: CVE-2026-9547: curl: SSH improper host validationhttps://bugzilla.suse.com/show_bug.cgi?id=1268420VUL-0: CVE-2026-10536: curl: HTTP/2 stream-dependency tree UAFhttps://bugzilla.suse.com/show_bug.cgi?id=1268422VUL-0: CVE-2026-12064: curl: proto-default skips SSH verificationhttps://bugzilla.suse.com/show_bug.cgi?id=1268427http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8286http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8286http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8458http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8458http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8924http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8924http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8927http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8927http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9079http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9079http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9080http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9080http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9545http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9545http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9547http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9547http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-10536http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-10536http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-12064http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-12064Security update for openssl-3This update for openssl-3 fixes the following issues: + +August 2026 release. + +- CVE-2026-14456: unbounded memory growth in QUIC server incoming channel queue (bsc#1274791). +- CVE-2026-14457: RPK server signature algorithm selection can dereference a missing certificate (bsc#1274792). +- CVE-2026-18798: QUIC server may trigger double free when processing INITIAL packet (bsc#1274777). +- CVE-2026-34181: PKCS#12 files with PBMAC1 are accepted with short HMAC keys (bsc#1266343). +- CVE-2026-54874: excessive memory use buffering DTLS records for a future epoch (bsc#1274795). +- CVE-2026-63072: heap buffer overflow in CMS key unwrapping (bsc#1274788). +- CVE-2026-63073: untrusted sender DN used as format string in CMP response validation (bsc#1274796). +- CVE-2026-63074: CMP indefinite cache growth of `ExtraCerts` (bsc#1274797). +- CVE-2026-63075: QUIC ACK-only packet retention can cause memory exhaustion (bsc#1274798). +- CVE-2026-63076: invalid pointer dereference in CMP server via crafted `protectionAlg` (bsc#1274790). +- CVE-2026-75803: AEAD forgeries with empty ciphertext when using `EVP_Cipher()` (bsc#1275837). +VUL-0: CVE-2026-34181: openssl, openssl-3: PKCS#12 Files with PBMAC1 Are Accepted with Short HMAC Keyshttps://bugzilla.suse.com/show_bug.cgi?id=1266343VUL-0: EMBARGOED: openssl: August 2026 releasehttps://bugzilla.suse.com/show_bug.cgi?id=1274774VUL-0: CVE-2026-18798: openssl: QUIC Server May Trigger Double Free When Processing INITIAL Packethttps://bugzilla.suse.com/show_bug.cgi?id=1274777VUL-0: CVE-2026-63072: openssl: Heap Buffer Overflow in CMS Key Unwrappinghttps://bugzilla.suse.com/show_bug.cgi?id=1274788VUL-0: CVE-2026-63076: openssl: Invalid Pointer Dereference in CMP Server via Crafted protectionAlghttps://bugzilla.suse.com/show_bug.cgi?id=1274790VUL-0: CVE-2026-14456: openssl: Unbounded Memory Growth in QUIC Server Incoming Channel Queuehttps://bugzilla.suse.com/show_bug.cgi?id=1274791VUL-0: CVE-2026-14457: openssl: RPK Server Signature Algorithm Selection Can Dereference a Missing Certificatehttps://bugzilla.suse.com/show_bug.cgi?id=1274792VUL-0: CVE-2026-54874: openssl: Excessive Memory Use Buffering DTLS Records for a Future Epochhttps://bugzilla.suse.com/show_bug.cgi?id=1274795VUL-0: CVE-2026-63073: openssl: Untrusted Sender DN Used as Format String in CMP Response Validationhttps://bugzilla.suse.com/show_bug.cgi?id=1274796VUL-0: CVE-2026-63074: openssl: CMP Indefinite Cache Growth of ExtraCertshttps://bugzilla.suse.com/show_bug.cgi?id=1274797VUL-0: CVE-2026-63075: openssl: QUIC ACK-only Packet Retention Can Cause Memory Exhaustionhttps://bugzilla.suse.com/show_bug.cgi?id=1274798VUL-0: CVE-2026-75803: openssl: AEAD Forgeries with Empty Ciphertext When Using EVP_Cipher()https://bugzilla.suse.com/show_bug.cgi?id=1275837http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-14456http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-14456http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-14457http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-14457http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-18798http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-18798http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-34181http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-34181http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54874http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54874http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63072http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63072http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63073http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63073http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63074http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63074http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63075http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63075http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63076http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63076http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75803http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75803Security update for curlThis update for curl fixes the following issues: + +- CVE-2026-5773: wrong reuse of SMB connection (bsc#1262633). +- CVE-2026-7168: cross-proxy Digest auth state leak (bsc#1263440). +- CVE-2026-8926: password leak with netrc and user in URL (bsc#1268412). +- CVE-2026-13608: OpenLDAP SASL authentication bypass (bsc#1277476). +- CVE-2026-80229: OpenSSL provider use-after-free (bsc#1277479). +- CVE-2026-80230: OpenSSL pinning bypass (bsc#1277480). +VUL-0: CVE-2026-5773: curl: wrong reuse of SMB connectionhttps://bugzilla.suse.com/show_bug.cgi?id=1262633VUL-0: CVE-2026-7168: curl: cross-proxy Digest auth state leakhttps://bugzilla.suse.com/show_bug.cgi?id=1263440VUL-0: CVE-2026-8926: curl: password leak with netrc and user in URLhttps://bugzilla.suse.com/show_bug.cgi?id=1268412VUL-0: CVE-2026-13608: curl: OpenLDAP SASL authentication bypasshttps://bugzilla.suse.com/show_bug.cgi?id=1277476VUL-0: CVE-2026-80229: curl: OpenSSL provider use-after-freehttps://bugzilla.suse.com/show_bug.cgi?id=1277479VUL-0: CVE-2026-80230: curl: OpenSSL pinning bypasshttps://bugzilla.suse.com/show_bug.cgi?id=1277480http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-5773http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-5773http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-7168http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-7168http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8926http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8926http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-13608http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-13608http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80229http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80229http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80230http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80230Security update for glibcThis update for glibc fixes the following issues: + +- CVE-2026-6368: invalid free via wordexp WRDE_APPEND rollback (bsc#1274726). +- CVE-2026-6791: stack overflow in wordexp tilde expansion (bsc#1274723). +- CVE-2026-8674: reachable assert in `resolv/resolv_conf.c:update_from_conf` allows an attacker in a local network to + trigger a process crash (bsc#1281297). +- CVE-2026-18374: heap buffer overflow in the fopen ccs extension (bsc#1277262). +- CVE-2026-19499: buffer overflow in strfmon right-justification padding (bsc#1276892). +- CVE-2026-19542: out-of-bounds array write in tdelete (bsc#1276946). +- CVE-2026-77117: SHIFT_JISX0213 decoding lacks pending character reset (bsc#1277921). +- CVE-2026-80489: EUC_JISX0213 decoding lacks pending character reset (bsc#1277922). +- CVE-2026-86805: TOCTOU race condition in the dynamic loader allows local attackers to escalate privileges + (bsc#1282509). + +Other changes: + +- Add -flive-patching=inline-clone to avoid untraceable inter-procedural optimizations (bsc#1277247). +VUL-0: CVE-2026-6791: glibc: wordexp: unbounded alloca (strndupa) in parse_tildehttps://bugzilla.suse.com/show_bug.cgi?id=1274723VUL-0: CVE-2026-6368: glibc: wordexp WRDE_APPEND rollback restores stale we_wordv, leading to invalid free in wordfreehttps://bugzilla.suse.com/show_bug.cgi?id=1274726VUL-0: CVE-2026-19499: glibc: glibc: Buffer Overflow in strfmon right-justification paddinghttps://bugzilla.suse.com/show_bug.cgi?id=1276892VUL-0: CVE-2026-19542: glibc: glibc: Fix out-of-bounds array write in tdeletehttps://bugzilla.suse.com/show_bug.cgi?id=1276946openssl and glibc are being compiled with propagating optimizations we can't track for livepatchinghttps://bugzilla.suse.com/show_bug.cgi?id=1277247VUL-0: CVE-2026-18374: glibc: overflow in the fopen ccs extensionhttps://bugzilla.suse.com/show_bug.cgi?id=1277262VUL-0: CVE-2026-77117: glibc: non-progress DoS in SHIFT_JISX0213 -&gt; UCS-4 conversion state handlinghttps://bugzilla.suse.com/show_bug.cgi?id=1277921VUL-0: CVE-2026-80489: glibc: glibc: non-progress DoS in SHIFT_JISX0213 -&gt; UCS-4 conversion state handlinghttps://bugzilla.suse.com/show_bug.cgi?id=1277922VUL-0: CVE-2026-8674: glibc: reachable assert in `resolv/resolv_conf.c:update_from_conf` allows an attacker in a local network to trigger a process crashhttps://bugzilla.suse.com/show_bug.cgi?id=1281297VUL-0: CVE-2026-86805: glibc: TOCTOU race condition in the dynamic loader allows local attackers to escalate privilegeshttps://bugzilla.suse.com/show_bug.cgi?id=1282509http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6368http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6368http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6791http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6791http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8674http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8674http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-18374http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-18374http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-19499http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-19499http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-19542http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-19542http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-77117http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-77117http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80489http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80489http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-86805http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-86805Recommended update for timezoneThis update for timezone fixes the following issues: + +Changes in timezone: + +- Update to 2026e: + * Manitoba moves to permanent -05 on 2026-10-31 + +- Update to 2026d: + * Canada’s Northwest Territories moved to permanent -06 on 2026-08-21 + * Obsolescent settings like TZ="EST5EDT" now conform better to POSIX + * Fix security, performance and porting bugs in zic and localtime +Security update for openssl-3This update for openssl-3 fixes the following issues: + +- CVE-2026-35189: Excessive Memory Allocation in Relative CRLDP Processing (bsc#1280589). +- CVE-2026-35191: QUIC Unvalidated Amplification Credit may be Over Accounted (bsc#1280590). +- CVE-2026-54872: Timing Side-Channel in Scalar Multiplication for Non-NIST EC Curves (bsc#1280591). +- CVE-2026-54875: Non-Constant-Time SM2 Scalar Multiplication on ARM64 and RISC-V (bsc#1280592). +- CVE-2026-72897: Out-of-Bounds Access After SSL_set_SSL_CTX() During a Handshake (bsc#1280594). +- CVE-2026-75804: QUIC Connection-Level Flow Control is Not Enforced for Streams (bsc#1280595). +- CVE-2026-75805: NULL Pointer Dereference in CMP Client Revocation Response Handling (bsc#1280596). +- CVE-2026-75806: Unauthenticated and Undersized DTLS 1.2 AEAD Record Causes DoS (bsc#1280597). +- CVE-2026-77696: Timing Side-Channel in SM2 Signature Generation (bsc#1280598). +- CVE-2026-84782: DTLS Retransmits Handshake Messages From a Stale Buffer Offset (bsc#1280587). +- CVE-2026-84784: QUIC: Unbounded RETIRE_CONNECTION_ID Backlog (bsc#1280599). +VUL-0: CVE-2026-84782: openssl-3: DTLS Retransmits Handshake Messages From a Stale Buffer Offsethttps://bugzilla.suse.com/show_bug.cgi?id=1280587VUL-0: CVE-2026-35189: openssl-3: Excessive Memory Allocation in Relative CRLDP Processinghttps://bugzilla.suse.com/show_bug.cgi?id=1280589VUL-0: CVE-2026-35191: openssl-3: QUIC Unvalidated Amplification Credit may be Over Accountedhttps://bugzilla.suse.com/show_bug.cgi?id=1280590VUL-0: CVE-2026-54872: openssl-3: Timing Side-Channel in Scalar Multiplication for Non-NIST EC Curveshttps://bugzilla.suse.com/show_bug.cgi?id=1280591VUL-0: CVE-2026-54875: openssl-3: Non-Constant-Time SM2 Scalar Multiplication on ARM64 and RISC-Vhttps://bugzilla.suse.com/show_bug.cgi?id=1280592VUL-0: CVE-2026-72897: openssl-3: Out-of-Bounds Access After SSL_set_SSL_CTX() During a Handshakehttps://bugzilla.suse.com/show_bug.cgi?id=1280594VUL-0: CVE-2026-75804: openssl-3: QUIC Connection-Level Flow Control is Not Enforced for Streamshttps://bugzilla.suse.com/show_bug.cgi?id=1280595VUL-0: CVE-2026-75805: openssl-3: NULL Pointer Dereference in CMP Client Revocation Response Handlinghttps://bugzilla.suse.com/show_bug.cgi?id=1280596VUL-0: CVE-2026-75806: openssl-3: Unauthenticated and Undersized DTLS 1.2 AEAD Record Causes DoShttps://bugzilla.suse.com/show_bug.cgi?id=1280597VUL-0: CVE-2026-77696: openssl-3: Timing Side-Channel in SM2 Signature Generationhttps://bugzilla.suse.com/show_bug.cgi?id=1280598VUL-0: CVE-2026-84784: openssl-3: QUIC: Unbounded RETIRE_CONNECTION_ID Backloghttps://bugzilla.suse.com/show_bug.cgi?id=1280599http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-35189http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-35189http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-35191http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-35191http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54872http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54872http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54875http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54875http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-72897http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-72897http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75804http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75804http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75805http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75805http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75806http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75806http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-77696http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-77696http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-84782http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-84782http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-84784http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-84784 + + diff --git a/tests/data/os/leap-16-zypper-updates.xml b/tests/data/os/leap-16-zypper-updates.xml new file mode 100644 index 00000000..224aa3b8 --- /dev/null +++ b/tests/data/os/leap-16-zypper-updates.xml @@ -0,0 +1,21 @@ + + +Loading repository data... +Reading installed packages... + + +Standard Shared Libraries (from the GNU C Library)The GNU C Library provides the most important standard libraries used +by nearly all programs: the standard C library, the standard math +library, and the POSIX thread library. A system is not functional +without these libraries.en_US Locale Data for Localized ProgramsLocale data for the internationalisation features of the GNU C library. +This package contains only the U.S. English locale.Library for transferring data from URLsThe cURL shared library for accessing data using different +network protocols.OpenSSL FIPS providerThis package contains the OpenSSL FIPS provider.Secure Sockets and Transport Layer SecurityOpenSSL is a software library to be used in applications that need to +secure communications over computer networks against eavesdropping or +need to ascertain the identity of the party at the other end. +OpenSSL contains an implementation of the SSL and TLS protocols.Secure Sockets and Transport Layer SecurityOpenSSL is a software library to be used in applications that need to +secure communications over computer networks against eavesdropping or +need to ascertain the identity of the party at the other end. +OpenSSL contains an implementation of the SSL and TLS protocols.Time Zone DescriptionsThese are configuration files that describe available time zones. You +can select an appropriate time zone for your system with YaST. + + diff --git a/tests/data/os/tumbleweed-os-release b/tests/data/os/tumbleweed-os-release new file mode 100644 index 00000000..82a2a969 --- /dev/null +++ b/tests/data/os/tumbleweed-os-release @@ -0,0 +1,16 @@ +NAME="openSUSE Tumbleweed" +# VERSION="20261011" +ID="opensuse-tumbleweed" +ID_LIKE="opensuse suse" +VERSION_ID="20261011" +PRETTY_NAME="openSUSE Tumbleweed" +ANSI_COLOR="0;32" +# CPE 2.3 format, boo#1217921 +CPE_NAME="cpe:2.3:o:opensuse:tumbleweed:20261011:*:*:*:*:*:*:*" +#CPE 2.2 format +#CPE_NAME="cpe:/o:opensuse:tumbleweed:20261011" +BUG_REPORT_URL="https://bugzilla.opensuse.org" +SUPPORT_URL="https://bugs.opensuse.org" +HOME_URL="https://www.opensuse.org" +DOCUMENTATION_URL="https://en.opensuse.org/Portal:Tumbleweed" +LOGO="distributor-logo-Tumbleweed" diff --git a/tests/data/os/tumbleweed-zypper-updates.xml b/tests/data/os/tumbleweed-zypper-updates.xml new file mode 100644 index 00000000..a497bffc --- /dev/null +++ b/tests/data/os/tumbleweed-zypper-updates.xml @@ -0,0 +1,18 @@ + + +Loading repository data... +Reading installed packages... + + +The public gpg keys for rpm package signature verificationThis package contains the gpg keys that are used to sign the +openSUSE rpm packages. The keys installed here are not actually +used by anything. rpm/zypper use the keys in the rpm db instead.Test update that requires a system rebootPackage for testing the update stack during product development. + +An update for this package should inform the user that a system +reboot is necessary. This is used, for example, in kernel updates.Test update that includes security fixesPackage for testing the update stack during product development. + +An update for this package should have its category set to +"security", indicating that one or more security vulnerabilities +are being fixed. + + diff --git a/tests/lib/OsStatus/DnfTest.php b/tests/lib/OsStatus/DnfTest.php new file mode 100644 index 00000000..48bc049f --- /dev/null +++ b/tests/lib/OsStatus/DnfTest.php @@ -0,0 +1,341 @@ +host = new FakeHost(); + $this->host->binaries[] = 'dnf-3'; + $this->host->globs['/var/cache/dnf/*-updateinfo.solvx'] = ['/var/cache/dnf/baseos-updateinfo.solvx']; + $this->dnf = new Dnf($this->host, new Rpm($this->host), new SystemdProbe($this->host)); + } + + public function testParsesCheckUpdate(): void { + $updates = Dnf::parseCheckUpdate(implode("\n", [ + '', + 'openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos', + 'kernel.x86_64 5.14.0-503.15.1.el9_5 baseos', + 'tzdata.noarch 2025a-1.el9 appstream', + 'Obsoleting Packages', + 'grub2-tools.x86_64 1:2.06-92.el9 baseos', + ])); + + $this->assertSame([ + 'openssl-libs' => ['1:3.2.2-6.el9_5.1', 'baseos'], + 'kernel' => ['5.14.0-503.15.1.el9_5', 'baseos'], + 'tzdata' => ['2025a-1.el9', 'appstream'], + ], $updates); + } + + public function testJoinsWrappedCheckUpdateLines(): void { + // dnf 4 fits the columns into 80 characters and continues a row on the + // next line after a column that does not fit (dnf/cli/output.py). + $updates = Dnf::parseCheckUpdate(implode("\n", [ + '', + 'glibc.aarch64 2.34-283.el9_8 rhel-9-for-aarch64-baseos-rpms', + 'glibc-minimal-langpack.aarch64', + ' 2.34-283.el9_8 rhel-9-for-aarch64-baseos-rpms', + 'python3-cryptography-vectors.noarch', + ' 38.0.4-1.el9_4.20260101.1', + ' rhel-9-for-aarch64-appstream-rpms', + 'tzdata.noarch 2026e-1.el9_8 rhel-9-for-aarch64-baseos-rpms', + 'not-a-package-line', + 'Obsoleting Packages', + 'grub2-tools.aarch64', + ' 1:2.06-92.el9 rhel-9-for-aarch64-baseos-rpms', + ])); + + $this->assertSame([ + 'glibc' => ['2.34-283.el9_8', 'rhel-9-for-aarch64-baseos-rpms'], + 'glibc-minimal-langpack' => ['2.34-283.el9_8', 'rhel-9-for-aarch64-baseos-rpms'], + 'python3-cryptography-vectors' => ['38.0.4-1.el9_4.20260101.1', 'rhel-9-for-aarch64-appstream-rpms'], + 'tzdata' => ['2026e-1.el9_8', 'rhel-9-for-aarch64-baseos-rpms'], + ], $updates); + } + + public function testAcceptsEveryArchitecture(): void { + $updates = Dnf::parseCheckUpdate(implode("\n", [ + 'openssl-libs.x86_64_v2 1:3.2.2-6.el10_0.1 baseos', + 'glibc.ppc64 2.39-37.el10 baseos', + 'zlib-ng.s390 2.2.3-1.el10 baseos', + 'python3.12-pip.noarch 23.2.1-4.el10 appstream', + ])); + + $this->assertSame(['openssl-libs', 'glibc', 'zlib-ng', 'python3.12-pip'], array_keys($updates)); + } + + public function testParsesDnf4Advisories(): void { + $advisories = Dnf::parseAdvisories(implode("\n", [ + 'RHSA-2026:1234 Important/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64', + 'RHSA-2026:1240 Moderate/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64', + 'ALSA-2026:0099 Critical/Sec. kernel-5.14.0-503.15.1.el9_5.x86_64', + 'not an advisory line', + ])); + + $this->assertSame(['RHSA-2026:1234', 'RHSA-2026:1240'], $advisories['openssl-libs']['ids']); + $this->assertSame('Important', $advisories['openssl-libs']['severity']); + $this->assertSame('Critical', $advisories['kernel']['severity']); + } + + public function testParsesDnf5Advisories(): void { + $advisories = Dnf::parseAdvisories(implode("\n", [ + 'Name Type Severity Package Issued', + 'FEDORA-2026-1a2b3c4d security Moderate curl-8.11.1-4.fc43.x86_64 2026-10-01 00:00:00', + 'FEDORA-2026-99887766 security None libxml2-2.12.9-1.fc43.x86_64 2026-10-02 00:00:00', + ])); + + $this->assertSame(['FEDORA-2026-1a2b3c4d'], $advisories['curl']['ids']); + $this->assertSame('Moderate', $advisories['curl']['severity']); + $this->assertNull($advisories['libxml2']['severity']); + } + + public function testPendingUpdatesAreMatchedWithAdvisories(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], "openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos\ntzdata.noarch 2025a-1.el9 appstream\n", 100) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], "RHSA-2026:1234 Important/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64\n") + ->withCommand('rpm', ['-qa', '--qf', Rpm::QUERY_FORMAT], "openssl-libs 1:3.2.2-6.el9_5 1792000000\ntzdata 0:2024b-2.el9 1792100000\n"); + + $updates = $this->dnf->getPendingUpdates(); + + $this->assertCount(2, $updates); + $this->assertTrue($updates[0]->security); + $this->assertSame(['RHSA-2026:1234'], $updates[0]->advisories); + $this->assertSame('1:3.2.2-6.el9_5', $updates[0]->installedVersion); + $this->assertFalse($updates[1]->security); + $this->assertSame('2024b-2.el9', $updates[1]->installedVersion); + $this->assertSame(1792100000, $this->dnf->getLastUpgradeAt()); + $this->assertCount(1, array_filter($this->host->ran, static fn (string $command): bool => str_starts_with($command, 'rpm ')), 'rpm -qa runs once'); + } + + private function withPendingUpdates(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], "openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos\ntzdata.noarch 2025a-1.el9 appstream\n", 100); + } + + public function testFailedAdvisoriesLeaveTheUpdatesUnclassified(): void { + $this->withPendingUpdates(); + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], '', 1, "Error: Cache-only enabled but no cache for 'appstream'\n"); + + $updates = $this->dnf->getPendingUpdates(); + + $this->assertSame(['openssl-libs', 'tzdata'], array_map(static fn ($u) => $u->name, $updates)); + $this->assertFalse($updates[0]->security); + $this->assertFalse($this->dnf->classifiesSecurity(), 'No security fix is ruled out'); + $this->assertSame(["dnf could not list security advisories, so pending updates are not told apart into security fixes and others: Error: Cache-only enabled but no cache for 'appstream'"], $this->dnf->getDiagnostics()); + } + + public function testRepositoriesWithoutUpdateinfoDoNotClassify(): void { + unset($this->host->globs['/var/cache/dnf/*-updateinfo.solvx']); + $this->withPendingUpdates(); + + $updates = $this->dnf->getPendingUpdates(); + + $this->assertCount(2, $updates); + $this->assertFalse($this->dnf->classifiesSecurity()); + $this->assertNotContains('dnf-3 -q --cacheonly updateinfo list --security --available', $this->host->ran); + $this->assertSame([], $this->dnf->getDiagnostics()); + } + + public function testUpdateinfoOfEitherDnfCounts(): void { + unset($this->host->globs['/var/cache/dnf/*-updateinfo.solvx']); + $this->host->globs['/var/cache/libdnf5/*/repodata/*updateinfo*'] = ['/var/cache/libdnf5/updates-7608c6d2bd2ece7d/repodata/1a2b-updateinfo.xml.zst']; + + $this->assertTrue($this->dnf->classifiesSecurity()); + } + + public function testCentOsStreamDoesNotClassify(): void { + $stream = OsRelease::parse("NAME=\"CentOS Stream\"\nID=\"centos\"\nID_LIKE=\"rhel fedora\"\nVERSION_ID=\"10\"\n"); + $this->assertNotNull($stream); + $dnf = $this->dnf->withRelease($stream); + + $this->assertFalse($dnf->classifiesSecurity()); + $this->assertTrue($this->dnf->classifiesSecurity(), 'withRelease() leaves the original alone'); + } + + public function testNoUpdatesSkipsTheAdvisories(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 0); + + $this->assertSame([], $this->dnf->getPendingUpdates()); + $this->assertNotContains('dnf-3 -q --cacheonly updateinfo list --security --available', $this->host->ran); + } + + public function testCheckUpdateFailure(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 1, "Error: Cache-only enabled but no cache for 'baseos'\n"); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage("no cache for 'baseos'"); + $this->dnf->getPendingUpdates(); + } + + public function testVersionlock(): void { + $dnf4 = Dnf::parseVersionlock("Last metadata expiration check: 0:10:00 ago.\nmariadb-server-3:10.11.10-1.el9.*\n"); + $this->assertSame('mariadb-server', $dnf4[0]->name); + $this->assertSame('3:10.11.10-1.el9', $dnf4[0]->constraint, 'The release is not taken for an architecture'); + + $dnf5 = Dnf::parseVersionlock("# Added by 'versionlock add' command on 2026-10-01 10:00:00\nPackage name: nginx\nevr = 1.26.2-1.fc43\n"); + $this->assertSame('nginx', $dnf5[0]->name); + } + + public function testAutomaticInstallTimer(): void { + $this->host->withCommand('systemctl', ['is-enabled', 'dnf-automatic-install.timer'], "enabled\n") + ->withFile('/etc/dnf/automatic.conf', "[commands]\nupgrade_type = security\napply_updates = no\n"); + + $automatic = $this->dnf->getAutomaticUpdates(); + + $this->assertSame(AutomaticUpdates::STATE_ENABLED, $automatic->state); + $this->assertSame(AutomaticUpdates::SCOPE_SECURITY, $automatic->scope); + } + + public function testAutomaticTimerThatOnlyDownloads(): void { + $this->host->withCommand('systemctl', ['is-enabled', 'dnf-automatic-install.timer'], "disabled\n", 1) + ->withCommand('systemctl', ['is-enabled', 'dnf-automatic.timer'], "enabled\n") + ->withFile('/etc/dnf/automatic.conf', "[commands]\napply_updates = no\n"); + + $automatic = $this->dnf->getAutomaticUpdates(); + + $this->assertSame(AutomaticUpdates::STATE_DISABLED, $automatic->state); + $this->assertSame(AutomaticUpdates::SCOPE_DOWNLOAD, $automatic->scope); + } + + public function testNeedsRestartingReboot(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], "Core libraries or services have been updated since boot-up:\n * glibc\n * kernel\n\nReboot is required to fully utilize these updates.\n", 1); + + $reasons = $this->dnf->getRebootReasons(); + + $this->assertSame(['glibc', 'kernel'], $reasons[0]->packages); + } + + public function testNeedsRestartingNoReboot(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], "No core libraries or services have been updated since boot-up.\n", 0); + + $this->assertSame([], $this->dnf->getRebootReasons()); + $this->assertTrue($this->dnf->hasOwnRebootCheck()); + $this->assertCount(1, $this->host->ran, 'needs-restarting runs once'); + } + + public function testNeedsRestartingWithoutThePlugin(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], '', 1, FakeHost::fixture('alma-9-dnf-needs-restarting-no-plugin.stderr')); + + $this->assertSame([], $this->dnf->getRebootReasons(), 'A missing plugin is no reason to reboot'); + $this->assertFalse($this->dnf->hasOwnRebootCheck(), 'The generic core library check answers instead'); + } + + public function testNeedsRestartingWithoutCache(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], '', 1, FakeHost::fixture('alma-9-dnf-check-update-no-cache.stderr')); + + $this->assertSame([], $this->dnf->getRebootReasons()); + $this->assertFalse($this->dnf->hasOwnRebootCheck()); + } + + public function testNeedsRestartingTimeout(): void { + $this->host->commands['dnf-3 -q --cacheonly needs-restarting -r'] = new CommandResult(1, "Reboot is required to fully utilize these updates.\n", '', true); + + $this->assertSame([], $this->dnf->getRebootReasons()); + $this->assertFalse($this->dnf->hasOwnRebootCheck()); + } + + public function testDnf5IsNeverRunAsTheWebServer(): void { + $this->host->binaries[] = 'dnf5'; + $this->host->withFile('/etc/dnf/versionlock.toml', FakeHost::fixture('fedora-44-versionlock.toml')); + + try { + $this->dnf->getPendingUpdates(); + $this->fail('dnf 5 must not be asked'); + } catch (ProbeException $e) { + $this->assertStringContainsString('root helper', $e->getMessage()); + } + $held = $this->dnf->getHeldPackages(); + + $this->assertSame(['sudo' => '1.9.17-10.p2.fc44', 'curl' => '8.18.0-14.fc44'], array_combine( + array_map(static fn ($h) => $h->name, $held ?? []), + array_map(static fn ($h) => $h->constraint, $held ?? []), + )); + $this->assertSame([], $this->dnf->getRebootReasons()); + $this->assertFalse($this->dnf->hasOwnRebootCheck()); + $this->assertSame([], $this->host->ran, 'Not a single dnf process'); + } + + public function testDnf4RunsAsDnf3EvenWhenDnfIsThere(): void { + $this->host->binaries[] = 'dnf'; + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 0); + + $this->assertTrue($this->dnf->isAvailable()); + $this->assertSame([], $this->dnf->getPendingUpdates()); + $this->assertSame(['dnf-3 -q --cacheonly check-update'], $this->host->ran); + } + + public function testDnfThatIsNotDnf4IsNeverRun(): void { + $host = new FakeHost(); + $host->binaries = ['dnf', 'rpm']; + $dnf = new Dnf($host, new Rpm($host), new SystemdProbe($host)); + + $this->assertTrue($dnf->isAvailable()); + try { + $dnf->getPendingUpdates(); + $this->fail('An unidentified dnf must not be asked'); + } catch (NeedsRootException $e) { + $this->assertStringContainsString('root helper', $e->getMessage()); + } + $this->assertSame([], $dnf->getRebootReasons()); + $this->assertFalse($dnf->hasOwnRebootCheck()); + $this->assertNull($dnf->getHeldPackages()); + $this->assertSame([], $host->ran); + } + + public function testDnf5NextToDnf4IsLeftToTheHelper(): void { + $this->host->binaries[] = 'dnf5'; + + $this->expectException(NeedsRootException::class); + $this->dnf->getPendingUpdates(); + } + + public function testDnf5WithoutLocks(): void { + $this->host->binaries[] = 'dnf5'; + $this->host->modified['/etc/dnf'] = 1; + + $this->assertSame([], $this->dnf->getHeldPackages()); + } + + public function testListsRefreshTimeIncludesSingleFiles(): void { + $this->host->globs['/var/cache/dnf/*/metalink.xml'] = ['/var/cache/dnf/baseos-1/metalink.xml']; + $this->host->modified['/var/cache/dnf/baseos-1/metalink.xml'] = 1792438330; + $this->host->modified['/var/cache/dnf/last_makecache'] = 1792438500; + + $this->assertSame(1792438500, $this->dnf->getListsRefreshedAt()); + } + + public function testListsRefreshTimeFromMetalinks(): void { + $this->host->globs['/var/cache/libdnf5/*/metalink.xml'] = ['/var/cache/libdnf5/fedora-1/metalink.xml', '/var/cache/libdnf5/updates-2/metalink.xml']; + $this->host->modified['/var/cache/libdnf5/fedora-1/metalink.xml'] = 1792438330; + $this->host->modified['/var/cache/libdnf5/updates-2/metalink.xml'] = 1792438400; + + $this->assertSame(1792438400, $this->dnf->getListsRefreshedAt()); + } + + public function testSplitNevra(): void { + $this->assertSame(['openssl-libs', '1:3.2.2-6.el9_5.1'], Rpm::splitNevra('openssl-libs-1:3.2.2-6.el9_5.1.x86_64')); + $this->assertSame(['python3-dnf-plugin-versionlock', '4.3.0-16.el9'], Rpm::splitNevra('python3-dnf-plugin-versionlock-4.3.0-16.el9.noarch')); + $this->assertSame(['openssl-libs', '1:3.2.2-6.el10_0.1'], Rpm::splitNevra('openssl-libs-1:3.2.2-6.el10_0.1.x86_64_v2')); + $this->assertSame(['glibc', '2.39-37.el10'], Rpm::splitNevra('glibc-2.39-37.el10.s390')); + $this->assertSame(['sudo', '0:1.9.17p2-3.el9_8.1'], Rpm::splitNevra('sudo-0:1.9.17p2-3.el9_8.1', false)); + $this->assertNull(Rpm::splitNevra('nonsense')); + } +} diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index 41c4b0f5..6921c3a7 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -20,8 +20,13 @@ use OCA\ServerInfo\OsStatus\OsStatus; use OCA\ServerInfo\OsStatus\OsStatusCollector; use OCA\ServerInfo\OsStatus\OsStatusStore; +use OCA\ServerInfo\OsStatus\Packages\Apk; use OCA\ServerInfo\OsStatus\Packages\Apt; +use OCA\ServerInfo\OsStatus\Packages\Dnf; use OCA\ServerInfo\OsStatus\Packages\PackageManagerFactory; +use OCA\ServerInfo\OsStatus\Packages\Pacman; +use OCA\ServerInfo\OsStatus\Packages\Rpm; +use OCA\ServerInfo\OsStatus\Packages\Zypper; use OCA\ServerInfo\OsStatus\Reboot\CoreLibraryProbe; use OCA\ServerInfo\OsStatus\Reboot\KernelProbe; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; @@ -57,6 +62,7 @@ protected function setUp(): void { private function collector(int $now = self::NOW): OsStatusCollector { $appConfig = $this->createMock(IAppConfig::class); $appConfig->method('getAppValueString')->willReturnCallback(fn (string $key, string $default = ''): string => (string)($this->config[$key] ?? $default)); + $appConfig->method('getAppValueBool')->willReturnCallback(fn (string $key, bool $default = false): bool => (bool)($this->config[$key] ?? $default)); $appConfig->method('getAppValueArray')->willReturnCallback(fn (string $key): array => $this->config[$key] ?? []); $appConfig->method('setAppValueArray')->willReturnCallback(function (string $key, array $value): bool { $this->config[$key] = $value; @@ -74,6 +80,7 @@ private function collector(int $now = self::NOW): OsStatusCollector { $host = $this->host; $systemd = new SystemdProbe($host); + $rpm = new Rpm($host); return new OsStatusCollector( $host, @@ -83,7 +90,7 @@ private function collector(int $now = self::NOW): OsStatusCollector { new EnvironmentDetector($host), new SupportTable(), new SubscriptionProbe($host), - new PackageManagerFactory(new Apt($host, $systemd)), + new PackageManagerFactory(new Apt($host, $systemd), new Dnf($host, $rpm, $systemd), new Zypper($host, $rpm, $systemd), new Pacman($host, $time, $appConfig), new Apk($host)), new KernelProbe($host), new CoreLibraryProbe($host), $systemd, @@ -261,6 +268,45 @@ public function testUpdatesFromTheHelperWhenTheWebServerCannotAsk(): void { $this->assertContains('No supported package manager was found for Ubuntu 24.04.3 LTS.', $status->diagnostics); } + public function testDnf5LeavesTheUpdatesToTheHelperWithoutComplaint(): void { + $this->host->withFile('/etc/os-release', "NAME=\"Fedora Linux\"\nID=fedora\nVERSION_ID=44\nPRETTY_NAME=\"Fedora Linux 44\"\n"); + $this->host->binaries = ['dnf', 'dnf5']; + + $without = $this->collector()->collect(); + + $this->assertSame('dnf', $without->packageManager); + $this->assertNull($without->updates); + $this->assertContains('dnf 5 can only be asked about pending updates as root, so the root helper reports them on this system.', $without->diagnostics); + + $this->withRootsHelperReportPath(); + $this->host->withFile(HelperReportReader::DEFAULT_PATH, (string)json_encode([ + 'schema' => 1, 'helperVersion' => '1.0.0', 'generatedAt' => self::NOW - 600, 'hostname' => 'cloud.example.org', + 'bootTime' => self::BOOT, 'listsRefreshedAt' => self::NOW - 600, + 'pendingUpdates' => [['name' => 'openssl-libs', 'installed' => '1', 'candidate' => '2', 'security' => true]], + ])); + + $with = $this->collector()->collect(); + + $this->assertSame(OsStatus::SOURCE_HELPER, $with->updatesSource); + $this->assertSame([], array_values(array_filter($with->diagnostics, static fn (string $d): bool => str_contains($d, 'dnf 5')))); + $this->assertSame([], array_values(array_filter($this->host->ran, static fn (string $c): bool => str_starts_with($c, 'dnf'))), 'dnf 5 never runs'); + } + + public function testUnclassifiedDnfUpdatesAreKeptWithADiagnostic(): void { + $this->host->withFile('/etc/os-release', "NAME=\"AlmaLinux\"\nID=\"almalinux\"\nID_LIKE=\"rhel centos fedora\"\nVERSION_ID=\"9.8\"\nPRETTY_NAME=\"AlmaLinux 9.8\"\n"); + $this->host->binaries = ['dnf', 'dnf-3']; + $this->host->globs['/var/cache/dnf/*-updateinfo.solvx'] = ['/var/cache/dnf/baseos-updateinfo.solvx']; + $this->host->modified['/var/cache/dnf/last_makecache'] = self::NOW - 600; + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], FakeHost::fixture('alma-9-dnf-check-update-pending'), 100) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], '', 1, "Error: something broke\n"); + + $status = $this->collector()->collect(); + + $this->assertCount(9, $status->updates ?? []); + $this->assertFalse($status->securityClassified); + $this->assertContains('dnf could not list security advisories, so pending updates are not told apart into security fixes and others: Error: something broke', $status->diagnostics); + } + public function testFailuresBecomeDiagnostics(): void { $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], '', 100, "E: The package lists are damaged\n"); diff --git a/tests/lib/OsStatus/OsVerdictTest.php b/tests/lib/OsStatus/OsVerdictTest.php index 953d1dbc..9d951e17 100644 --- a/tests/lib/OsStatus/OsVerdictTest.php +++ b/tests/lib/OsStatus/OsVerdictTest.php @@ -104,10 +104,12 @@ public function testUpdatesFromOldPackageListsAreMarked(): void { $fresh = $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 6 * self::DAY])); $stale = $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 7 * self::DAY])); $pacman = $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => self::NOW - 90 * self::DAY])); + $pacmanSynced = $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => self::NOW])); $this->assertFalse($fresh->get(Concern::UPDATES)?->facts['listsStale']); $this->assertTrue($stale->get(Concern::UPDATES)?->facts['listsStale']); - $this->assertFalse($pacman->get(Concern::UPDATES)?->facts['listsStale']); + $this->assertTrue($pacman->get(Concern::UPDATES)?->facts['listsStale'], 'pacman -Qu answers from root\'s databases'); + $this->assertFalse($pacmanSynced->get(Concern::UPDATES)?->facts['listsStale']); } public function testUnknownUpdatesLeaveNoAllClear(): void { @@ -174,7 +176,8 @@ public function testOldPackageLists(): void { $this->assertSame(Concern::LEVEL_OK, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 6 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); $this->assertSame(Concern::LEVEL_WARNING, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 7 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); $this->assertSame(Concern::LEVEL_ERROR, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 31 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); - $this->assertSame(Concern::LEVEL_OK, $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => null]))->get(Concern::PACKAGE_LISTS)?->level); + $this->assertSame(Concern::LEVEL_UNKNOWN, $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => null]))->get(Concern::PACKAGE_LISTS)?->level); + $this->assertSame(Concern::LEVEL_ERROR, $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => self::NOW - 31 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); } public function testHealth(): void { diff --git a/tests/lib/OsStatus/PackageManagerFactoryTest.php b/tests/lib/OsStatus/PackageManagerFactoryTest.php new file mode 100644 index 00000000..5cf1b49c --- /dev/null +++ b/tests/lib/OsStatus/PackageManagerFactoryTest.php @@ -0,0 +1,137 @@ +host = new FakeHost(); + $this->host->globs['/var/cache/dnf/*-updateinfo.solvx'] = ['/var/cache/dnf/baseos-updateinfo.solvx']; + } + + /** + * @param list $binaries + */ + private function managerFor(string $osRelease, array $binaries): ?IPackageManager { + $this->host->binaries = $binaries; + $release = OsRelease::parse($osRelease); + $this->assertNotNull($release); + $host = $this->host; + $systemd = new SystemdProbe($host); + $rpm = new Rpm($host); + $factory = new PackageManagerFactory( + new Apt($host, $systemd), + new Dnf($host, $rpm, $systemd), + new Zypper($host, $rpm, $systemd), + new Pacman($host, $this->createMock(ITimeFactory::class), $this->createMock(IAppConfig::class)), + new Apk($host), + ); + + return $factory->forRelease($release); + } + + /** + * @return array, ?string, ?bool}> + */ + public static function systems(): array { + return [ + 'Debian 12' => ["ID=debian\nVERSION_ID=\"12\"\n", ['apt-get'], 'apt', true], + 'RHEL 9 with dnf 4' => ["ID=\"rhel\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3', 'yum'], 'dnf', true], + 'Rocky Linux 9' => ["ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3'], 'dnf', true], + 'Oracle Linux 9' => ["ID=\"ol\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3'], 'dnf', true], + 'Amazon Linux 2023' => ["ID=\"amzn\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"2023\"\n", ['dnf', 'dnf-3'], 'dnf', true], + 'Amazon Linux 2 has only yum' => ["ID=\"amzn\"\nID_LIKE=\"centos rhel fedora\"\nVERSION_ID=\"2\"\n", ['yum'], null, null], + 'CentOS Stream 10' => ["ID=\"centos\"\nID_LIKE=\"rhel fedora\"\nVERSION_ID=\"10\"\n", ['dnf', 'dnf-3'], 'dnf', false], + 'Fedora with dnf 5' => ["ID=fedora\nVERSION_ID=44\n", ['dnf', 'dnf5'], 'dnf', true], + 'openSUSE Leap 16' => ["ID=\"opensuse-leap\"\nID_LIKE=\"suse opensuse\"\nVERSION_ID=\"16.0\"\n", ['zypper'], 'zypper', true], + 'openSUSE Tumbleweed' => ["ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n", ['zypper'], 'zypper', false], + 'SLES 15' => ["ID=\"sles\"\nID_LIKE=\"suse\"\nVERSION_ID=\"15.6\"\n", ['zypper'], 'zypper', true], + 'Arch Linux' => ["ID=arch\n", ['pacman'], 'pacman', false], + 'Manjaro' => ["ID=manjaro\nID_LIKE=arch\n", ['pacman'], 'pacman', false], + 'Alpine' => ["ID=alpine\nVERSION_ID=3.24.2\n", ['apk'], 'apk', false], + 'postmarketOS' => ["ID=\"postmarketos\"\nID_LIKE=\"alpine\"\nVERSION_ID=\"v25.06\"\n", ['apk'], 'apk', false], + 'Gentoo' => ["ID=gentoo\n", ['emerge'], null, null], + 'Debian without apt-get' => ["ID=debian\n", [], null, null], + ]; + } + + /** + * @param list $binaries + */ + #[\PHPUnit\Framework\Attributes\DataProvider('systems')] + public function testDispatch(string $osRelease, array $binaries, ?string $expected, ?bool $classifies): void { + $manager = $this->managerFor($osRelease, $binaries); + + $this->assertSame($expected, $manager?->getId()); + $this->assertSame($classifies, $manager?->classifiesSecurity()); + } + + /** + * @return array + */ + public static function hiddenByOpenBasedir(): array { + return [ + 'openSUSE Leap 16' => ["ID=\"opensuse-leap\"\nID_LIKE=\"suse opensuse\"\nVERSION_ID=\"16.0\"\n", 'zypper', 'zypper'], + 'Arch Linux' => ["ID=arch\n", 'pacman', 'pacman'], + 'Alpine' => ["ID=alpine\nVERSION_ID=3.24.2\n", 'apk', 'apk'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('hiddenByOpenBasedir')] + public function testTriesAPackageManagerOpenBasedirHides(string $osRelease, string $binary, string $expected): void { + $this->host->hiddenBinaries = [$binary]; + + $this->assertSame($expected, $this->managerFor($osRelease, [])?->getId()); + } + + public function testDnfNamesATimeout(): void { + $manager = $this->managerFor("ID=\"rhel\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3']); + $this->host->commands['dnf-3 -q --cacheonly check-update'] = new CommandResult(-1, "Last metadata expiration check\n", '', true); + + $this->expectExceptionMessage('dnf check-update failed: timed out after 180 s'); + $manager?->getPendingUpdates(); + } + + public function testRhel9RunsDnf4AsDnf3(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 0); + $manager = $this->managerFor("ID=\"rhel\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3']); + + $this->assertSame([], $manager?->getPendingUpdates()); + $this->assertSame(['dnf-3 -q --cacheonly check-update'], $this->host->ran); + } + + public function testFedoraWithDnf5RunsNothing(): void { + $manager = $this->managerFor("ID=fedora\nVERSION_ID=44\n", ['dnf', 'dnf5']); + + try { + $manager?->getPendingUpdates(); + $this->fail('dnf 5 must not be asked'); + } catch (NeedsRootException) { + } + $this->assertSame([], $this->host->ran); + } +} diff --git a/tests/lib/OsStatus/PacmanApkTest.php b/tests/lib/OsStatus/PacmanApkTest.php new file mode 100644 index 00000000..97bd1068 --- /dev/null +++ b/tests/lib/OsStatus/PacmanApkTest.php @@ -0,0 +1,222 @@ +createMock(ITimeFactory::class); + $time->method('getTime')->willReturn(self::NOW); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueBool')->willReturnMap([[ConfigLexicon::OS_ALLOW_NETWORK, true, true, $network]]); + + return new Pacman($host, $time, $appConfig); + } + + public function testCheckupdatesOutput(): void { + $updates = Pacman::parseUpdates("linux 6.11.2.arch1-1 -> 6.11.3.arch1-1\nopenssl 3.3.2-1 -> 3.4.0-1\nnginx 1.26.2-1 -> 1.26.2-2 [ignored]\nnot an update\n"); + + $this->assertSame(['linux', 'openssl'], array_map(static fn ($u) => $u->name, $updates), 'An ignored package is held, not pending'); + $this->assertSame('6.11.2.arch1-1', $updates[0]->installedVersion); + $this->assertSame('6.11.3.arch1-1', $updates[0]->candidateVersion); + } + + public function testArchAuditMarksSecurityUpdates(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates', 'arch-audit']; + $host->withCommand('checkupdates', [], "openssl 3.3.2-1 -> 3.4.0-1\nnano 8.1-1 -> 8.2-1\n") + ->withCommand('arch-audit', ['--upgradable', '--quiet'], "openssl\n"); + + $pacman = $this->pacman($host); + $updates = $pacman->getPendingUpdates(); + + $this->assertTrue($pacman->classifiesSecurity()); + $this->assertTrue($updates[0]->security); + $this->assertFalse($updates[1]->security); + } + + public function testCheckupdatesWithNothingToDo(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates']; + $host->withCommand('checkupdates', [], '', 2); + + $pacman = $this->pacman($host); + + $this->assertFalse($pacman->classifiesSecurity()); + $this->assertSame([], $pacman->getPendingUpdates()); + } + + public function testCheckupdatesSyncIsTheListsAge(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates']; + $host->withCommand('checkupdates', [], '', 2); + $host->modified['/var/lib/pacman/sync'] = self::NOW - 90 * 86400; + + $pacman = $this->pacman($host); + $pacman->getPendingUpdates(); + + $this->assertSame(self::NOW, $pacman->getListsRefreshedAt()); + $this->assertSame([], $pacman->getDiagnostics()); + } + + public function testFallbackReportsTheAgeOfRootsDatabases(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates']; + $host->withCommand('checkupdates', [], '', 1, "==> ERROR: Cannot fetch updates\n") + ->withCommand('pacman', ['-Qu'], "openssl 3.3.2-1 -> 3.4.0-1\n"); + $host->globs['/var/lib/pacman/sync/*.db'] = ['/var/lib/pacman/sync/core.db', '/var/lib/pacman/sync/extra.db']; + $host->modified['/var/lib/pacman/sync/core.db'] = self::NOW - 40 * 86400; + $host->modified['/var/lib/pacman/sync/extra.db'] = self::NOW - 35 * 86400; + $host->modified['/var/lib/pacman/sync'] = self::NOW - 34 * 86400; + + $pacman = $this->pacman($host); + + $this->assertCount(1, $pacman->getPendingUpdates()); + $this->assertSame(self::NOW - 34 * 86400, $pacman->getListsRefreshedAt()); + $this->assertSame(['checkupdates could not sync the package databases, so pending updates come from the databases root last synced: ==> ERROR: Cannot fetch updates'], $pacman->getDiagnostics()); + } + + public function testWithoutNetworkOnlyLocalDatabasesAreRead(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates', 'arch-audit']; + $host->withCommand('pacman', ['-Qu'], "openssl 3.3.2-1 -> 3.4.0-1\n"); + $host->modified['/var/lib/pacman/sync'] = self::NOW - 3600; + + $pacman = $this->pacman($host, false); + $updates = $pacman->getPendingUpdates(); + + $this->assertFalse($updates[0]->security); + $this->assertFalse($pacman->classifiesSecurity()); + $this->assertSame(self::NOW - 3600, $pacman->getListsRefreshedAt()); + $this->assertSame(['pacman -Qu'], $host->ran, 'Neither checkupdates nor arch-audit runs'); + $this->assertSame([], $pacman->getDiagnostics()); + } + + public function testPacmanQuWithNothingToDo(): void { + $host = new FakeHost(); + $host->binaries = ['pacman']; + $host->withCommand('pacman', ['-Qu'], '', 1); + + $this->assertSame([], $this->pacman($host)->getPendingUpdates()); + } + + /** + * @return array + */ + public static function failedQueries(): array { + return [ + 'error on stderr only' => [1, '', "error: failed to initialize alpm library:\n(root: /, dbpath: /var/lib/pacman/)\ncould not find or read directory\n"], + 'database lock' => [1, '', "error: could not lock database: File exists\n"], + 'other exit code' => [2, '', ''], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('failedQueries')] + public function testPacmanQuErrorsAreNotUpToDate(int $exitCode, string $stdout, string $stderr): void { + $host = new FakeHost(); + $host->binaries = ['pacman']; + $host->withCommand('pacman', ['-Qu'], $stdout, $exitCode, $stderr); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage('pacman -Qu failed'); + $this->pacman($host)->getPendingUpdates(); + } + + public function testArchAuditFailureKeepsTheUpdatesUnclassified(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates', 'arch-audit']; + $host->withCommand('checkupdates', [], "openssl 3.3.2-1 -> 3.4.0-1\nnano 8.1-1 -> 8.2-1\n") + ->withCommand('arch-audit', ['--upgradable', '--quiet'], '', 1, "Error: Failed to fetch https://security.archlinux.org/issues/all.json\n"); + + $pacman = $this->pacman($host); + $updates = $pacman->getPendingUpdates(); + + $this->assertSame(['openssl', 'nano'], array_map(static fn ($u) => $u->name, $updates)); + $this->assertFalse($updates[0]->security); + $this->assertFalse($pacman->classifiesSecurity()); + $this->assertSame(['arch-audit could not be run, so pending updates are not told apart into security fixes and others: Error: Failed to fetch https://security.archlinux.org/issues/all.json'], $pacman->getDiagnostics()); + } + + public function testIgnoredPackages(): void { + $held = Pacman::parseIgnored("[options]\nIgnorePkg = linux nginx # pinned\n#IgnorePkg = commented\nIgnorePkg=php\n"); + + $this->assertSame(['linux', 'nginx', 'php'], array_map(static fn ($h) => $h->name, $held)); + } + + public function testPacmanLog(): void { + $host = new FakeHost(); + $host->withFile('/var/log/pacman.log', "[2026-10-12T06:41:02+0200] [ALPM] upgraded openssl (3.3.2-1 -> 3.4.0-1)\n[2026-10-12T07:00:00+0200] [ALPM] removed foo (1-1)\n"); + + $this->assertSame((int)strtotime('2026-10-12 04:41:02 UTC'), ($this->pacman($host))->getLastUpgradeAt()); + } + + public function testApkUpgradable(): void { + $updates = Apk::parseUpgradable(implode("\n", [ + 'busybox-1.37.0-r13 x86_64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.37.0-r12]', + 'py3-cryptography-44.0.0-r0 x86_64 {py3-cryptography} (Apache-2.0 OR BSD-3-Clause) [upgradable from: py3-cryptography-43.0.3-r0]', + 'nonsense', + ])); + + $this->assertSame('busybox', $updates[0]->name); + $this->assertSame('1.37.0-r12', $updates[0]->installedVersion); + $this->assertSame('py3-cryptography', $updates[1]->name); + $this->assertSame('44.0.0-r0', $updates[1]->candidateVersion); + } + + public function testApkWorldPins(): void { + $host = new FakeHost(); + $host->withFile('/etc/apk/world', "alpine-base\nnginx=1.26.2-r0\nphp83<8.4\ncurl@edge\nzlib~1.3\nmusl>=1.2\nopenssl@edge<=3.5.8-r0\n!busybox-extras\n-x=1\n"); + + $held = (new Apk($host))->getHeldPackages(); + + $this->assertSame( + ['nginx' => '=1.26.2-r0', 'php83' => '<8.4', 'zlib' => '~1.3', 'openssl' => '@edge<=3.5.8-r0'], + array_combine(array_map(static fn ($h) => $h->name, $held ?? []), array_map(static fn ($h) => $h->constraint, $held ?? [])), + 'A repository tag alone or a lower bound holds nothing back', + ); + } + + public function testApkWorldConstraintsFilterTheUpgradable(): void { + $host = new FakeHost(); + $host->binaries[] = 'apk'; + $host->withFile('/etc/apk/world', "php83<8.3.20\nzlib~1.3\ncurl@edge\nnginx<1.28\nmusl>=1.2\n") + ->withCommand('apk', ['--no-network', 'list', '--upgradable'], implode("\n", [ + 'php83-8.3.21-r0 aarch64 {php83} (PHP-3.01 AND BSD-3-Clause) [upgradable from: php83-8.3.19-r0]', + 'zlib-1.4.0-r0 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.2-r1]', + 'curl-8.18.0-r0 aarch64 {curl} (curl) [upgradable from: curl-8.17.0-r0]', + 'nginx-1.26.3-r0 aarch64 {nginx} (BSD-2-Clause) [upgradable from: nginx-1.26.2-r0]', + 'musl-1.2.6-r0 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r9]', + ])); + + $updates = (new Apk($host))->getPendingUpdates(); + + $this->assertSame(['curl', 'nginx', 'musl'], array_map(static fn ($u) => $u->name, $updates)); + } + + public function testApkVersions(): void { + $this->assertSame(-1, Apk::compareVersions('3.5.8-r0', '3.5.8-r1')); + $this->assertSame(1, Apk::compareVersions('3.5.10-r0', '3.5.9-r3')); + $this->assertSame(-1, Apk::compareVersions('1.2_rc1-r0', '1.2-r0')); + $this->assertSame(-1, Apk::compareVersions('1.2_pre1', '1.2')); + $this->assertSame(1, Apk::compareVersions('1.2_p1-r0', '1.2-r5')); + $this->assertTrue(Apk::satisfies('1.3.2-r1', '~', '1.3')); + $this->assertFalse(Apk::satisfies('1.30-r0', '~', '1.3')); + $this->assertTrue(Apk::satisfies('8.3.19-r0', '<', '8.4')); + $this->assertFalse(Apk::satisfies('1.3.2-r2', '=', '1.3.2-r1')); + } +} diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php index 85b6a681..9284447c 100644 --- a/tests/lib/OsStatus/RealOutputTest.php +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -9,16 +9,36 @@ namespace OCA\ServerInfo\Tests\OsStatus; +use OCA\ServerInfo\Config\ConfigLexicon; +use OCA\ServerInfo\OsStatus\Health\SystemdProbe; use OCA\ServerInfo\OsStatus\OsRelease; +use OCA\ServerInfo\OsStatus\Packages\Apk; use OCA\ServerInfo\OsStatus\Packages\Apt; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; +use OCA\ServerInfo\OsStatus\Packages\Dnf; +use OCA\ServerInfo\OsStatus\Packages\Pacman; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; +use OCA\ServerInfo\OsStatus\Packages\Rpm; +use OCA\ServerInfo\OsStatus\Packages\Zypper; +use OCP\AppFramework\Services\IAppConfig; +use OCP\AppFramework\Utility\ITimeFactory; /** - * Output captured from real systems (arm64 containers, October 2026), run as - * an unprivileged user unless named otherwise. + * Output captured from real systems (arm64 containers of each distribution, + * October 2026), run as an unprivileged user unless named otherwise. */ class RealOutputTest extends \Test\TestCase { + private const NOW = 1792411200; + + private function pacman(FakeHost $host, bool $network = true): Pacman { + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturn(self::NOW); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueBool')->willReturnMap([[ConfigLexicon::OS_ALLOW_NETWORK, true, true, $network]]); + + return new Pacman($host, $time, $appConfig); + } + /** * @param list $updates * @return array name => security @@ -50,4 +70,147 @@ public function testUbuntu2404(): void { 'tzdata' => false, 'curl' => true, 'libcurl4t64' => true, ], self::names($updates)); } + + public function testAlmaLinux9WithDnf4(): void { + $host = new FakeHost(); + $host->binaries[] = 'dnf-3'; + $host->globs['/var/cache/dnf/*-updateinfo.solvx'] = ['/var/cache/dnf/appstream-updateinfo.solvx', '/var/cache/dnf/baseos-updateinfo.solvx', '/var/cache/dnf/crb-updateinfo.solvx']; + $host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], FakeHost::fixture('alma-9-dnf-check-update-pending'), 100) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], FakeHost::fixture('alma-9-dnf-updateinfo-security')) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'versionlock', 'list'], FakeHost::fixture('alma-9-dnf-versionlock')) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], FakeHost::fixture('alma-9-dnf-needs-restarting-r'), 1) + ->withFile('/etc/dnf/automatic.conf', FakeHost::fixture('alma-9-dnf-automatic.conf')); + $dnf = new Dnf($host, new Rpm($host), new SystemdProbe($host)); + + $updates = $dnf->getPendingUpdates(); + + $this->assertSame([ + 'curl-minimal' => true, 'glibc' => true, 'glibc-common' => true, 'glibc-minimal-langpack' => true, + 'libcurl-minimal' => true, 'openssl' => true, 'openssl-fips-provider' => false, 'openssl-libs' => true, + 'tzdata' => false, + ], self::names($updates), 'sudo has an advisory but is versionlocked, so it is not pending'); + $this->assertSame(['ALSA-2026:77396'], $updates[5]->advisories); + $this->assertSame('Important', $updates[5]->severity); + $this->assertSame('1:3.5.8-2.el9_8', $updates[5]->candidateVersion); + + $this->assertSame(['sudo'], array_map(static fn ($h) => $h->name, $dnf->getHeldPackages() ?? [])); + $this->assertSame(['glibc'], $dnf->getRebootReasons()[0]->packages); + } + + public function testFedora44WithDnf5(): void { + $updates = Dnf::parseCheckUpdate(FakeHost::fixture('fedora-44-dnf-check-update-pending')); + $advisories = Dnf::parseAdvisories(FakeHost::fixture('fedora-44-dnf-advisory-security')); + + $this->assertSame(['curl', 'glibc', 'glibc-common', 'glibc-minimal-langpack', 'libcurl', 'openssl-libs', 'tzdata'], array_keys($updates)); + $this->assertSame(['1:3.5.9-1.fc44', 'updates'], $updates['openssl-libs']); + $this->assertSame(['FEDORA-2026-12f3f3d569'], $advisories['openssl-libs']['ids']); + $this->assertSame('Moderate', $advisories['curl']['severity']); + $this->assertArrayHasKey('sudo', $advisories, 'Advisories cover locked packages, which check-update leaves out'); + + $held = Dnf::parseVersionlock(FakeHost::fixture('fedora-44-dnf-versionlock')); + $this->assertSame(['sudo'], array_map(static fn ($h) => $h->name, $held)); + } + + public function testOpenSuseLeap16(): void { + $patches = Zypper::parsePatches(FakeHost::fixture('leap-16-zypper-patches-security.xml')); + $packages = Zypper::parsePackageUpdates(FakeHost::fixture('leap-16-zypper-updates.xml')); + + $this->assertCount(6, $patches); + $this->assertSame('openSUSE-Leap-16.0-1157', $patches[0]->name); + $this->assertSame('Security update for glibc', $patches[0]->summary); + $this->assertSame(['openSUSE-Leap-16.0-1157', 'CVE-2026-5435', 'CVE-2026-6238'], $patches[0]->advisories); + $this->assertSame('Moderate', $patches[0]->severity); + $this->assertSame(['Moderate', 'Important', 'Important', 'Moderate', 'Important', 'Important'], array_map(static fn ($p) => $p->severity, $patches)); + + $this->assertSame(['glibc', 'glibc-locale-base', 'libcurl4', 'libopenssl-3-fips-provider', 'libopenssl3', 'openssl-3', 'timezone'], array_keys(self::names($packages))); + $this->assertSame('2.40-160000.5.1', $packages[0]->installedVersion); + $this->assertSame(['curl'], array_map(static fn ($h) => $h->name, Zypper::parseLocks(FakeHost::fixture('leap-16-zypper-locks.xml')))); + } + + public function testOpenSuseLeap16PatchesBecomePackageSecurityUpdates(): void { + $coverage = Zypper::parsePatchConflicts(FakeHost::fixture('leap-16-zypper-info-patch')); + + $this->assertSame(['openSUSE-Leap-16.0-1157', 'openSUSE-Leap-16.0-1187'], array_keys($coverage)); + $this->assertContains('glibc-locale-base', $coverage['openSUSE-Leap-16.0-1157']); + $this->assertContains('cross-ppc64le-glibc-devel', $coverage['openSUSE-Leap-16.0-1157']); + $this->assertContains('libcurl4', $coverage['openSUSE-Leap-16.0-1187']); + $this->assertNotContains('glibc.aarch64', $coverage['openSUSE-Leap-16.0-1157'], 'Architectures are stripped'); + + $merged = Zypper::mergePatches( + Zypper::parsePatches(FakeHost::fixture('leap-16-zypper-patches-security.xml')), + Zypper::parsePackageUpdates(FakeHost::fixture('leap-16-zypper-updates.xml')), + $coverage, + ); + $byName = []; + foreach ($merged as $update) { + $byName[$update->name] = $update; + } + + $this->assertTrue($byName['glibc']->security); + $this->assertSame(['openSUSE-Leap-16.0-1157', 'CVE-2026-5435', 'CVE-2026-6238'], $byName['glibc']->advisories); + $this->assertSame('Moderate', $byName['glibc']->severity); + $this->assertTrue($byName['libcurl4']->security, 'curl itself is locked, its library is not'); + $this->assertSame('Important', $byName['libcurl4']->severity); + $this->assertFalse($byName['timezone']->security); + $this->assertArrayNotHasKey('openSUSE-Leap-16.0-1157', $byName, 'A patch whose packages are listed is not repeated'); + $this->assertSame(PendingUpdate::KIND_PATCH, $byName['openSUSE-Leap-16.0-1592']->kind, 'Patches without looked-up packages stay visible'); + } + + public function testZypperFallsBackToPatchRowsWhenInfoFails(): void { + $host = new FakeHost(); + $host->binaries[] = 'zypper'; + $host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], FakeHost::fixture('leap-16-zypper-patches-security.xml')) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], FakeHost::fixture('leap-16-zypper-updates.xml')); + + $updates = (new Zypper($host, new Rpm($host), new SystemdProbe($host)))->getPendingUpdates(); + + $this->assertCount(13, $updates); + $this->assertCount(6, array_filter($updates, static fn (PendingUpdate $u): bool => $u->kind === PendingUpdate::KIND_PATCH)); + } + + public function testOpenSuseTumbleweed(): void { + $host = new FakeHost(); + $host->binaries[] = 'zypper'; + $host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], FakeHost::fixture('tumbleweed-zypper-updates.xml')); + $release = OsRelease::parse(FakeHost::fixture('tumbleweed-os-release')); + $this->assertNotNull($release); + $zypper = (new Zypper($host, new Rpm($host), new SystemdProbe($host)))->withRelease($release); + + $this->assertFalse($zypper->classifiesSecurity()); + $this->assertSame(['openSUSE-build-key', 'update-test-reboot-needed', 'update-test-security'], array_keys(self::names($zypper->getPendingUpdates()))); + } + + public function testArchLinux(): void { + $this->assertSame(['python-urllib3' => false], self::names(Pacman::parseUpdates(FakeHost::fixture('arch-checkupdates-pending')))); + $this->assertSame([], Pacman::parseUpdates(FakeHost::fixture('arch-pacman-qu-ignored')), 'Ignored packages are held, not pending'); + $this->assertSame(['python-requests'], array_map(static fn ($h) => $h->name, Pacman::parseIgnored(FakeHost::fixture('arch-pacman-conf-ignorepkg')))); + + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates', 'arch-audit']; + $host->withCommand('checkupdates', [], FakeHost::fixture('arch-checkupdates-pending')) + ->withCommand('arch-audit', ['--upgradable', '--quiet'], FakeHost::fixture('arch-audit-u-pending')); + + $this->assertSame(['python-urllib3' => true], self::names($this->pacman($host)->getPendingUpdates())); + } + + public function testArchFallsBackWhenCheckupdatesCannotRun(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates']; + $host->withCommand('checkupdates', [], '', 1, "==> ERROR: Cannot find the fakeroot binary.\n") + ->withCommand('pacman', ['-Qu'], "python-urllib3 1.26.3-1 -> 2.8.0-1\n"); + + $this->assertSame(['python-urllib3' => false], self::names($this->pacman($host)->getPendingUpdates())); + } + + public function testAlpine(): void { + $host = new FakeHost(); + $host->binaries[] = 'apk'; + $host->withCommand('apk', ['--no-network', 'list', '--upgradable'], FakeHost::fixture('alpine-apk-list-upgradable')) + ->withFile('/etc/apk/world', FakeHost::fixture('alpine-apk-world-pinned')); + + $updates = (new Apk($host))->getPendingUpdates(); + + $this->assertSame(['libcrypto3' => false, 'libssl3' => false], self::names($updates), 'zlib is pinned to its installed version'); + $this->assertSame('3.5.8-r0', $updates[0]->installedVersion); + } } diff --git a/tests/lib/OsStatus/ZypperTest.php b/tests/lib/OsStatus/ZypperTest.php new file mode 100644 index 00000000..05108270 --- /dev/null +++ b/tests/lib/OsStatus/ZypperTest.php @@ -0,0 +1,203 @@ + + + Loading repository data... + + + Security update for openssl-3 + old + bugfix + + + + XML; + + private const UPDATES = <<<'XML' + + + + + Secure Sockets + + + + XML; + + private FakeHost $host; + private Zypper $zypper; + + protected function setUp(): void { + parent::setUp(); + $this->host = new FakeHost(); + $this->host->binaries[] = 'zypper'; + $this->zypper = new Zypper($this->host, new Rpm($this->host), new SystemdProbe($this->host)); + } + + public function testOnlyNeededSecurityPatchesCount(): void { + $patches = Zypper::parsePatches(self::PATCHES); + + $this->assertCount(1, $patches); + $this->assertSame('openSUSE-SLE-15.6-2026-3456', $patches[0]->name); + $this->assertSame(PendingUpdate::KIND_PATCH, $patches[0]->kind); + $this->assertSame('Important', $patches[0]->severity); + $this->assertTrue($patches[0]->security); + } + + public function testPackageUpdates(): void { + $updates = Zypper::parsePackageUpdates(self::UPDATES); + + $this->assertSame('libopenssl3', $updates[0]->name); + $this->assertSame('3.1.4-150600.5.24.1', $updates[0]->installedVersion); + $this->assertSame('repo-sle-update', $updates[0]->origin); + } + + public function testEntitiesAreNotExpanded(): void { + $xml = ']>' + . ''; + + foreach (Zypper::parsePackageUpdates($xml) as $update) { + $this->assertStringNotContainsString('root:', $update->name); + } + $this->addToAssertionCount(1); + } + + public function testCombinesPatchesAndPackages(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], self::PATCHES) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES); + + $this->assertCount(2, $this->zypper->getPendingUpdates()); + $this->assertTrue($this->zypper->classifiesSecurity()); + } + + public function testErrorsInsideTheXmlStream(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], + "Repository 'main' is invalid."); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage("Repository 'main' is invalid."); + $this->zypper->getPendingUpdates(); + } + + public function testTumbleweedHasNoPatches(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES); + $release = OsRelease::parse("NAME=\"openSUSE Tumbleweed\"\nID=\"opensuse-tumbleweed\"\n"); + $this->assertNotNull($release); + $zypper = $this->zypper->withRelease($release); + + $this->assertFalse($zypper->classifiesSecurity()); + $this->assertCount(1, $zypper->getPendingUpdates()); + $this->assertStringContainsString('dup', $zypper->getUpgradeCommands()['all']); + } + + public function testSkippedRepositoriesStillGiveUpdates(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], self::PATCHES, 106) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], + str_replace('Repository 'SLE-Module-Python3' is out-of-date.\nassertCount(2, $this->zypper->getPendingUpdates()); + $this->assertSame(["zypper skipped repositories it could not read, so pending updates may be incomplete: Repository 'SLE-Module-Python3' is out-of-date."], array_values(array_filter( + $this->zypper->getDiagnostics(), + static fn (string $d): bool => str_contains($d, 'Python3'), + ))); + $this->assertContains('zypper skipped repositories it could not read, so pending updates may be incomplete.', $this->zypper->getDiagnostics()); + } + + public function testInformationalExitCodesAreNotFailures(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], self::PATCHES, 101) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES, 100); + + $this->assertCount(2, $this->zypper->getPendingUpdates()); + $this->assertSame([], $this->zypper->getDiagnostics()); + } + + public function testLockedZypperIsBusy(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], '', 7, "System management is locked by the application with pid 4242 (zypper).\n"); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage('zypper is busy'); + $this->zypper->getPendingUpdates(); + } + + /** + * @return array + */ + public static function failedRuns(): array { + return [ + 'zypp problem' => [4, self::PATCHES, false], + 'interrupted' => [105, self::PATCHES, false], + 'no XML' => [106, 'Segmentation fault', false], + 'timed out' => [0, self::PATCHES, true], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('failedRuns')] + public function testFailedRuns(int $exitCode, string $stdout, bool $timedOut): void { + $this->host->commands['zypper --non-interactive --no-refresh --xmlout list-patches --category security'] = new CommandResult($exitCode, $stdout, 'Problem', $timedOut); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage('zypper list-patches failed'); + $this->zypper->getPendingUpdates(); + } + + public function testPatchNamesMustStartWithALetterOrDigit(): void { + $patches = '' + . '' + . '' + . ''; + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], $patches) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES); + + $this->zypper->getPendingUpdates(); + + $this->assertContains('zypper --non-interactive --no-refresh info -t patch openSUSE-Leap-16.0-1157', $this->host->ran); + } + + public function testPatchConflictsWithAnyArchitecture(): void { + $coverage = Zypper::parsePatchConflicts(implode("\n", [ + 'Information for patch SUSE-SLE-Module-Basesystem-15-SP6-2026-1:', + 'Conflicts : [4]', + ' libopenssl3.s390x < 3.1.4-150600.5.27.1', + ' libopenssl3.x86_64_v3 < 3.1.4-150600.5.27.1', + ' go1.22 < 1.22.5-150000.1.3.1', + ' glibc.ppc64 < 2.38-150600.14.1', + ])); + + $this->assertSame(['libopenssl3', 'go1.22', 'glibc'], $coverage['SUSE-SLE-Module-Basesystem-15-SP6-2026-1']); + } + + public function testLocks(): void { + $locks = Zypper::parseLocks("kernel-defaultpackage"); + + $this->assertSame('kernel-default', $locks[0]->name); + } + + public function testNeedsRebooting(): void { + $this->host->withCommand('zypper', ['--non-interactive', 'needs-rebooting'], "Core libraries or services have been updated.\n", 102); + $this->assertCount(1, $this->zypper->getRebootReasons()); + + $this->host->withCommand('zypper', ['--non-interactive', 'needs-rebooting'], "No core libraries or services have been updated since the last system boot.\n", 0); + $this->assertSame([], $this->zypper->getRebootReasons()); + $this->assertFalse($this->zypper->hasOwnRebootCheck(), 'needs-rebooting only knows what /etc/zypp/needreboot lists'); + $this->assertCount(2, $this->host->ran, 'hasOwnRebootCheck() runs nothing'); + } +}