[FEAT] Evidence Automation + Continuous Control Monitoring (P1)

Colma il gap competitivo vs Vanta/Drata (compliance automation):
- ServicesController::ingestEvidence -> POST /services/evidence-ingest (scope ingest:evidence)
  raccolta evidenze automatiche dai connettori (M365/Google/AWS/Azure/IdP/EDR/SIEM), batch fino a 200, upsert idempotente su external_ref
- recomputeControlMonitoring: ricalcolo monitoring_status (healthy/warning/stale/failing) per freschezza+esito
- controlsMonitoring -> GET /services/controls-monitoring (scope read:compliance): coverage + summary semafori
- Migrazione 024: tabella control_evidence_auto + compliance_controls.{monitoring_status,last_checked_at,freshness_days}
- Route POST:evidenceIngest, GET:controlsMonitoring

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-30 08:55:26 +02:00
parent 21909994c2
commit 307993fbad
3 changed files with 271 additions and 0 deletions

View File

@ -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,
]);
}
}

View File

@ -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;

View File

@ -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',