OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

LegacySecretCleanupTest.php

13 KB
<?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');
    });
};