Files
fitme3070.pt/shared/platform-tools/functions_security_events.php
T
andreandClaude Opus 5.5 0e8b53f620 feat(audit): platform-tools atualizado (12f26fd) e registo de auditoria ligado
- shared/platform-tools: ea3616a → 12f26fd (27 commits). Traz o módulo de
  audit (f761d99, o pin da frota), «manter sessão iniciada», o registo de
  segurança da frota (o gancho que o login já tinha passa a funcionar) e a
  correção do IP do cliente encontrada nesta auditoria (último X-Forwarded-For
  / X-Real-IP), promovida ao partilhado. client_ip() da app delega nele.
- config/functions_audit.php: audit() sobre platform_audit(); tabela
  audit_log criada no primeiro uso, segredos ocultados, nunca parte a ação.
- Registado: login, falha de login, bloqueio por excesso de tentativas,
  logout, pedidos de recuperação, mudança/definição de palavra-passe,
  registo e criação de utilizadores, convites, perfil (antes/depois), todas
  as definições (db_update_setting, com valor anterior), cada passo da
  lixeira (apagar, restaurar, eliminar de vez) e ações nas mensagens.
- Sistema → Auditoria (views/sysadmin/audit.php), só sysadmin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:28:13 +01:00

399 lines
17 KiB
PHP

