$cnf, 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); openssl_pkey_export($k, $privPem, null, ['config' => $cnf]); $pubPem = openssl_pkey_get_details($k)['key']; $hash = password_hash('Ein-Sehr-Langes-Passwort-1', PASSWORD_DEFAULT); $bake = function (array $env) use ($site, $tmp): array { $cmd = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($site . '/docker-bake.php') . ' 2>&1'; $full = $env + ['BAKE_SECRETS_DIR' => $tmp . '/secrets', 'BAKE_KEY_DIR' => $tmp . '/secure', 'BAKE_CONFIG_PATH' => $tmp . '/config.php', 'BAKE_STORAGE_DIR' => $tmp . '/data']; $ph = proc_open($cmd, [1 => ['pipe', 'w']], $pipes, null, $full + getenv()); $out = stream_get_contents($pipes[1]); return [proc_close($ph), $out]; }; // Fehlerhafte Eingaben werden abgelehnt file_put_contents($tmp . '/secrets/admin_hash', 'kein-hash'); file_put_contents($tmp . '/secrets/catalog_key', 'abcdef0123456789abcdef0123456789abcdef0123456789'); file_put_contents($tmp . '/secrets/signing_key', $privPem); [$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']); $check($code !== 0 && str_contains($out, 'Passwort-Hash'), 'Klartext statt Passwort-Hash wird abgelehnt'); file_put_contents($tmp . '/secrets/admin_hash', $hash); [$code, $out] = $bake(['BASE_URL' => 'http://plugins.example.de', 'ADMIN_USER' => 'admin']); $check($code !== 0 && str_contains($out, 'https://'), 'http-Adresse wird abgelehnt'); [$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'a b']); $check($code !== 0 && str_contains($out, 'ADMIN_USER'), 'ungültiger Benutzername wird abgelehnt'); file_put_contents($tmp . '/secrets/signing_key', 'kein schlüssel'); [$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']); $check($code !== 0 && str_contains($out, 'Schlüssel'), 'ungültiger Signierschlüssel wird abgelehnt'); $check(!file_exists($tmp . '/config.php'), 'bei Fehlern wird keine config.php geschrieben'); file_put_contents($tmp . '/secrets/signing_key', $privPem); foreach (['' => 'fehlender', 'kurz' => 'zu kurzer', 'mit leerzeichen und sonderzeichen!!mit leerzeichen und sonderzeichen' => 'ungültiger'] as $badKey => $label) { file_put_contents($tmp . '/secrets/catalog_key', $badKey); [$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de', 'ADMIN_USER' => 'admin']); $check($code !== 0 && str_contains($out, 'catalog_key'), "{$label} Zugangsschlüssel für den Katalog wird abgelehnt"); } file_put_contents($tmp . '/secrets/catalog_key', 'abcdef0123456789abcdef0123456789abcdef0123456789'); // Erfolgsfall file_put_contents($tmp . '/secrets/signing_key', $privPem); [$code, $out] = $bake(['BASE_URL' => 'https://plugins.example.de/', 'ADMIN_USER' => 'betreiber', 'SITE_NAME' => 'Mein Katalog']); $check($code === 0 && str_contains($out, 'BEGIN PUBLIC KEY') && !str_contains($out, 'BEGIN PRIVATE KEY') && !str_contains($out, $hash), 'Build gelingt und gibt nur den öffentlichen Schlüssel aus'); $cfg = is_file($tmp . '/config.php') ? (array) require $tmp . '/config.php' : []; $check(($cfg['locked'] ?? false) === true && $cfg['admin_user'] === 'betreiber' && $cfg['admin_password_hash'] === $hash && $cfg['base_url'] === 'https://plugins.example.de' && $cfg['site_name'] === 'Mein Katalog', 'config.php enthält die fest eingebauten Werte und ist gesperrt'); $check(($cfg['access_key'] ?? '') === 'abcdef0123456789abcdef0123456789abcdef0123456789' && !str_contains($out, 'abcdef0123456789abcdef'), 'Zugangsschlüssel des Katalogs wird fest eingebaut und nicht ausgegeben'); $check(is_file($tmp . '/secure/herausgeber-privat.pem') && trim(file_get_contents($tmp . '/secure/herausgeber-privat.pem')) === trim($privPem) && $cfg['signing_key_file'] === $tmp . '/secure/herausgeber-privat.pem', 'privater Schlüssel liegt im geschützten Ordner'); // Gesperrte Konfiguration: Umgebungsvariablen und fremde Konfigurationsdateien haben keine Wirkung $siteCopy = $tmp . '/site'; mkdir($siteCopy . '/inc', 0755, true); copy($site . '/inc/lib.php', $siteCopy . '/inc/lib.php'); copy($tmp . '/config.php', $siteCopy . '/config.php'); $evil = $tmp . '/evil.php'; file_put_contents($evil, " 'angreifer', 'admin_password_hash' => " . var_export(password_hash('x', PASSWORD_DEFAULT), true) . ", 'locked' => false, 'storage_dir' => '/tmp/boese'];"); $probe = $tmp . '/probe.php'; file_put_contents($probe, " ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env + getenv()); $out = stream_get_contents($pipes[1]); proc_close($ph); return json_decode($out, true) ?: []; }; $c = $run(['PLUGIN_SITE_ADMIN_HASH' => password_hash('angriff', PASSWORD_DEFAULT), 'PLUGIN_SITE_STORAGE' => '/tmp/boese', 'PLUGIN_SITE_SIGNING_KEY' => '/tmp/fremd.pem', 'PLUGIN_SITE_BASE_URL' => 'https://boese.example', 'PLUGIN_SITE_CONFIG' => $evil]); $check($c['admin_password_hash'] === $hash && $c['admin_user'] === 'betreiber' && $c['storage_dir'] === $tmp . '/data' && $c['base_url'] === 'https://plugins.example.de' && $c['signing_key_file'] === $tmp . '/secure/herausgeber-privat.pem', 'im gesperrten Zustand ändern Umgebungsvariablen und fremde Konfigurationsdatei nichts'); // Gegenprobe: ohne Sperre würden die Umgebungsvariablen wirken (die lokale Entwicklungs-/Testvariante) $cfgOpen = $cfg; $cfgOpen['locked'] = false; file_put_contents($siteCopy . '/config.php', " 'ueberschrieben']); $check($c['admin_password_hash'] === 'ueberschrieben', 'Gegenprobe: ohne Sperre wirken Umgebungsvariablen weiterhin (Entwicklung)'); // Docker-Dateien enthalten die zugesagten Schutzmaßnahmen $df = (string) file_get_contents($site . '/Dockerfile'); $check(str_contains($df, '--mount=type=secret,id=admin_hash') && str_contains($df, '--mount=type=secret,id=signing_key') && str_contains($df, '--mount=type=secret,id=catalog_key'), 'Dockerfile übergibt Hash, Signierschlüssel und Zugangsschlüssel als BuildKit-Secrets'); $check(str_contains($df, 'health.php'), 'der Healthcheck braucht keinen Zugangsschlüssel'); $check(!preg_match('/ARG\s+(ADMIN_PASSWORD|PASSWORD|SIGNING_KEY|ADMIN_HASH)/i', $df), 'Dockerfile nimmt keine Passwörter oder Schlüssel als normale Build-Argumente'); $check(str_contains($df, 'chmod 0440 /var/www/html/config.php') && str_contains($df, 'chown -R root:root /var/www/html'), 'config.php ist im Image schreibgeschützt'); $check(str_contains($df, 'VOLUME ["/data"]'), 'nur /data ist als Volume vorgesehen'); $dc = (string) file_get_contents($site . '/docker-compose.yml'); $check(str_contains($dc, 'read_only: true') && str_contains($dc, 'no-new-privileges') && str_contains($dc, 'cap_drop'), 'Compose-Datei härtet den Container'); $check(!preg_match('/PLUGIN_SITE_|ADMIN|PASSWORD/i', preg_replace('/#.*$/m', '', $dc)), 'Compose-Datei enthält keine Zugangsdaten oder Überschreibungsmöglichkeiten'); $ps = (string) file_get_contents($site . '/docker-build.ps1'); $check(str_contains($ps, '--secret') || str_contains($ps, '`--secret') || str_contains($ps, '--secret "id=admin_hash'), 'Build-Skript nutzt Secrets'); $ignore = (string) file_get_contents($site . '/.dockerignore'); $check(str_contains($ignore, '*.pem') && str_contains($ignore, 'config.php') && str_contains($ignore, 'storage'), '.dockerignore hält lokale Schlüssel und Daten aus dem Image'); $rm = function (string $d) use (&$rm) { foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } @rmdir($d); }; $rm($tmp); @unlink($cnf); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "Docker website checks passed ({$total} Prüfungen).\n";