[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:
parent
21909994c2
commit
307993fbad
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
59
docs/sql/024_evidence_automation.sql
Normal file
59
docs/sql/024_evidence_automation.sql
Normal 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;
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user