<?php
// ─── Eventos de segurança e banimentos da frota, via hub ────────────────────
//
// Cada plataforma regista localmente o que as defesas recusam (banimentos,
// infracções, logins falhados, CSRF, tecto de envio) numa caixa de saída, e
// envia-a em lote ao hub: POST <hub>/api/security_events.php, autenticado com a
// MESMA chave com que o hub sonda esta plataforma (X-Hub-Key). A resposta traz
// os banimentos activos da frota, que entram no ip_bans local com
// source='fleet' — um IP banido na dance passa a ser recusado na fotografia.
//
// Porquê uma caixa de saída e não um POST por evento: um ataque gera centenas
// de recusas por minuto. Um pedido HTTP por recusa punha cada pedido do
// atacante à espera da rede e transformava o ataque a um site num ataque ao
// hub. Em lote, mil recusas são um pedido.
//
// Quando é que o lote sai:
// • no fim de um pedido qualquer (platform_ip_guard() regista-o), no máximo a
// cada HUB_SECURITY_FLUSH_EVERY s se há eventos e HUB_SECURITY_SYNC_EVERY s
// se não há — sem eventos continua a sincronizar, é assim que os
// banimentos dos OUTROS sites chegam aqui;
// • pelo cron: bin/hub-security-sync.php, que esvazia a caixa toda.
//
// Nada disto bloqueia nem rebenta. Sem hub emparelhado não se regista nada
// (senão a caixa crescia para sempre); com o hub em baixo os eventos esperam,
// com tecto, e os banimentos locais funcionam exactamente como antes.
//
// Origem de cada decisão sobre banimentos da frota:
// • um banimento chega ao hub só a partir do site que o decidiu — as cópias
// (source='fleet') nunca são reenviadas, senão ecoavam em ciclo;
// • levantar um banimento em QUALQUER site levanta-o na frota: as cópias
// desaparecem de todos na sincronização seguinte. Sem isto, um unban local
// de uma cópia era desfeito cinco minutos depois. O site de origem mantém
// o seu próprio banimento até expirar ou ser levantado lá.
require_once __DIR__ . '/functions_client_ip.php';
const HUB_SECURITY_FLUSH_EVERY = 30;
const HUB_SECURITY_SYNC_EVERY = 300;
const HUB_SECURITY_BATCH = 200;
// Tecto da caixa com o hub em baixo. Os banimentos nunca são descartados; o
// resto sai do mais antigo para o mais recente.
const HUB_SECURITY_OUTBOX_MAX = 5000;
function hub_security_enabled(): bool {
static $on = null;
if ($on !== null) return $on;
try {
if (!function_exists('hub_url')) require_once __DIR__ . '/functions_hub.php';
$on = hub_url() !== '' && hub_api_key() !== '';
} catch (Throwable $e) {
$on = false;
}
return $on;
}
/**
* Carimbos em ficheiro, para o fim de cada pedido decidir «há sincronização a
* fazer?» com dois stat() e sem tocar na base de dados.
*/
function hub_security_stamp(string $name): string {
return rtrim(sys_get_temp_dir(), '/\\') . '/hub-security-'
. substr(md5(dirname(__DIR__, 2)), 0, 12) . '-' . $name;
}
function hub_security_ensure_table(PDO $pdo): void {
static $done = false;
if ($done) return;
$done = true;
// occurred_at em epoch e não DATETIME: o hub corre noutro contentor, e um
// DATETIME sem fuso lido do outro lado seria uma hora errada em metade do ano.
$pdo->exec(
"CREATE TABLE IF NOT EXISTS `hub_security_outbox` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`kind` VARCHAR(40) NOT NULL,
`ip` VARCHAR(45) NULL,
`detail` VARCHAR(190) NULL,
`data` TEXT NULL,
`occurred_at` INT UNSIGNED NOT NULL,
`attempts` INT NOT NULL DEFAULT 0,
KEY `idx_hso_kind` (`kind`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
/**
* Destino alternativo dos eventos: fn(?PDO, kind, ?detail, ?ip, array data).
* É o hub que se regista aqui — ele é o registo da frota, e mandar os seus
* próprios eventos a si mesmo por HTTP exigia emparelhá-lo consigo próprio.
*/
function platform_security_event_sink(?callable $sink = null): ?callable {
static $current = null;
if ($sink !== null) $current = $sink;
return $current;
}
/**
* Regista um evento para o hub. Nunca lança e nunca espera pela rede.
*
* $kind curto e estável (ip_ban, ip_unban, offence, login_failed,
* login_throttled, csrf_failed, mail_rate_limited): é por ele que o hub agrupa.
*/
function platform_security_event(?PDO $pdo, string $kind, ?string $detail = null,
?string $ip = null, array $data = []): void {
try {
$ip ??= platform_client_ip();
$ip = ($ip !== null && $ip !== '') ? $ip : null;
// Os detalhes das infracções trazem às vezes o email de quem submeteu
// (o antispam usa-o como assunto). Não tem de sair do site para o hub.
if ($detail !== null) {
$detail = preg_replace_callback('/[^\s@<>"\'(),;:]+@[^\s@<>"\'(),;:]+\.[a-z]{2,}/i',
static fn(array $m): string => hub_security_mask_identifier($m[0]), $detail);
}
$sink = platform_security_event_sink();
if ($sink !== null) {
$sink($pdo ?? (($GLOBALS['pdo'] ?? null) instanceof PDO ? $GLOBALS['pdo'] : null), $kind, $detail, $ip, $data);
return;
}
if (!hub_security_enabled()) return;
$pdo ??= hub_pdo();
if (!$pdo instanceof PDO) return;
hub_security_ensure_table($pdo);
$pdo->prepare(
'INSERT INTO hub_security_outbox (kind, ip, detail, data, occurred_at) VALUES (?, ?, ?, ?, ?)'
)->execute([
mb_substr($kind, 0, 40),
$ip,
$detail === null ? null : mb_substr($detail, 0, 190),
$data ? json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
time(),
]);
@touch(hub_security_stamp('dirty'));
} catch (Throwable $e) {
error_log('[hub-security] evento não registado: ' . $e->getMessage());
}
}
/** «a***@exemplo.pt» — chega para ver um ataque a uma conta sem mandar o email ao hub. */
function hub_security_mask_identifier(string $id): string {
$id = mb_strtolower(trim($id));
$at = strrpos($id, '@');
if ($at === false) return mb_substr($id, 0, 1) . '***';
return mb_substr($id, 0, 1) . '***' . substr($id, $at);
}
/**
* Chamado cedo em cada pedido (pelo platform_ip_guard). Só agenda: o trabalho
* corre no fim, depois de a resposta sair quando o SAPI o permite.
*/
function hub_security_maybe_sync(PDO $pdo): void {
static $registered = false;
if ($registered) return;
$registered = true;
register_shutdown_function(static function () use ($pdo): void {
try {
if (!hub_security_due() || !hub_security_enabled()) return;
if (function_exists('fastcgi_finish_request')) {
@fastcgi_finish_request();
}
hub_security_sync($pdo, 1);
} catch (Throwable $e) {
error_log('[hub-security] sincronização falhou: ' . $e->getMessage());
}
});
}
function hub_security_due(): bool {
$attempt = hub_security_stamp('attempt');
$last = @filemtime($attempt) ?: 0;
$every = is_file(hub_security_stamp('dirty')) && !is_file(hub_security_stamp('failing'))
? HUB_SECURITY_FLUSH_EVERY
: HUB_SECURITY_SYNC_EVERY;
if (time() - $last < $every) return false;
// Marcar a tentativa ANTES de a fazer: os pedidos concorrentes desistem, e
// um hub em baixo não é tentado a cada pedido. Sem pasta temporária
// gravável não há como saber, e fica tudo para o cron.
return @touch($attempt);
}
/**
* Envia a caixa de saída e aplica a lista da frota que vem na resposta.
* $maxBatches: 1 no fim de um pedido, mais no cron.
*/
function hub_security_sync(PDO $pdo, int $maxBatches = 25): array {
if (!hub_security_enabled()) return ['ok' => false, 'error' => 'plataforma não emparelhada com o hub'];
if (!function_exists('curl_init')) return ['ok' => false, 'error' => 'cURL indisponível'];
hub_security_ensure_table($pdo);
@touch(hub_security_stamp('attempt'));
// Um só sincronizador de cada vez, entre todos os processos PHP do site. O
// nome leva a base de dados: os locks do MariaDB são do servidor inteiro, e a
// frota partilha o servidor — sem isto a sincronização de um site fazia os
// outros desistir.
if ((int) $pdo->query("SELECT GET_LOCK(CONCAT('hub_security_sync:', DATABASE()), 0)")->fetchColumn() !== 1) {
return ['ok' => true, 'skipped' => 'já a correr'];
}
$out = ['ok' => true, 'sent' => 0, 'fleet' => null, 'error' => null];
try {
hub_security_trim_outbox($pdo);
for ($i = 0; $i < max(1, $maxBatches); $i++) {
$rows = $pdo->query(
'SELECT * FROM hub_security_outbox ORDER BY id LIMIT ' . HUB_SECURITY_BATCH
)->fetchAll(PDO::FETCH_ASSOC);
$events = [];
foreach ($rows as $r) {
$events[] = [
'kind' => $r['kind'],
'ip' => $r['ip'],
'detail' => $r['detail'],
'data' => $r['data'] !== null ? json_decode((string) $r['data'], true) : null,
'ts' => (int) $r['occurred_at'],
];
}
$res = hub_security_post(['events' => $events]);
if (!$res['ok']) {
if ($rows) {
$ids = implode(',', array_map('intval', array_column($rows, 'id')));
$pdo->exec("UPDATE hub_security_outbox SET attempts = attempts + 1 WHERE id IN ($ids)");
}
@touch(hub_security_stamp('failing'));
$out['ok'] = false;
$out['error'] = $res['error'];
error_log('[hub-security] envio falhou: ' . $res['error']);
break;
}
@unlink(hub_security_stamp('failing'));
if ($rows) {
$ids = implode(',', array_map('intval', array_column($rows, 'id')));
$pdo->exec("DELETE FROM hub_security_outbox WHERE id IN ($ids)");
$out['sent'] += count($rows);
}
if (is_array($res['data']['fleet'] ?? null)) {
$out['fleet'] = hub_security_apply_fleet($pdo, $res['data']['fleet']);
}
@touch(hub_security_stamp('synced'));
if (count($rows) < HUB_SECURITY_BATCH) {
// Contar de novo: um evento que entrou durante o envio tem de
// manter a marca, senão esperava pela sincronização lenta.
if ((int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn() === 0) {
@unlink(hub_security_stamp('dirty'));
}
break;
}
}
} finally {
$pdo->query("SELECT RELEASE_LOCK(CONCAT('hub_security_sync:', DATABASE()))");
}
return $out;
}
function hub_security_trim_outbox(PDO $pdo): void {
$n = (int) $pdo->query('SELECT COUNT(*) FROM hub_security_outbox')->fetchColumn();
if ($n <= HUB_SECURITY_OUTBOX_MAX) return;
$excess = $n - HUB_SECURITY_OUTBOX_MAX;
$pdo->exec(
"DELETE FROM hub_security_outbox WHERE kind NOT IN ('ip_ban', 'ip_unban')
ORDER BY id LIMIT " . (int) $excess
);
error_log("[hub-security] caixa de saída acima do tecto — $excess eventos antigos descartados");
}
/** POST ao hub. ['ok'=>bool, 'data'=>array|null, 'error'=>string]. */
function hub_security_post(array $payload): array {
$ch = curl_init(hub_url() . '/api/security_events.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
// Curto: no pior caso isto corre no fim de um pedido de um visitante.
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_TIMEOUT => 5,
// X-Hub-Key e não Authorization: o proxy come o Authorization (ver functions_hub.php).
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json',
'X-Hub-Key: ' . hub_api_key()],
CURLOPT_USERAGENT => 'andmore-platform-security/1.0',
]);
$raw = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false || $err !== '') return ['ok' => false, 'data' => null, 'error' => $err ?: 'sem resposta'];
$data = json_decode((string) $raw, true);
if (!is_array($data)) return ['ok' => false, 'data' => null, 'error' => "resposta não-JSON (HTTP $http)"];
if (empty($data['ok'])) return ['ok' => false, 'data' => $data, 'error' => (string) ($data['error'] ?? "HTTP $http")];
return ['ok' => true, 'data' => $data, 'error' => ''];
}
/**
* Aplica a lista da frota ao ip_bans local.
*
* Um banimento próprio activo ganha sempre à cópia — foi decidido aqui, com o
* motivo daqui. As cópias que o hub deixou de mandar (levantadas ou expiradas)
* saem, mas só quando a lista vem completa: uma lista truncada não diz nada
* sobre o que ficou de fora.
*/
function hub_security_apply_fleet(PDO $pdo, array $fleet): array {
require_once __DIR__ . '/functions_ip_ban.php';
platform_ip_ban_ensure_tables($pdo);
$local = [];
foreach ($pdo->query(
"SELECT ip, source, reason, expires_at,
(expires_at IS NULL OR expires_at > NOW()) AS active
FROM ip_bans
WHERE source = 'fleet' OR expires_at IS NULL OR expires_at > NOW()"
) as $r) {
$local[$r['ip']] = $r;
}
$upsert = $pdo->prepare(
"INSERT INTO ip_bans (ip, reason, source, expires_at, created_by) VALUES (?, ?, 'fleet', ?, NULL)
ON DUPLICATE KEY UPDATE reason = VALUES(reason), source = 'fleet',
expires_at = VALUES(expires_at), created_at = NOW(), created_by = NULL"
);
$seen = [];
$applied = 0;
foreach ((array) ($fleet['bans'] ?? []) as $b) {
$ip = trim((string) ($b['ip'] ?? ''));
if (!filter_var($ip, FILTER_VALIDATE_IP) || platform_ip_ban_exempt($ip)) continue;
$seen[$ip] = true;
$cur = $local[$ip] ?? null;
if ($cur !== null && $cur['source'] !== 'fleet' && (int) $cur['active'] === 1) continue;
$ts = isset($b['expires_ts']) ? (int) $b['expires_ts'] : null;
$expires = $ts ? date('Y-m-d H:i:s', $ts) : null;
$reason = mb_substr('frota · ' . (string) ($b['origin'] ?? '?') . ' · ' . (string) ($b['reason'] ?? ''), 0, 190);
if ($cur !== null && $cur['source'] === 'fleet'
&& $cur['reason'] === $reason && $cur['expires_at'] === $expires) {
continue;
}
try {
$upsert->execute([$ip, $reason, $expires]);
$applied++;
} catch (Throwable $e) {
error_log('[hub-security] cópia de banimento não aplicada: ' . $e->getMessage());
}
}
$removed = 0;
if (!empty($fleet['complete'])) {
$del = $pdo->prepare("DELETE FROM ip_bans WHERE ip = ? AND source = 'fleet'");
foreach ($local as $ip => $r) {
if ($r['source'] === 'fleet' && !isset($seen[$ip])) {
$del->execute([$ip]);
$removed++;
}
}
}
return ['received' => count($seen), 'applied' => $applied, 'removed' => $removed];
}
/**
* `checks.security` para o health do hub. A pergunta é «o envio está a
* funcionar?»: eventos presos há mais de uma hora querem dizer que não.
*/
function hub_security_check(PDO $pdo): array {
if (!hub_security_enabled()) return [];
hub_security_ensure_table($pdo);
$row = $pdo->query('SELECT COUNT(*) n, MIN(occurred_at) oldest FROM hub_security_outbox')
->fetch(PDO::FETCH_ASSOC);
$pending = (int) ($row['n'] ?? 0);
$oldest = $row['oldest'] !== null ? time() - (int) $row['oldest'] : 0;
$fleet = 0;
try {
$fleet = (int) $pdo->query(
"SELECT COUNT(*) FROM ip_bans WHERE source = 'fleet' AND (expires_at IS NULL OR expires_at > NOW())"
)->fetchColumn();
} catch (Throwable) { /* ip_bans ainda por criar */ }
$synced = @filemtime(hub_security_stamp('synced')) ?: null;
$detail = $pending . ' por enviar'
. ($synced ? ' · sincronizado há ' . max(0, (int) round((time() - $synced) / 60)) . ' min' : ' · nunca sincronizou')
. ' · ' . $fleet . ' banimento(s) da frota';
return [
'ok' => $oldest < 3600,
'detail' => $detail,
'pending' => $pending,
'oldest_seconds' => $oldest,
'fleet_bans' => $fleet,
'synced_at' => $synced ? date('c', $synced) : null,
];
}