= $minPct) continue; // How long it has actually been like this. last_change is when the state last flipped, so // a host that went down two minutes ago is excluded here and caught on a later pass — which // is the whole point of the gate. $since = (int) ($worstRec['last_change'] ?? 0); $for = $since > 0 ? time() - $since : 0; if (($worstRec['state'] ?? '') === 'down' && $for < $minDown) { $skipped++; continue; } $why = vv_npm_why((int) ($h['id'] ?? 0)); if (!($why['ok'] ?? false)) continue; // The findings the check already writes, which is the whole reason this does not have its // own opinion about what is wrong. Only the decisive ones are carried into the record. $said = []; foreach ($why['findings'] as $f) if (in_array($f['level'], ['bad', 'warn'], true)) $said[] = $f['text']; if (!$said) { $skipped++; continue; } $evidence = sprintf("%s is at %.2f%% over 24h%s.\n\n%s", $worstDom, $worstPct, $for > 0 ? ' and has been ' . ($worstRec['state'] ?? 'failing') . ' for ' . round($for / 3600, 1) . ' hours' : '', implode("\n", $said)); $lines[] = sprintf(' %-34s %6.2f%% %s', $worstDom, $worstPct, $said[0]); if ($dryRun) { $filed++; continue; } $w = vv_ai_finding_write([ 'kind' => 'proxy_down', 'subject' => implode(', ', $names), 'ref' => 'npm:proxy:' . ($h['id'] ?? 0), 'evidence' => $evidence, 'observed' => sprintf('%.2f%% over 24h', $worstPct), // Proven, because these are measurements rather than an inference: a TCP connect either // completed or it did not, and the access log either counted 5xx or it did not. 'proven' => true, ]); $w['ok'] ? $filed++ : $skipped++; } // ── Half two: hosts that are guarded but not protected ── // // Grouped by Authelia instance, not filed per hostname. The first version of this produced // twenty-two findings that were all the same sentence, because they all had the same cause: a // default policy of bypass means every host whose rule does not name your group lets you // through, so the number of findings was really the number of hostnames. One finding per // instance, naming the hosts it affects, is the fact — and it has one fix rather than // twenty-two. $openLines = []; if ($doAccess) { // Somebody who exists and holds none of the groups the rules name. A rule that still lets // this account through is a rule protecting nothing, and asking about an invented username // would prove nothing about the directory. $probe = vv_auth_sweep_ordinary_user(); $byInstance = []; foreach ($p['proxies'] as $h) { if (($h['enabled'] ?? true) === false) continue; if (!str_contains((string) ($h['advanced_config'] ?? ''), 'auth_request')) continue; foreach ($h['domain_names'] ?? [] as $d) { $d = strtolower(trim((string) $d)); if ($d === '' || str_contains($d, '*')) continue; $a = vv_auth_access_check($d, $probe); if (!($a['ok'] ?? false)) continue; if (($a['policy'] ?? '') !== 'bypass') continue; $inst = (string) ($a['authelia']['container'] ?? '?'); $byInstance[$inst]['default'] = (string) ($a['default_policy'] ?? '?'); $byInstance[$inst]['config'] = (string) ($a['authelia']['config'] ?? ''); // Which of the two shapes this is, per host: a hostname no rule mentions, or one a // rule covers and then steps over. They have different fixes — write a rule, or // widen an existing one — so the record keeps them apart. $stepped = false; foreach ($a['trace'] ?? [] as $t) if (($t['skip'] ?? '') === 'subject') $stepped = true; $byInstance[$inst][$stepped ? 'stepped' : 'unlisted'][] = $d; } } foreach ($byInstance as $inst => $g) { $unlisted = $g['unlisted'] ?? []; $stepped = $g['stepped'] ?? []; $all = array_merge($unlisted, $stepped); if (!$all) continue; $ev = []; $ev[] = $inst . ' has default_policy: ' . ($g['default'] ?? '?') . ', so any request its rules do ' . 'not decide is allowed through. ' . count($all) . ' hostname' . (count($all) === 1 ? '' : 's') . ' behind an auth_request block pointing at it reach the ' . 'application without being asked to authenticate.'; if ($unlisted) $ev[] = "\nNo rule mentions these at all:\n " . implode("\n ", $unlisted); if ($stepped) $ev[] = "\nA rule covers these but does not apply to an ordinary account" . ($probe ? ' (tested as ' . $probe . ')' : '') . ":\n " . implode("\n ", $stepped); $ev[] = "\nRules are in " . ($g['config'] ?: 'a config that was not found') . '.'; // Said plainly because "bypass" reads as harmless and it is the single most // consequential line in that file. $ev[] = "\nThe fix is a default_policy of deny with an explicit rule for anything that is " . "meant to be public — not a rule per hostname above."; $openLines[] = sprintf(' %-22s default bypass — %d hostname%s unprotected', $inst, count($all), count($all) === 1 ? '' : 's'); if ($dryRun) { $filed++; continue; } $w = vv_ai_finding_write([ 'kind' => 'access_open', 'subject' => $inst . ' — ' . count($all) . ' hostnames not protected', 'ref' => 'authelia:' . $inst . ':default_policy', 'evidence' => implode("\n", $ev), 'observed' => 'default_policy: ' . ($g['default'] ?? '?'), 'proven' => true, ]); $w['ok'] ? $filed++ : $skipped++; } } if ($report) { // Silent on a clean week. The orchestrator's job is to say nothing when there is nothing // to say, and a section that always prints is a section that stops being read. if (!$lines && !$openLines) exit(0); echo "Auth stack review\n"; if ($lines) { echo "\nProxy hosts not serving:\n"; foreach ($lines as $l) echo "$l\n"; } if ($openLines) { echo "\nBehind Authelia but not protected:\n"; foreach ($openLines as $l) echo "$l\n"; } exit(1); } printf("%s%d finding%s, %d skipped\n", $dryRun ? 'dry run — ' : '', $filed, $filed === 1 ? '' : 's', $skipped); foreach (array_merge($lines, $openLines) as $l) echo "$l\n"; exit(0); } finally { flock($lock, LOCK_UN); fclose($lock); } // A directory member holding none of the groups any rule names. Returns '' when every user is // privileged or the directory cannot be read, and the caller then asks about no user at all — // which still answers the "no rule mentions this host" case and simply cannot answer the // "the rule stepped over this person" one. function vv_auth_sweep_ordinary_user(): string { $named = []; // Groups named by the rules of every Authelia instance in play, not just the configured one — // a .us hostname is decided by a config this conf file does not point at. foreach (vv_auth_sweep_configs() as $cfg) { $r = vv_authelia_read_rules($cfg); foreach (($r['ok'] ?? false) ? $r['rules'] : [] as $rule) { $s = $rule['subject'] ?? null; foreach (is_array($s) ? $s : [$s] as $alt) foreach (is_array($alt) ? $alt : [$alt] as $one) if (is_string($one) && str_starts_with($one, 'group:')) $named[strtolower(substr($one, 6))] = true; } } $u = vv_lldap_list_users(); foreach (($u['ok'] ?? false) ? $u['users'] : [] as $user) { $mine = array_map('strtolower', array_filter(array_column($user['groups'] ?? [], 'displayName'))); if (array_intersect($mine, array_keys($named))) continue; return (string) ($user['id'] ?? ''); } return ''; } // Every Authelia configuration this installation actually uses, discovered through the proxy hosts // rather than listed anywhere. Two instances run here and conf names one. function vv_auth_sweep_configs(): array { $out = []; $p = vv_npm_list_proxies(); foreach (($p['ok'] ?? false) ? $p['proxies'] : [] as $h) { $i = vv_authelia_instance_for($h); if (($i['config'] ?? '') !== '' && is_file($i['config'])) $out[$i['config']] = true; } return array_keys($out); }