Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Core\LegacySecretCleanup;
use OpenNIT\Security\CredentialCrypto;
/**
* Deckt die Bereinigung von Klartext-Zugangsdaten beim Update ab.
*
* `.env` und `config/` sind vom Update geschützt und behalten ihren Stand sonst
* dauerhaft – ein einmal geschriebener Klartextwert bliebe für immer stehen.
*/
return static function (): void {
$wurzel = dirname(__DIR__);
/** Legt ein Wegwerf-Verzeichnis an. */
$tmp = static function (): string {
$dir = sys_get_temp_dir() . '/opennit-secrets-' . bin2hex(random_bytes(6));
mkdir($dir, 0700, true);
return $dir;
};
/** Räumt es wieder ab. */
$weg = static function (string $dir): void {
foreach ((array)glob($dir . '/*') as $datei) {
@unlink((string)$datei);
}
@rmdir($dir);
};
// ----------------------------------------------------------------
// .env
// ----------------------------------------------------------------
test('Die Anmeldedaten des Administrators verlassen die .env', function () use ($tmp, $weg) {
$dir = $tmp();
$env = $dir . '/.env';
file_put_contents($env, <<<ENV
APP_NAME="OpenNIT"
APP_KEY=0123456789abcdef
# Lokaler Admin (nur wenn Azure AD nicht konfiguriert)
LOCAL_ADMIN_EMAIL="admin@example.org"
LOCAL_ADMIN_PASSWORD="Geheim!123"
STORAGE_PATH=/var/daten
ENV);
$entfernt = LegacySecretCleanup::stripInstallerCredentials($env);
$neu = (string)file_get_contents($env);
assertSame(['LOCAL_ADMIN_EMAIL', 'LOCAL_ADMIN_PASSWORD'], $entfernt);
assertFalse(str_contains($neu, 'Geheim!123'), 'Das Passwort steht weiterhin in der Datei');
assertFalse(str_contains($neu, 'LOCAL_ADMIN'), 'Die Schlüssel stehen weiterhin in der Datei');
assertFalse(str_contains($neu, 'Lokaler Admin'), 'Der Kommentarkopf bleibt ohne Inhalt stehen');
$weg($dir);
});
test('Alle übrigen Zeilen bleiben unangetastet', function () use ($tmp, $weg) {
// Die .env gehört dem Betreiber; angefasst wird nur, was aufzuräumen ist.
$dir = $tmp();
$env = $dir . '/.env';
file_put_contents($env, "APP_KEY=abc\nDB_PASSWORD=\"p#ass=wort\"\nLOCAL_ADMIN_PASSWORD=\"x\"\n# Hinweis\nTRUSTED_PROXIES=10.0.0.1\n");
LegacySecretCleanup::stripInstallerCredentials($env);
$neu = (string)file_get_contents($env);
assertSame("APP_KEY=abc\nDB_PASSWORD=\"p#ass=wort\"\n# Hinweis\nTRUSTED_PROXIES=10.0.0.1\n", $neu);
$weg($dir);
});
test('Ein zweiter Lauf ändert nichts mehr', function () use ($tmp, $weg) {
$dir = $tmp();
$env = $dir . '/.env';
file_put_contents($env, "APP_KEY=abc\nLOCAL_ADMIN_EMAIL=\"a@b.c\"\n");
assertSame(['LOCAL_ADMIN_EMAIL'], LegacySecretCleanup::stripInstallerCredentials($env));
$nachEins = (string)file_get_contents($env);
assertSame([], LegacySecretCleanup::stripInstallerCredentials($env), 'Der zweite Lauf meldet Arbeit');
assertSame($nachEins, (string)file_get_contents($env), 'Der zweite Lauf schreibt erneut');
$weg($dir);
});
test('Eine .env ohne die Schlüssel wird nicht angefasst', function () use ($tmp, $weg) {
$dir = $tmp();
$env = $dir . '/.env';
file_put_contents($env, "APP_KEY=abc\n");
$vorher = filemtime($env);
assertSame([], LegacySecretCleanup::stripInstallerCredentials($env));
clearstatcache(true, $env);
assertSame($vorher, filemtime($env), 'Die Datei wurde ohne Anlass neu geschrieben');
$weg($dir);
});
test('Eine fehlende Datei ist kein Fehler', function () {
assertSame([], LegacySecretCleanup::stripInstallerCredentials('/gibt/es/nicht/.env'));
});
test('Die .env wird atomar geschrieben', function () use ($wurzel) {
// Ein Abbruch mitten im Schreiben – volle Platte, beendeter Prozess –
// liesse die Datei abgeschnitten zurueck. Ohne Zugangsdaten zur
// Datenbank startet die Anwendung dann nicht mehr.
$quelle = (string)file_get_contents($wurzel . '/src/Core/LegacySecretCleanup.php');
$von = strpos($quelle, 'public static function stripInstallerCredentials');
$rumpf = substr($quelle, (int)$von, (int)strpos($quelle, "\n }\n", (int)$von) - (int)$von);
assertTrue(str_contains($rumpf, 'AtomicFile::write('), 'Die Datei wird direkt ueberschrieben');
assertFalse(str_contains($rumpf, 'file_put_contents'), 'Es wird weiterhin direkt geschrieben');
});
test('Die Rechte der .env bleiben erhalten', function () use ($tmp, $weg) {
// 0640 ist die vom Installer gesetzte Vorgabe; ein Schreibvorgang darf
// die Datei nicht fuer andere lesbar machen.
$dir = $tmp();
$env = $dir . '/.env';
file_put_contents($env, "APP_KEY=abc\nLOCAL_ADMIN_PASSWORD=\"x\"\n");
chmod($env, 0640);
LegacySecretCleanup::stripInstallerCredentials($env);
clearstatcache(true, $env);
assertSame('0640', substr(sprintf('%o', fileperms($env)), -4));
$weg($dir);
});
// ----------------------------------------------------------------
// config/runtime.php
// ----------------------------------------------------------------
test('Klartext in der Laufzeitkonfiguration wird verschlüsselt', function () use ($tmp, $weg) {
$dir = $tmp();
$rt = $dir . '/runtime.php';
file_put_contents($rt, "<?php\n\nreturn " . var_export([
'smtp' => ['host' => 'mail.example.org', 'password' => 'KlartextMail'],
'azure' => ['client_secret' => 'KlartextAzure'],
'modules' => ['tickets' => true],
], true) . ";\n");
$schluessel = bin2hex(random_bytes(16));
$anzahl = LegacySecretCleanup::encryptRuntimeSecrets($rt, $schluessel);
assertSame(2, $anzahl, 'Nicht alle Klartextwerte erfasst');
$neu = include $rt;
assertTrue(CredentialCrypto::isEncrypted($neu['smtp']['password']), 'Mail-Passwort weiterhin im Klartext');
assertTrue(CredentialCrypto::isEncrypted($neu['azure']['client_secret']), 'Azure-Secret weiterhin im Klartext');
// Entschlüsselbar, also weiterhin nutzbar.
assertSame('KlartextMail', CredentialCrypto::decrypt($neu['smtp']['password'], $schluessel));
// Nicht-Geheimnisse bleiben, wie sie sind.
assertSame('mail.example.org', $neu['smtp']['host']);
assertTrue($neu['modules']['tickets']);
$weg($dir);
});
test('Bereits verschlüsselte Werte bleiben unberührt', function () use ($tmp, $weg) {
$dir = $tmp();
$rt = $dir . '/runtime.php';
$schluessel = bin2hex(random_bytes(16));
$fertig = CredentialCrypto::encrypt('schon-fertig', $schluessel);
file_put_contents($rt, "<?php\n\nreturn " . var_export(['smtp' => ['password' => $fertig]], true) . ";\n");
assertSame(0, LegacySecretCleanup::encryptRuntimeSecrets($rt, $schluessel));
$neu = include $rt;
assertSame($fertig, $neu['smtp']['password'], 'Der Wert wurde ein zweites Mal verschlüsselt');
$weg($dir);
});
test('Ohne Anwendungsschlüssel bleibt die Datei unverändert', function () use ($tmp, $weg) {
// Lieber Klartext stehen lassen als mit einem untauglichen Schlüssel
// unlesbar machen.
$dir = $tmp();
$rt = $dir . '/runtime.php';
file_put_contents($rt, "<?php\n\nreturn " . var_export(['smtp' => ['password' => 'Klartext']], true) . ";\n");
$vorher = (string)file_get_contents($rt);
assertSame(0, LegacySecretCleanup::encryptRuntimeSecrets($rt, ''));
assertSame(0, LegacySecretCleanup::encryptRuntimeSecrets($rt, 'zu-kurz'));
assertSame($vorher, (string)file_get_contents($rt));
$weg($dir);
});
test('Die Liste deckt dieselben Werte ab wie die Einstellungsseite', function () use ($wurzel) {
// Was beim Speichern verschlüsselt wird, muss die Umstellung auch finden.
$cleanup = (string)file_get_contents($wurzel . '/src/Core/LegacySecretCleanup.php');
$settings = (string)file_get_contents($wurzel . '/src/Controllers/Admin/SettingsController.php');
preg_match_all("/\\\$config\['([a-z_]+)'\]\['([a-z_]+)'\] = CredentialCrypto::encrypt/", $settings, $t, PREG_SET_ORDER);
assertTrue(count($t) >= 6, 'Die Einstellungsseite verschlüsselt zu wenige Felder – Prüfung greift ins Leere');
foreach ($t as [, $gruppe, $feld]) {
assertTrue(
str_contains($cleanup, "['" . $gruppe . "',") && str_contains($cleanup, "'" . $feld . "'"),
'Nicht in der Umstellung: ' . $gruppe . '.' . $feld
);
}
// Die Mail-OAuth-Werte entstehen in einem Array-Literal und entgehen dem
// Muster oben – sie sind der Grund, warum hier zusätzlich geprüft wird.
foreach (['smtp_oauth', 'refresh_token'] as $begriff) {
assertTrue(str_contains($settings, $begriff), 'Die Einstellungsseite kennt nicht: ' . $begriff);
assertTrue(str_contains($cleanup, $begriff), 'Nicht in der Umstellung: ' . $begriff);
}
});
// ----------------------------------------------------------------
// Verankerung im Update
// ----------------------------------------------------------------
test('Die Bereinigung läuft bei jedem Update mit', function () use ($wurzel) {
$quelle = (string)file_get_contents($wurzel . '/src/Content/UpdateManager.php');
assertTrue(str_contains($quelle, '$this->cleanupLegacySecrets();'), 'Nicht aufgerufen');
// clearCaches() ist der gemeinsame Abschluss aller Update-Wege.
assertTrue(
strpos($quelle, '$this->cleanupLegacySecrets();') > strpos($quelle, '$this->pruneExcludedArtifacts();'),
'Nicht am gemeinsamen Abschluss verankert'
);
});
test('Ein Fehlschlag laesst das Update weiterlaufen', function () use ($wurzel) {
$quelle = (string)file_get_contents($wurzel . '/src/Content/UpdateManager.php');
$von = strpos($quelle, 'private function cleanupLegacySecrets');
$rumpf = substr($quelle, (int)$von, (int)strpos($quelle, "\n }\n", (int)$von) - (int)$von);
// Jeder Schritt einzeln – die Zahl waechst mit den Schritten mit.
$schritte = substr_count($rumpf, 'try {');
$abgesichert = substr_count($rumpf, 'catch (\Throwable');
assertTrue($schritte > 0, 'Kein Aufraeumschritt gefunden');
assertSame($schritte, $abgesichert, 'Nicht jeder Schritt ist abgesichert');
});
test('Das Protokoll nennt die Schlüssel, nie ihre Werte', function () use ($wurzel) {
$quelle = (string)file_get_contents($wurzel . '/src/Content/UpdateManager.php');
$von = strpos($quelle, 'private function cleanupLegacySecrets');
$rumpf = substr($quelle, (int)$von, (int)strpos($quelle, "\n }\n", (int)$von) - (int)$von);
assertTrue(str_contains($rumpf, "'legacy_env_credentials_removed'"), 'Kein Eintrag zur .env');
assertTrue(str_contains($rumpf, "'runtime_secrets_encrypted'"), 'Kein Eintrag zur Laufzeitkonfiguration');
// Der Audit-Eintrag bekommt nur Namen und Anzahl, keine Inhalte.
assertTrue(str_contains($rumpf, 'implode(\', \', $entfernt)'), 'Der .env-Eintrag trägt etwas anderes');
assertFalse((bool)preg_match('/\$wert|\$password|\$secret/', $rumpf), 'Ein Wert taucht im Protokollblock auf');
});
test('Eine nicht entfernbare .env wird gemeldet', function () use ($wurzel) {
// Ohne Schreibrecht kann das Update nichts ausrichten. Bliebe das
// unbemerkt, laege das Passwort weiter im Klartext.
$quelle = (string)file_get_contents($wurzel . '/src/Controllers/Admin/SystemHealthController.php');
$von = strpos($quelle, "'Anmeldedaten in der .env'");
assertTrue($von !== false, 'Keine Pruefung');
$block = substr($quelle, (int)$von - 900, 2200);
assertTrue(str_contains($block, 'LOCAL_ADMIN_(EMAIL|PASSWORD)'), 'Es wird nicht auf die Schluessel geprueft');
assertTrue(str_contains($block, "'error'"), 'Der nicht behebbare Fall gilt nur als Warnung');
assertTrue(str_contains($block, 'is_writable'), 'Das Schreibrecht wird nicht unterschieden');
assertTrue(str_contains($block, 'von Hand'), 'Ohne Angabe, was zu tun ist');
});
test('Der Hinweis steht ganz oben im Changelog', function () use ($wurzel) {
// Nach einem Update wird der Changelog im Backend angezeigt; der Hinweis
// darf nicht unter den Versionsabschnitten verschwinden.
$cl = (string)file_get_contents($wurzel . '/CHANGELOG.md');
$hinweis = strpos($cl, 'Achtung');
$ersteVersion = strpos($cl, "\n## [");
assertTrue($hinweis !== false, 'Kein Hinweis im Changelog');
assertTrue($hinweis < $ersteVersion, 'Der Hinweis steht unter den Versionsabschnitten');
assertTrue(str_contains($cl, 'Passwort'), 'Der Hinweis nennt den Anlass nicht');
});
};