Files
2026-11-05 21:21:59 +00:00

283 lines
20 KiB
PHP
Raw Permalink 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);
/**
* Prüft Passkeys über die echte Weboberfläche (Anmeldeseite, staff/passkey.php, Profil, Mitarbeiterverwaltung) mit einem nachgebauten Authenticator:
* Registrieren (mit Passwortbestätigung), Anmelden ohne Passwort, Ablehnungen, Entfernen durch den Mitarbeiter und durch einen Administrator.
*/
if (!extension_loaded('pdo_sqlite') || !function_exists('curl_init')) { echo "Abmelde-Web-Test übersprungen: pdo_sqlite oder curl fehlt.\n"; exit(0); }
date_default_timezone_set('Europe/Berlin');
$root = dirname(__DIR__);
$work = sys_get_temp_dir() . '/deskhub-pkw-' . bin2hex(random_bytes(4));
$tmp = $work . '/inst';
mkdir($tmp, 0755, true);
$webPort = random_int(35000, 35499);
$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|plugins/[^/]+|\.git)(/|$)#';
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) {
$rel = str_replace(chr(92), '/', substr($f->getPathname(), strlen($root) + 1));
if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue;
if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; }
@mkdir(dirname($tmp . '/' . $rel), 0755, true);
copy($f->getPathname(), $tmp . '/' . $rel);
}
@mkdir($tmp . '/uploads');
@mkdir($tmp . '/config');
file_put_contents($tmp . '/config/config.php', '<?php return ' . var_export([
'app_key' => bin2hex(random_bytes(32)),
'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'],
'site' => ['name' => 'Test', 'url' => 'https://127.0.0.1:' . $webPort, 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'],
'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'],
], true) . ';');
putenv('DESKHUB_CONFIG_JSON');
putenv('DESKHUB_SQLITE_PATH');
putenv('DESKHUB_TESTING');
putenv('DESKHUB_API_ALLOW_INSECURE=1');
require_once $root . '/database/sqlite_schema.php';
require_once $root . '/database/migrate.php';
$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec('PRAGMA foreign_keys = ON');
$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql')));
run_migrations($db, true);
$now = date('Y-m-d H:i:s');
$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn();
$db->exec('DELETE FROM staff');
$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)');
foreach ([['Chef', 'chef@test.de', 'admin'], ['Eva', 'eva@test.de', 'agent'], ['Max', 'max@test.de', 'agent']] as [$n, $e, $r]) {
$ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]);
}
$evaId = (int) $db->query("SELECT id FROM staff WHERE email = 'eva@test.de'")->fetchColumn();
$db = null;
$failures = [];
$total = 0;
$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; };
$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp);
try {
for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); }
$pdo = function () use ($tmp): PDO {
$p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite');
$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
return $p;
};
$call = function (string $method, string $path, array $form, string $jar, ?string $bearer = null, ?array $json = null) use ($webPort): array {
$ch = curl_init("http://127.0.0.1:$webPort$path");
$h = ['Sec-Fetch-Mode: navigate'];
if ($json !== null) $h[] = 'Content-Type: application/json';
if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer;
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $h]);
if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); }
if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); }
$raw = (string) curl_exec($ch);
$s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
return [$s, substr($raw, $hs), substr($raw, 0, $hs)];
};
$login = function (string $email) use ($call): string {
$jar = tempnam(sys_get_temp_dir(), 'sow');
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
$call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234'], $jar);
return $jar;
};
$csrf = function (string $path, string $jar) use ($call): string {
[, $p] = $call('GET', $path, [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm);
return $mm[1] ?? '';
};
$loggedIn = function (string $jar) use ($call): bool {
[$s, $b] = $call('GET', '/staff/profile.php?tab=security', [], $jar);
return $s === 200 && str_contains($b, 'Von allen Geräten abmelden');
};
$apiToken = function (string $email) use ($call): string {
[, $b] = $call('POST', '/api/staff/v1/login.php', [], '', null, ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test-Gerät']);
return (string) (json_decode($b, true)['token'] ?? '');
};
$apiOk = fn(string $tok) => $call('GET', '/api/staff/v1/me.php', [], '', $tok)[0];
require_once $root . '/includes/webauthn.php';
require_once __DIR__ . '/fixtures/fake_authenticator.php';
$GLOBALS['fa_rp'] = '127.0.0.1';
$GLOBALS['fa_origin'] = 'https://127.0.0.1:' . $webPort;
$chefId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'chef@test.de'")->fetchColumn();
$maxId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'max@test.de'")->fetchColumn();
$count = fn(int $sid) => (int) $pdo()->query("SELECT COUNT(*) FROM staff_passkeys WHERE staff_id = {$sid}")->fetchColumn();
$pk = function (string $jar, string $csrf, array $f) use ($call): array {
[$s, $b] = $call('POST', '/staff/passkey.php', ['csrf_token' => $csrf] + $f, $jar);
return [$s, json_decode($b, true) ?: []];
};
$addPasskey = function (string $jar, FakeKey $k, string $name = 'Laptop', string $pw = 'Sehr-Sicher-1234') use ($csrf, $pk): array {
$t = $csrf('/staff/profile.php?tab=security', $jar);
[$s, $o] = $pk($jar, $t, ['action' => 'register_options', 'password' => $pw]);
if ($s !== 200) return [$s, $o];
return $pk($jar, $t, ['action' => 'register_verify', 'name' => $name, 'credential' => json_encode(t_register($k, $o['options']))]);
};
$passkeyLogin = function (FakeKey $k, array $o = []) use ($call, $pk): array {
$jar = tempnam(sys_get_temp_dir(), 'pkw');
[, $lp] = $call('GET', '/staff/login.php', [], $jar);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
[$s, $opt] = $pk($jar, $m[1] ?? '', ['action' => 'login_options']);
if ($s !== 200) return [$jar, $s, $opt];
[$s, $r] = $pk($jar, $m[1], ['action' => 'login_verify', 'credential' => json_encode(t_login($k, $opt['options'], 0, $o + ['userHandle' => '']))]);
return [$jar, $s, $r];
};
$jarEva = $login('eva@test.de');
$jarChef = $login('chef@test.de');
$jarMax = $login('max@test.de');
[, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarEva);
$check(str_contains($page, 'Passkeys') && str_contains($page, 'Noch kein Passkey eingerichtet') && str_contains($page, '/assets/js/passkey.js'), 'Profil zeigt den Bereich „Passkeys“ ohne Eintrag');
[, $lp] = $call('GET', '/staff/login.php', [], '');
$check(str_contains($lp, 'Mit Passkey anmelden') && str_contains($lp, 'passkey.js'), 'Anmeldeseite hat den Knopf „Mit Passkey anmelden“');
$check(preg_match('/id="pk_name"[^>]*autocomplete="off"[^>]*data-bwignore/', $page) === 1, 'Namensfeld des Passkeys wird von Passwortmanagern nicht ausgefüllt');
// Registrieren
$evaKey = new FakeKey('ec');
[$s, $r] = $addPasskey($jarEva, $evaKey, 'Laptop', 'falsches-Passwort');
$check($s === 403 && empty($r['ok']) && $count($evaId) === 0, 'Registrierung ohne richtiges Passwort wird abgelehnt (HTTP ' . $s . ')');
[$s, $r] = $pk($jarEva, '', ['action' => 'register_options', 'password' => 'Sehr-Sicher-1234']);
$check($s === 403, 'ohne CSRF-Token wird nichts angenommen');
[$s, $b] = $call('POST', '/staff/passkey.php', ['action' => 'register_options', 'password' => 'x'], '');
$check($s === 403, 'ohne Sitzung wird nichts angenommen');
$t = $csrf('/staff/profile.php?tab=security', $jarEva);
[$s, $r] = $pk('', $t, ['action' => 'register_options', 'password' => 'Sehr-Sicher-1234']);
$check($s === 403 || $s === 401, 'ohne Anmeldung gibt es keine Registrierungsoptionen (HTTP ' . $s . ')');
[$s, $r] = $addPasskey($jarEva, $evaKey);
$check($s === 200 && !empty($r['ok']) && $count($evaId) === 1, 'Registrierung mit richtigem Passwort klappt: ' . json_encode($r));
[, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarEva);
$check(str_contains($page, 'Laptop') && str_contains($page, 'Passkey hinzugefügt'), 'Profil zeigt den Passkey und die Erfolgsmeldung');
[$s, $r] = $addPasskey($jarEva, $evaKey, 'Nochmal');
$check(empty($r['ok']) && $count($evaId) === 1, 'Derselbe Passkey lässt sich nicht doppelt eintragen');
// Anmelden ohne Passwort
[$jar, $s, $r] = $passkeyLogin($evaKey);
$check($s === 200 && !empty($r['ok']) && $loggedIn($jar), 'Anmeldung mit Passkey klappt und die Sitzung ist gültig: ' . json_encode($r));
$ev = $pdo()->query("SELECT COUNT(*) FROM login_events WHERE staff_id = {$evaId} AND event IN ('success','new_device','new_place')")->fetchColumn();
$check((int) $ev >= 1, 'Protokoll: die Anmeldung steht im Anmelde-Wächter');
@unlink($jar);
[$jar, $s, $r] = $passkeyLogin($evaKey, ['count' => 1]);
$check($s === 401 && !$loggedIn($jar), 'Zähler, der nicht wächst, wird abgelehnt (HTTP ' . $s . ')');
@unlink($jar);
[$jar, $s, $r] = $passkeyLogin(new FakeKey('ec'));
$check($s === 401 && !$loggedIn($jar), 'unbekannter Passkey wird abgelehnt');
@unlink($jar);
[$jar, $s, $r] = $passkeyLogin($evaKey, ['signWith' => (new FakeKey('ec'))->res, 'count' => 20]);
$check($s === 401 && !$loggedIn($jar), 'falsche Signatur wird abgelehnt');
@unlink($jar);
[$jar, $s, $r] = $passkeyLogin($evaKey, ['origin' => 'https://evil.example', 'count' => 30]);
$check($s === 401 && !$loggedIn($jar), 'falsche Herkunft wird abgelehnt');
@unlink($jar);
// Antwort ohne vorherige Optionen
$jarN = tempnam(sys_get_temp_dir(), 'pkw');
[, $lp] = $call('GET', '/staff/login.php', [], $jarN);
preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m);
$fake = t_login($evaKey, ['challenge' => webauthn_b64u_encode(random_bytes(32))], 0, ['count' => 40, 'userHandle' => '']);
[$s, $r] = $pk($jarN, $m[1], ['action' => 'login_verify', 'credential' => json_encode($fake)]);
$check($s === 401 && !$loggedIn($jarN), 'ohne vorherige Anfrage (keine Challenge in der Sitzung) wird abgelehnt');
@unlink($jarN);
// Deaktiviertes Konto
$p = $pdo(); $p->exec("UPDATE staff SET is_active = 0 WHERE id = {$evaId}");
[$jar, $s] = $passkeyLogin($evaKey, ['count' => 50]);
$check($s === 401 && !$loggedIn($jar), 'deaktiviertes Konto kann sich nicht mit Passkey anmelden');
@unlink($jar);
$p->exec("UPDATE staff SET is_active = 1 WHERE id = {$evaId}");
// Administrator entfernt die Passkeys eines Mitarbeiters
[$s, $r] = $addPasskey($jarMax, new FakeKey('ec'), 'Max Handy');
$check($count($maxId) === 1, 'Max hat einen Passkey');
[, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef);
$check(str_contains($page, 'Passkeys entfernen (1)') && str_contains($page, '🔑 1 Passkey'), 'Mitarbeiterverwaltung zeigt „Passkeys entfernen (1)“ und die Anzahl');
// Ein normaler Mitarbeiter darf das nicht
$t = $csrf('/staff/profile.php', $jarMax);
$call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkeys', 'id' => $evaId], $jarMax);
$check($count($evaId) === 1, 'Ein normaler Mitarbeiter kann keine fremden Passkeys entfernen');
$t = $csrf('/staff/staff_manage.php', $jarChef);
[$s] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkeys', 'id' => $maxId], $jarChef);
$check($s === 302 && $count($maxId) === 0 && $count($evaId) === 1, 'Administrator entfernt Max’ Passkeys, Evas bleiben (HTTP ' . $s . ')');
$ev = $pdo()->query("SELECT detail FROM login_events WHERE event = 'passkey_removed' AND staff_id = {$maxId}")->fetchColumn();
$check(str_contains((string) $ev, 'Administrator'), 'Protokoll nennt den Administrator');
$aud = $pdo()->query("SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE 'Passkeys von Mitarbeiter #{$maxId} entfernt%'")->fetchColumn();
$check((int) $aud === 1, 'Admin-Protokoll hat den Eintrag');
[, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef);
$check(!str_contains($page, 'Passkeys entfernen (0)') && str_contains($page, 'Passkeys entfernen (1)'), 'Danach steht der Knopf nur noch bei Eva');
$maxKey = new FakeKey('ec');
$addPasskey($jarMax, $maxKey, 'Neu');
[$jar, $s] = $passkeyLogin($maxKey);
$check($s === 200 && $loggedIn($jar), 'Nach dem Entfernen kann der Mitarbeiter einen neuen Passkey einrichten und nutzen');
@unlink($jar);
// Mitarbeiter entfernt den eigenen Passkey (Passwort nötig)
$pid = (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$evaId}")->fetchColumn();
$t = $csrf('/staff/profile.php?tab=security', $jarEva);
$call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $pid, 'confirm_password' => 'falsch'], $jarEva);
$check($count($evaId) === 1, 'Eigener Passkey: mit falschem Passwort bleibt er bestehen');
$call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $pid, 'confirm_password' => 'Sehr-Sicher-1234'], $jarEva);
$check($count($evaId) === 0, 'Eigener Passkey: mit richtigem Passwort wird er entfernt');
[$jar, $s] = $passkeyLogin($evaKey, ['count' => 60]);
$check($s === 401 && !$loggedIn($jar), 'Der entfernte Passkey meldet nicht mehr an');
@unlink($jar);
// Fremden Passkey kann man nicht über die eigene ID löschen
$maxPid = (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$maxId}")->fetchColumn();
$t = $csrf('/staff/profile.php?tab=security', $jarEva);
$call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $maxPid, 'confirm_password' => 'Sehr-Sicher-1234'], $jarEva);
$check($count($maxId) === 1, 'Ein Passkey eines anderen Mitarbeiters lässt sich nicht löschen');
// Gestaltung: der Knopf steht im selben Block wie die Knöpfe der Anmelde-Plugins
[, $lp] = $call('GET', '/staff/login.php', [], '');
$check(preg_match('#id="login-alt"[^>]*>\s*<button type="button" id="passkey-login" class="btn secondary"#', $lp) === 1 && !str_contains($lp, 'passkey-box'), 'Anmeldeseite: Passkey-Knopf im Block der Anmeldeknöpfe, gleiche Klassen wie bei authentik');
// Administrator: einzelnen Passkey entfernen und Karte in der Bearbeiten-Ansicht
$k1 = new FakeKey('ec'); $k2 = new FakeKey('ec');
$addPasskey($jarMax, $k1, 'Erster'); $addPasskey($jarMax, $k2, 'Zweiter');
[, $edit] = $call('GET', '/staff/staff_manage.php?edit=' . $maxId, [], $jarChef);
$check(str_contains($edit, 'Passkeys von Max') && str_contains($edit, 'Erster') && str_contains($edit, 'Zweiter') && str_contains($edit, 'Alle Passkeys entfernen'), 'Bearbeiten-Ansicht zeigt die Passkeys des Mitarbeiters mit Entfernen');
$firstId = (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$maxId} AND name = 'Erster'")->fetchColumn();
$t = $csrf('/staff/staff_manage.php?edit=' . $maxId, $jarChef);
[$s] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkey', 'id' => $maxId, 'passkey_id' => $firstId], $jarChef);
$left = $pdo()->query("SELECT name FROM staff_passkeys WHERE staff_id = {$maxId}")->fetchAll(PDO::FETCH_COLUMN);
$check($s === 302 && !in_array('Erster', $left, true) && in_array('Zweiter', $left, true) && count($left) === 2, 'Administrator entfernt einen einzelnen Passkey, die anderen bleiben');
$evaOther = $pdo()->query("SELECT COUNT(*) FROM staff_passkeys WHERE staff_id = {$evaId}")->fetchColumn();
$call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkey', 'id' => $evaId, 'passkey_id' => (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$maxId}")->fetchColumn()], $jarChef);
$check($count($maxId) === 2, 'Ein Passkey lässt sich nur über den richtigen Mitarbeiter entfernen');
[$s, , $hdr] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkeys', 'id' => $maxId, 'back' => 'edit'], $jarChef);
$check($s === 302 && $count($maxId) === 0 && str_contains($hdr, 'edit=' . $maxId), 'Alle entfernen aus der Bearbeiten-Ansicht führt dorthin zurück');
// Schalter: Passkeys ausschalten
$kk = new FakeKey('ec');
$addPasskey($jarMax, $kk, 'Handy');
$t = $csrf('/staff/login_log.php', $jarChef);
[, $lg] = $call('GET', '/staff/login_log.php', [], $jarChef);
$check(str_contains($lg, 'name="passkeys_enabled"') && preg_match('/name="passkeys_enabled"[^>]*checked/', $lg) === 1, 'Anmelde-Wächter hat den Schalter „Passkeys erlauben“ (an)');
$call('POST', '/staff/login_log.php', ['csrf_token' => $t, 'action' => 'save', 'login_guard' => '1', 'login_guard_max_attempts' => '5', 'login_guard_lock_minutes' => '15', 'login_guard_retention_days' => '180'], $jarChef);
[, $lp] = $call('GET', '/staff/login.php', [], '');
$check(!str_contains($lp, 'id="passkey-login"'), 'Ausgeschaltet: kein Passkey-Knopf auf der Anmeldeseite');
[$jar, $s, $r] = $passkeyLogin($kk, ['count' => 5]);
$check($s === 403 && !$loggedIn($jar), 'Ausgeschaltet: Anmeldung mit Passkey wird abgelehnt (HTTP ' . $s . ')');
@unlink($jar);
[, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarMax);
$check(str_contains($page, 'zurzeit ausgeschaltet'), 'Ausgeschaltet: Profil weist darauf hin');
$check($count($maxId) === 1, 'Ausgeschaltet: vorhandene Passkeys bleiben gespeichert');
$call('POST', '/staff/login_log.php', ['csrf_token' => $t, 'action' => 'save', 'login_guard' => '1', 'passkeys_enabled' => '1', 'login_guard_max_attempts' => '5', 'login_guard_lock_minutes' => '15', 'login_guard_retention_days' => '180'], $jarChef);
[$jar, $s] = $passkeyLogin($kk, ['count' => 6]);
$check($s === 200 && $loggedIn($jar), 'Wieder eingeschaltet: derselbe Passkey funktioniert wieder');
@unlink($jar);
foreach ([$jarEva, $jarChef, $jarMax] as $j) @unlink($j);
} finally {
if (is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); }
$rm = function (string $d) use (&$rm): void { foreach (glob($d . '/{,.}*', GLOB_BRACE) ?: [] as $e) { $b = basename($e); if ($b === '.' || $b === '..') continue; is_dir($e) ? $rm($e) : @unlink($e); } @rmdir($d); };
$rm($work);
}
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "Passkey web checks passed ({$total} Prüfungen).\n";