118 lines
8.7 KiB
PHP
118 lines
8.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Prüft die fest eingebauten Zugangsdaten der Docker-Variante der Plugin-Webseite, soweit ohne laufenden Docker-Daemon möglich:
|
|
* Das Bake-Skript (läuft im Docker-Build) schreibt eine gesperrte config.php, die Webseite ignoriert dann jede Umgebungsvariable
|
|
* und andere Konfigurationsdatei, und die Docker-Dateien enthalten die zugesagten Schutzmaßnahmen.
|
|
*/
|
|
|
|
require_once __DIR__ . '/fixtures/paths.php';
|
|
$site = website_dir_or_skip('plugin_website_docker');
|
|
$tmp = sys_get_temp_dir() . '/dh-bake-' . bin2hex(random_bytes(4));
|
|
mkdir($tmp . '/secrets', 0755, true);
|
|
$failures = [];
|
|
$total = 0;
|
|
$check = function (bool $ok, string $what) use (&$failures, &$total): void {
|
|
$total++;
|
|
if (!$ok) $failures[] = $what;
|
|
};
|
|
|
|
$cnf = tempnam(sys_get_temp_dir(), 'cnf');
|
|
file_put_contents($cnf, "[req]\ndistinguished_name=req_dn\n[req_dn]\n");
|
|
$k = openssl_pkey_new(['config' => $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, "<?php return ['admin_user' => '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, "<?php require " . var_export($siteCopy . '/inc/lib.php', true) . "; echo json_encode(site_config());");
|
|
$run = function (array $env) use ($probe): array {
|
|
$ph = proc_open([PHP_BINARY, $probe], [1 => ['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', "<?php return " . var_export($cfgOpen, true) . ';');
|
|
$c = $run(['PLUGIN_SITE_ADMIN_HASH' => '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";
|