Files
KantSites/zentrale/app/Bestand.php
T
kantarciandClaude Opus 5.5 9d80af3795 Flag installations with vulnerable PHP in the central registry
Mark sites reporting a PHP release without the php2shell fixes
(CVE-2026-109618/-109619; patched in 8.2.35, 8.3.36, 8.4.27, 8.5.12)
or an end-of-life branch (8.1 and older). The overview gets a counter
tile that doubles as a filter, a status pill per row and PHP in the
search; the detail page explains that the hoster has to update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 06:54:12 +02:00

186 lines
7.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace Zentrale;
/**
* Die Installationen und ihre Aufträge - was die Übersicht zeigt und was ihre Knöpfe tun.
*/
final class Bestand
{
/** Ohne Meldung seit so vielen Stunden gilt eine Installation als verstummt. */
public const STILL_STUNDEN = 48;
/** Ein abgeholter Auftrag ohne Ergebnis nach so vielen Stunden: keine Rückmeldung. */
public const AUFTRAG_STUNDEN = 2;
/**
* Erste PHP-Fassung je Zweig ohne bekannte kritische Lücke (zuletzt php2shell,
* CVE-2026-109618/-109619, 21.10.2026). Bei der nächsten PHP-Sicherheitsmeldung hier nachziehen.
*/
public const PHP_SICHER = ['8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.27', '8.5' => '8.5.12'];
/** Bis hierher gibt es keine Sicherheitsupdates mehr. */
public const PHP_ALT_BIS = '8.1';
public function __construct(private \PDO $db)
{
}
/** @return list<array<string, mixed>> */
public function alle(?string $neueste): array
{
$zeilen = $this->db->query('SELECT * FROM installation ORDER BY domain, erste_meldung')->fetchAll();
// Natürlich sortiert und ohne www: kunde-2 vor kunde-10, www.ebsolar.de bei e.
usort($zeilen, static fn (array $a, array $b): int => strnatcasecmp(preg_replace('/^www\./', '', (string) $a['domain']), preg_replace('/^www\./', '', (string) $b['domain'])));
$auftraege = [];
foreach ($this->db->query('SELECT a.* FROM auftrag a JOIN (SELECT installation, MAX(id) AS id FROM auftrag GROUP BY installation) j ON j.id = a.id')->fetchAll() as $auftrag) {
$auftraege[$auftrag['installation']] = $auftrag;
}
return array_map(fn (array $zeile): array => $this->anreichern($zeile, $auftraege[$zeile['id']] ?? null, $neueste), $zeilen);
}
/** @return array<string, mixed>|null */
public function eine(string $id, ?string $neueste): ?array
{
$abfrage = $this->db->prepare('SELECT * FROM installation WHERE id = ?');
$abfrage->execute([$id]);
$zeile = $abfrage->fetch() ?: null;
if (null === $zeile) {
return null;
}
$verlauf = $this->db->prepare('SELECT * FROM auftrag WHERE installation = ? ORDER BY id DESC LIMIT 30');
$verlauf->execute([$id]);
$auftraege = $verlauf->fetchAll();
$eine = $this->anreichern($zeile, $auftraege[0] ?? null, $neueste);
$eine['verlauf'] = array_map(fn (array $a): array => [...$a, 'zustand' => $this->auftragZustand($a)], $auftraege);
return $eine;
}
/**
* Ein Update in Auftrag geben. Gibt null zurück oder den Grund, warum nicht.
*/
public function beauftragen(string $id, string $fassung): ?string
{
$eine = $this->eine($id, $fassung);
if (null === $eine) {
return 'Diese Installation gibt es nicht.';
}
if (!$eine['kann_update']) {
return $eine['kein_update_grund'];
}
$offen = $eine['auftrag'];
if (null !== $offen && null === $offen['abgeholt'] && null === $offen['erledigt']) {
$this->db->prepare('UPDATE auftrag SET fassung = ?, erstellt = ? WHERE id = ?')->execute([$fassung, Datenbank::jetzt(), $offen['id']]);
return null;
}
$this->db->prepare('INSERT INTO auftrag (installation, aktion, fassung, erstellt) VALUES (?, ?, ?, ?)')
->execute([$id, 'update', $fassung, Datenbank::jetzt()]);
return null;
}
/** Einen noch nicht abgeholten Auftrag zurückziehen. */
public function zurueckziehen(string $id): bool
{
$abfrage = $this->db->prepare('DELETE FROM auftrag WHERE installation = ? AND abgeholt IS NULL AND erledigt IS NULL');
$abfrage->execute([$id]);
return $abfrage->rowCount() > 0;
}
public function notiz(string $id, string $notiz): void
{
$this->db->prepare('UPDATE installation SET notiz = ? WHERE id = ?')->execute([mb_substr(trim($notiz), 0, 200), $id]);
}
public function entfernen(string $id): void
{
$this->db->prepare('DELETE FROM installation WHERE id = ?')->execute([$id]);
}
/**
* @param array<string, mixed> $zeile
* @param array<string, mixed>|null $auftrag
*
* @return array<string, mixed>
*/
private function anreichern(array $zeile, ?array $auftrag, ?string $neueste): array
{
$alter = time() - (int) strtotime((string) $zeile['letzte_meldung']);
$veraltet = null !== $neueste && 'unbekannt' !== $zeile['fassung'] && version_compare((string) $zeile['fassung'], $neueste, '<');
$zustand = null === $auftrag ? null : $this->auftragZustand($auftrag);
// Ein wartender Auftrag lässt sich noch ändern; ein abgeholter läuft.
$laeuft = 'laeuft' === ($zustand['art'] ?? null);
$grund = match (true) {
null === $neueste => 'Die neueste Fassung ist gerade nicht bekannt.',
!$veraltet => 'Diese Installation ist auf dem neuesten Stand.',
'git' === $zeile['art'] => 'Eine Git-Arbeitskopie wird über Git aktualisiert, nicht aus der Ferne.',
$laeuft => 'Ein Update läuft gerade.',
default => '',
};
return [
...$zeile,
'alter' => $alter,
'still' => $alter > self::STILL_STUNDEN * 3600,
'veraltet' => $veraltet,
'unbekannt' => '' === trim((string) $zeile['notiz']),
'auftrag' => $auftrag,
'auftrag_zustand' => $zustand,
'kann_update' => '' === $grund,
'kein_update_grund' => $grund,
'php_luecke' => self::phpLuecke((string) $zeile['php']),
];
}
/**
* Warum die gemeldete PHP-Fassung als unsicher gilt, oder null. Unbekannte und neuere
* Zweige (etwa 8.6-Vorabfassungen) gelten nicht als unsicher.
*/
public static function phpLuecke(string $php): ?string
{
if (1 !== preg_match('/^(\d+)\.(\d+)\.(\d+)/', $php, $teile)) {
return null;
}
$zweig = $teile[1].'.'.$teile[2];
$fassung = $zweig.'.'.$teile[3];
if (version_compare($zweig, self::PHP_ALT_BIS, '<=')) {
return sprintf('PHP %s bekommt keine Sicherheitsupdates mehr – auf PHP %s umstellen.', $zweig, self::PHP_SICHER['8.4']);
}
$sicher = self::PHP_SICHER[$zweig] ?? null;
if (null !== $sicher && version_compare($fassung, $sicher, '<')) {
return sprintf('PHP %s hat eine bekannte kritische Sicherheitslücke – mindestens %s nötig.', $fassung, $sicher);
}
return null;
}
/**
* @param array<string, mixed> $auftrag
*
* @return array{art: string, text: string}
*/
private function auftragZustand(array $auftrag): array
{
if (null !== $auftrag['erledigt']) {
return (int) $auftrag['ok'] === 1
? ['art' => 'ok', 'text' => sprintf('Update auf %s erledigt', $auftrag['fassung'])]
: ['art' => 'fehler', 'text' => sprintf('Update auf %s gescheitert', $auftrag['fassung'])];
}
if (null === $auftrag['abgeholt']) {
return ['art' => 'wartet', 'text' => sprintf('Update auf %s wartet auf die nächste Meldung', $auftrag['fassung'])];
}
if ((int) strtotime((string) $auftrag['abgeholt']) < time() - self::AUFTRAG_STUNDEN * 3600) {
return ['art' => 'fehler', 'text' => sprintf('Update auf %s: keine Rückmeldung', $auftrag['fassung'])];
}
return ['art' => 'laeuft', 'text' => sprintf('Update auf %s läuft', $auftrag['fassung'])];
}
}