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>
This commit is contained in:
andreandClaude Opus 5.5 committed 2026-09-25 07:28:13 +01:00
1 parent 5e300fc341
commit 0e8b53f620
65 files changed
+5852 -455

No files matched your search

@@ -25,8 +25,13 @@
// Nada disto se aplica a endereços internos: atrás do NPM o contentor fala
// consigo próprio para cron e health checks, e auto-banir-se seria uma avaria
// difícil de diagnosticar.
//
// Com o site emparelhado com o hub, os banimentos e as infracções seguem para lá
// e os banimentos dos outros sites chegam cá com source='fleet' — ver
// functions_security_events.php.
require_once __DIR__ . '/functions_client_ip.php';
require_once __DIR__ . '/functions_security_events.php';
/** [infracções em 24h => minutos de banimento]. null = permanente. */
const IP_BAN_LADDER = [3 => 60, 5 => 1440, 8 => 10080, 12 => null];
@@ -123,6 +128,13 @@ function platform_ip_ban(PDO $pdo, string $ip, string $reason, ?int $minutes = 6
expires_at = VALUES(expires_at), created_at = NOW(), created_by = VALUES(created_by)'
)->execute([$ip, mb_substr($reason, 0, 190), $source, $expires, $userId]);
error_log("[ip-ban] $ip banido ($source): $reason" . ($expires ? " até $expires" : ' permanentemente'));
// As cópias da frota não voltam ao hub: ecoavam em ciclo.
if ($source !== 'fleet') {
platform_security_event($pdo, 'ip_ban', $reason, $ip, [
'source' => $source,
'expires_ts' => $minutes === null ? null : time() + $minutes * 60,
]);
}
return true;
} catch (Throwable $e) {
error_log('[ip-ban] não conseguiu banir: ' . $e->getMessage());
@@ -134,6 +146,9 @@ function platform_ip_unban(PDO $pdo, string $ip): bool
{
try {
platform_ip_ban_ensure_tables($pdo);
// Levanta também na frota — incluindo quando isto era uma cópia vinda de
// outro site. Sem isso a sincronização seguinte voltava a pô-la cá.
platform_security_event($pdo, 'ip_unban', null, $ip);
return $pdo->prepare('DELETE FROM ip_bans WHERE ip = ?')->execute([$ip]);
} catch (Throwable $e) {
error_log('[ip-ban] não conseguiu levantar: ' . $e->getMessage());
@@ -161,6 +176,7 @@ function platform_ip_record_offence(PDO $pdo, string $kind, ?string $detail = nu
platform_ip_ban_ensure_tables($pdo);
$pdo->prepare('INSERT INTO ip_offences (ip, kind, detail) VALUES (?, ?, ?)')
->execute([$ip, mb_substr($kind, 0, 40), $detail === null ? null : mb_substr($detail, 0, 190)]);
platform_security_event($pdo, 'offence', $kind . ($detail !== null && $detail !== '' ? ' · ' . $detail : ''), $ip);
$st = $pdo->prepare('SELECT COUNT(*) FROM ip_offences WHERE ip = ? AND created_at >= NOW() - INTERVAL 1 DAY');
$st->execute([$ip]);
@@ -194,6 +210,8 @@ function platform_ip_record_offence(PDO $pdo, string $kind, ?string $detail = nu
*/
function platform_ip_guard(PDO $pdo, ?string $contactEmail = null): void
{
hub_security_maybe_sync($pdo);
$ban = platform_ip_banned($pdo);
if ($ban === null) {
return;