[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
co-authored by Claude Opus 4.8
parent 21909994c2
commit 307993fbad
3 changed files with 271 additions and 0 deletions
@@ -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,
]);
}
}