diff --git a/application/controllers/ServicesController.php b/application/controllers/ServicesController.php index 0fddd94..c56e0b5 100644 --- a/application/controllers/ServicesController.php +++ b/application/controllers/ServicesController.php @@ -2401,4 +2401,214 @@ class ServicesController extends BaseController ]; return $map[$s] ?? null; } + + // ══════════════════════════════════════════════════════════════════════ + // EVIDENCE AUTOMATION + CONTINUOUS CONTROL MONITORING (P1) + // Colma il gap vs Vanta/Drata: i connettori (M365, AWS, EDR, ...) inviano + // evidenze automatiche per i controlli; lo stato/freschezza è ricalcolato. + // ══════════════════════════════════════════════════════════════════════ + + /** + * POST /api/services/evidence-ingest + * Auth: X-API-Key con scope ingest:evidence + * Body JSON (singola evidenza o batch via "evidences":[...]): + * { + * "control_code": "ART21_2_A", // controllo target (obbligatorio) + * "source": "m365|google|aws|azure|idp|edr|siem|api", + * "source_system": "Microsoft 365", + * "status": "pass|fail|warning|not_applicable", + * "summary": "MFA attiva per 142/142 utenti admin", + * "external_ref": "m365-mfa-check-2026-05-30", // idempotenza + * "payload": { ... }, // dettaglio raw (opzionale) + * "collected_at": "2026-05-30T06:00:00Z", + * "valid_for_days": 7 // default freshness_days del controllo + * } + */ + public function ingestEvidence(): void + { + $this->requireApiKey('ingest:evidence'); + $orgId = $this->currentOrgId; + $body = $this->getJsonBody(); + + // Supporta batch + $items = isset($body['evidences']) && is_array($body['evidences']) ? $body['evidences'] : [$body]; + if (count($items) > 200) { + $this->jsonError('Massimo 200 evidenze per richiesta', 422, 'BATCH_TOO_LARGE'); + } + + $validSource = ['m365', 'google', 'aws', 'azure', 'idp', 'edr', 'siem', 'api', 'manual']; + $validStatus = ['pass', 'fail', 'warning', 'not_applicable']; + + $results = []; + $touchedControls = []; + $accepted = 0; + + foreach ($items as $i => $ev) { + if (!is_array($ev)) { $results[] = ['index' => $i, 'ok' => false, 'error' => 'not_an_object']; continue; } + $code = trim((string) ($ev['control_code'] ?? '')); + if ($code === '') { $results[] = ['index' => $i, 'ok' => false, 'error' => 'control_code mancante']; continue; } + + $source = strtolower((string) ($ev['source'] ?? 'api')); + if (!in_array($source, $validSource, true)) $source = 'api'; + $status = strtolower((string) ($ev['status'] ?? 'pass')); + if (!in_array($status, $validStatus, true)) $status = 'pass'; + + $collectedTs = strtotime((string) ($ev['collected_at'] ?? 'now')) ?: time(); + $collectedAt = date('Y-m-d H:i:s', $collectedTs); + + // freshness: dal body o dal controllo (default 30) + $ctl = Database::fetchOne( + 'SELECT id, freshness_days FROM compliance_controls WHERE organization_id = ? AND control_code = ?', + [$orgId, $code] + ); + $validDays = isset($ev['valid_for_days']) ? max(1, (int) $ev['valid_for_days']) + : (int) ($ctl['freshness_days'] ?? 30); + $validUntil = date('Y-m-d H:i:s', $collectedTs + $validDays * 86400); + + $externalRef = isset($ev['external_ref']) ? substr(trim((string) $ev['external_ref']), 0, 190) : null; + $payload = isset($ev['payload']) ? json_encode($ev['payload'], JSON_UNESCAPED_UNICODE) : null; + $summary = isset($ev['summary']) ? substr(trim((string) $ev['summary']), 0, 255) : null; + $sourceSystem = isset($ev['source_system']) ? substr(trim((string) $ev['source_system']), 0, 120) : null; + + $data = [ + 'organization_id' => $orgId, + 'control_code' => $code, + 'source' => $source, + 'source_system' => $sourceSystem, + 'status' => $status, + 'summary' => $summary, + 'payload' => $payload, + 'external_ref' => $externalRef, + 'collected_at' => $collectedAt, + 'valid_until' => $validUntil, + ]; + + // Upsert idempotente su (org, control_code, external_ref) + try { + if ($externalRef !== null) { + $existing = Database::fetchOne( + 'SELECT id FROM control_evidence_auto WHERE organization_id = ? AND control_code = ? AND external_ref = ?', + [$orgId, $code, $externalRef] + ); + if ($existing) { + Database::query( + 'UPDATE control_evidence_auto SET source=?, source_system=?, status=?, summary=?, payload=?, collected_at=?, valid_until=? WHERE id=?', + [$source, $sourceSystem, $status, $summary, $payload, $collectedAt, $validUntil, $existing['id']] + ); + $eviId = (int) $existing['id']; + } else { + $eviId = Database::insert('control_evidence_auto', $data); + } + } else { + $eviId = Database::insert('control_evidence_auto', $data); + } + } catch (Throwable $e) { + $results[] = ['index' => $i, 'ok' => false, 'error' => 'db_error']; + error_log('[EVIDENCE] ' . $e->getMessage()); + continue; + } + + $accepted++; + $results[] = ['index' => $i, 'ok' => true, 'id' => $eviId, 'control_code' => $code, 'control_exists' => (bool) $ctl]; + if ($ctl) $touchedControls[$code] = true; + } + + // Ricalcola lo stato di monitoraggio dei controlli toccati + $monitoring = []; + foreach (array_keys($touchedControls) as $code) { + $monitoring[$code] = $this->recomputeControlMonitoring($orgId, $code); + } + + $this->jsonSuccess([ + 'accepted' => $accepted, + 'total' => count($items), + 'results' => $results, + 'monitoring_update' => $monitoring, + ], 'Evidenze elaborate', 201); + } + + /** + * Ricalcola monitoring_status + last_checked_at di un controllo in base + * all'ultima evidenza automatica. Restituisce lo stato calcolato. + */ + private function recomputeControlMonitoring(int $orgId, string $code): string + { + $last = Database::fetchOne( + 'SELECT status, collected_at, valid_until FROM control_evidence_auto + WHERE organization_id = ? AND control_code = ? + ORDER BY collected_at DESC LIMIT 1', + [$orgId, $code] + ); + if (!$last) return 'not_monitored'; + + $now = time(); + $isStale = $last['valid_until'] && strtotime($last['valid_until']) < $now; + + if ($isStale) { + $monStatus = 'stale'; + } elseif ($last['status'] === 'fail') { + $monStatus = 'failing'; + } elseif ($last['status'] === 'warning') { + $monStatus = 'warning'; + } else { // pass / not_applicable + $monStatus = 'healthy'; + } + + Database::query( + 'UPDATE compliance_controls SET monitoring_status = ?, last_checked_at = ? WHERE organization_id = ? AND control_code = ?', + [$monStatus, $last['collected_at'], $orgId, $code] + ); + return $monStatus; + } + + /** + * GET /api/services/controls-monitoring + * Auth: X-API-Key con scope read:compliance + * Stato del monitoraggio continuo: controlli con freschezza evidenza + * (healthy/warning/stale/failing/not_monitored). + */ + public function controlsMonitoring(): void + { + $this->requireApiKey('read:compliance'); + $orgId = $this->currentOrgId; + + // Auto-stale: controlli la cui ultima evidenza è scaduta vanno marcati stale + Database::query( + "UPDATE compliance_controls cc + LEFT JOIN ( + SELECT t.control_code, t.valid_until, t.status + FROM control_evidence_auto t + JOIN (SELECT control_code, MAX(collected_at) mx FROM control_evidence_auto + WHERE organization_id = ? GROUP BY control_code) m + ON m.control_code = t.control_code AND m.mx = t.collected_at + WHERE t.organization_id = ? + ) last ON last.control_code = cc.control_code + SET cc.monitoring_status = 'stale' + WHERE cc.organization_id = ? + AND cc.monitoring_status NOT IN ('not_monitored') + AND last.valid_until IS NOT NULL AND last.valid_until < NOW()", + [$orgId, $orgId, $orgId] + ); + + $rows = Database::fetchAll( + 'SELECT control_code, title, status, monitoring_status, last_checked_at, freshness_days, implementation_percentage + FROM compliance_controls WHERE organization_id = ? ORDER BY control_code', + [$orgId] + ); + + $summary = ['healthy' => 0, 'warning' => 0, 'stale' => 0, 'failing' => 0, 'not_monitored' => 0]; + foreach ($rows as $r) { + $ms = $r['monitoring_status'] ?? 'not_monitored'; + if (isset($summary[$ms])) $summary[$ms]++; + } + $monitored = count($rows) - $summary['not_monitored']; + + $this->jsonSuccess([ + 'total_controls' => count($rows), + 'monitored' => $monitored, + 'coverage_percent' => count($rows) ? (int) round($monitored * 100 / count($rows)) : 0, + 'summary' => $summary, + 'controls' => $rows, + ]); + } } diff --git a/docs/sql/024_evidence_automation.sql b/docs/sql/024_evidence_automation.sql new file mode 100644 index 0000000..9ad845a --- /dev/null +++ b/docs/sql/024_evidence_automation.sql @@ -0,0 +1,59 @@ +-- ============================================================================ +-- Migration 024 - Evidence Automation + Continuous Control Monitoring (P1) +-- ---------------------------------------------------------------------------- +-- Colma il gap competitivo vs Vanta/Drata: raccolta automatica evidenze di +-- conformita (NIS2 Art.21) dai connettori esterni e monitoraggio continuo +-- della freschezza/stato dei controlli. +-- +-- 1) Tabella control_evidence_auto: ogni evidenza raccolta da un collector +-- esterno (M365, AWS, EDR, ...) per uno specifico control_code. +-- 2) compliance_controls += monitoring_status, last_checked_at, freshness_days. +-- +-- Idempotente via information_schema. Rilanciabile. +-- mysql -h localhost nis2_agile_db -e "source docs/sql/024_evidence_automation.sql" +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS control_evidence_auto ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + organization_id INT NOT NULL, + control_code VARCHAR(50) NOT NULL COMMENT 'Codice controllo (compliance_controls.control_code)', + source ENUM('m365','google','aws','azure','idp','edr','siem','api','manual') NOT NULL DEFAULT 'api', + source_system VARCHAR(120) NULL COMMENT 'Sistema sorgente (es. Microsoft 365, CrowdStrike)', + status ENUM('pass','fail','warning','not_applicable') NOT NULL DEFAULT 'pass' COMMENT 'Esito del check automatico', + summary VARCHAR(255) NULL COMMENT 'Sintesi leggibile evidenza', + payload JSON NULL COMMENT 'Dettaglio raw evidenza (oggetto del check)', + external_ref VARCHAR(190) NULL COMMENT 'ID check esterno (idempotenza per re-collect)', + collected_at DATETIME NOT NULL COMMENT 'Quando il collector ha raccolto evidenza', + valid_until DATETIME NULL COMMENT 'Scadenza freschezza evidenza (oltre = stale)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_evi_org_control (organization_id, control_code), + KEY idx_evi_collected (organization_id, collected_at), + UNIQUE KEY uq_evi_external (organization_id, control_code, external_ref) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Evidenze raccolte automaticamente dai connettori (Continuous Control Monitoring)'; + +-- Colonne monitoraggio continuo su compliance_controls +DELIMITER // +DROP PROCEDURE IF EXISTS _mig024_add_col // +CREATE PROCEDURE _mig024_add_col(IN col VARCHAR(64), IN ddl TEXT) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'compliance_controls' AND COLUMN_NAME = col + ) THEN + SET @sql = CONCAT('ALTER TABLE compliance_controls ADD COLUMN ', ddl); + PREPARE st FROM @sql; EXECUTE st; DEALLOCATE PREPARE st; + END IF; +END // +DELIMITER ; + +CALL _mig024_add_col('monitoring_status', "monitoring_status ENUM('not_monitored','healthy','warning','stale','failing') NOT NULL DEFAULT 'not_monitored' COMMENT 'Stato monitoraggio continuo (calcolato da evidenze automatiche)' AFTER status"); +CALL _mig024_add_col('last_checked_at', "last_checked_at DATETIME NULL COMMENT 'Ultima evidenza automatica ricevuta' AFTER last_verified_at"); +CALL _mig024_add_col('freshness_days', "freshness_days SMALLINT NOT NULL DEFAULT 30 COMMENT 'Giorni entro cui un controllo deve essere ri-verificato da evidenza automatica'"); + +DROP PROCEDURE IF EXISTS _mig024_add_col; + +-- ROLLBACK: +-- DROP TABLE IF EXISTS control_evidence_auto; +-- ALTER TABLE compliance_controls DROP COLUMN monitoring_status, DROP COLUMN last_checked_at, DROP COLUMN freshness_days; diff --git a/public/index.php b/public/index.php index f0a3ae0..b9d6699 100644 --- a/public/index.php +++ b/public/index.php @@ -349,6 +349,8 @@ $actionMap = [ 'GET:risksFeed' => 'risksFeed', 'GET:incidentsFeed' => 'incidentsFeed', 'POST:incidentsIngest' => 'ingestIncident', // P1: ingestion alert SIEM/SOC/EDR + 'POST:evidenceIngest' => 'ingestEvidence', // P1: evidence automation (collector connettori) + 'GET:controlsMonitoring' => 'controlsMonitoring', // P1: continuous control monitoring 'GET:controlsStatus' => 'controlsStatus', 'GET:assetsCritical' => 'assetsCritical', 'GET:suppliersRisk' => 'suppliersRisk',