[SEC+UX] Hardening sicurezza + miglioramenti UX pre-audit

SICUREZZA:
- index.php: rimosso CORS wildcard in debug mode (solo origini autorizzate)
- AuthController: getClientIP() con X-Forwarded-For sicuro (proxy-aware)
- AuthController: refresh token con SELECT FOR UPDATE in transazione atomica
- AIService: anonimizzazione dati org nei prompt Anthropic API (no nome/fatturato)

UX AUDIT-READY:
- dashboard.html: gauge rinominato 'Avanzamento implementazione misure Art.21'
- incidents.html: decision tree Art.23 con 5 criteri per 'Is Significant?'
- policies.html: banner warning obbligatorio su bozze generate da AI
- risks.html: tooltip dettagliati scala Likelihood/Impact (ISO 27005)
- assessment.html: progress bar % completamento risposta domande

DB:
- migration 006: indici performance + audit_log immutabile (trigger) + soft delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-02-20 12:01:33 +01:00
co-authored by Claude Sonnet 4.6
parent 0e78ec24c1
commit 782389849f
9 changed files with 282 additions and 39 deletions
+47 -18
View File
@@ -10,15 +10,34 @@ require_once APP_PATH . '/services/RateLimitService.php';
class AuthController extends BaseController
{
/**
* Restituisce l'IP reale del client, gestendo proxy/nginx.
*/
private function getClientIP(): string
{
$remoteAddr = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
// Fidati di X-Forwarded-For solo se la richiesta arriva da localhost (nginx proxy)
if (in_array($remoteAddr, ['127.0.0.1', '::1', 'unknown'])
&& !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$xForwardedFor = $_SERVER['HTTP_X_FORWARDED_FOR'];
$ips = array_map('trim', explode(',', $xForwardedFor));
$firstIp = filter_var($ips[0], FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($firstIp !== false) {
return $firstIp;
}
}
return $remoteAddr;
}
/**
* POST /api/auth/register
*/
public function register(): void
{
// Rate limiting
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$ip = $this->getClientIP();
RateLimitService::check("register:{$ip}", RATE_LIMIT_AUTH_REGISTER);
RateLimitService::increment("register:{$ip}");
RateLimitService::increment("register:{$ip}"); // $ip defined above via getClientIP()
$this->validateRequired(['email', 'password', 'full_name']);
@@ -87,7 +106,7 @@ class AuthController extends BaseController
public function login(): void
{
// Rate limiting
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$ip = $this->getClientIP();
RateLimitService::check("login:{$ip}", RATE_LIMIT_AUTH_LOGIN);
RateLimitService::increment("login:{$ip}");
@@ -167,24 +186,34 @@ class AuthController extends BaseController
$refreshToken = $this->getParam('refresh_token');
$hashedToken = hash('sha256', $refreshToken);
// Verifica refresh token
$tokenRecord = Database::fetchOne(
'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW()',
[$hashedToken]
);
// Transazione atomica per evitare race condition (double-spend del refresh token)
Database::beginTransaction();
try {
// SELECT FOR UPDATE: blocca il record per tutta la transazione
$tokenRecord = Database::fetchOne(
'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW() FOR UPDATE',
[$hashedToken]
);
if (!$tokenRecord) {
$this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN');
if (!$tokenRecord) {
Database::rollback();
$this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN');
}
// Elimina vecchio token atomicamente
Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]);
// Genera nuovi tokens
$userId = (int) $tokenRecord['user_id'];
$accessToken = $this->generateJWT($userId);
$newRefreshToken = $this->generateRefreshToken($userId);
Database::commit();
} catch (Throwable $e) {
Database::rollback();
throw $e;
}
// Elimina vecchio token
Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]);
// Genera nuovi tokens
$userId = (int) $tokenRecord['user_id'];
$accessToken = $this->generateJWT($userId);
$newRefreshToken = $this->generateRefreshToken($userId);
$this->jsonSuccess([
'access_token' => $accessToken,
'refresh_token' => $newRefreshToken,
+19 -6
View File
@@ -35,17 +35,18 @@ class AIService
{
$responseSummary = $this->summarizeResponses($responses);
// Anonimizzazione: non inviare nome org né fatturato esatto ad API esterna
$employeeRange = $this->employeeRange((int)($organization['employee_count'] ?? 0));
$prompt = <<<PROMPT
Sei un esperto consulente di cybersecurity specializzato nella Direttiva NIS2 (EU 2022/2555) e nel D.Lgs. 138/2024 italiano.
Analizza i risultati della gap analysis per l'organizzazione seguente e fornisci raccomandazioni dettagliate.
## Organizzazione
- Nome: {$organization['name']}
## Organizzazione (dati anonimizzati)
- Settore: {$organization['sector']}
- Tipo entità NIS2: {$organization['entity_type']}
- Dipendenti: {$organization['employee_count']}
- Fatturato annuo: EUR {$organization['annual_turnover_eur']}
- Dimensione: {$employeeRange}
## Risultati Assessment (Score: {$overallScore}%)
@@ -131,8 +132,7 @@ PROMPT;
$prompt = <<<PROMPT
Sei un esperto di information security policy writing. Genera una policy aziendale per la categoria "{$category}" conforme alla Direttiva NIS2.
## Organizzazione
- Nome: {$organization['name']}
## Organizzazione (dati anonimizzati)
- Settore: {$organization['sector']}
- Tipo entità NIS2: {$organization['entity_type']}
@@ -245,6 +245,19 @@ PROMPT;
return $data['content'][0]['text'];
}
/**
* Converte numero dipendenti in range anonimizzato
*/
private function employeeRange(int $count): string
{
if ($count <= 0) return 'Non specificato';
if ($count <= 10) return 'Micro impresa (1-10 dipendenti)';
if ($count <= 50) return 'Piccola impresa (11-50 dipendenti)';
if ($count <= 250) return 'Media impresa (51-250 dipendenti)';
if ($count <= 1000) return 'Grande impresa (251-1000 dipendenti)';
return 'Grande organizzazione (>1000 dipendenti)';
}
/**
* Riassume le risposte dell'assessment per il prompt AI
*/