Checks\CoreIntegrityCheck::class, CheckCatalogue::FILE_PERMISSIONS => Checks\FilePermissionsCheck::class, CheckCatalogue::SUSPICIOUS_FILES => Checks\SuspiciousFilesCheck::class, CheckCatalogue::DATABASE_PREFIX => Checks\DatabasePrefixCheck::class, CheckCatalogue::ADMIN_USERS => Checks\AdminUsersCheck::class, CheckCatalogue::SECURITY_HEADERS => Checks\SecurityHeadersCheck::class, CheckCatalogue::FILE_EDITING => Checks\FileEditingCheck::class, CheckCatalogue::DEBUG_MODE => Checks\DebugModeCheck::class, CheckCatalogue::SSL => Checks\SslCheck::class, CheckCatalogue::PHP_VERSION => Checks\PhpVersionCheck::class, CheckCatalogue::CONFIG_FILE => Checks\WpConfigCheck::class, CheckCatalogue::LOGIN_SECURITY => Checks\LoginSecurityCheck::class, CheckCatalogue::API_EXPOSURE => Checks\RestApiExposureCheck::class, CheckCatalogue::LEGACY_RPC => Checks\XmlRpcCheck::class, CheckCatalogue::DIRECTORY_LISTING => Checks\DirectoryListingCheck::class, CheckCatalogue::SENSITIVE_FILES => Checks\SensitiveFilesCheck::class, CheckCatalogue::AUTHOR_ENUMERATION => Checks\AuthorEnumerationCheck::class, CheckCatalogue::OBSOLETE_VERSIONS => Checks\ObsoleteVersionsCheck::class, CheckCatalogue::EXTENSION_VULNERABILITIES => Checks\PluginVulnerabilityCheck::class, ]; /** ID of the row inserted by the most recent runScan() call. */ private ?int $lastScanId = null; public function getLastScanId(): ?int { return $this->lastScanId; } /** The checks this agent implements, with the catalogue's shared weights. */ public static function profile(): ScanProfile { return ScanProfile::of(array_keys(self::BINDINGS)); } public function runFullScan(): array { return $this->runScan(self::profile(), 'full'); } public function runQuickScan(): array { return $this->runScan(self::profile()->quick(), 'quick'); } private function runScan(ScanProfile $profile, string $type): array { $repo = new WpScanRepository(); $startedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $scanId = $repo->startScan($type); $this->lastScanId = $scanId; Logger::info('scanner', "Scan {$type} started (ID: {$scanId})"); try { $results = []; foreach ($profile->ids() as $checkId) { $checkClass = self::BINDINGS[$checkId]; try { $check = new $checkClass(); $result = $check->run(); $results[] = $result; } catch (\Throwable $e) { Logger::error('scanner', 'Check failed: ' . $checkId . ' - ' . $e->getMessage()); // Reported under the CATALOGUE id, not the PHP class basename: the // backend keys internal checks by name, so a crashed check used to // appear as a different entry ("CoreIntegrityCheck") than a healthy // one ("core_integrity"). $results[] = new CheckResult( $checkId, 'skip', 0, $profile->maxScore($checkId), 'low', ['error' => $e->getMessage()], 'Check failed: ' . $e->getMessage() ); } } $scoreData = ScoreCalculator::calculate($results); // Counting, bucketing and detail assembly are pure and identical on every // platform — and load-bearing: `critical_issues > 0` is what raises the alert // below, so an agent that bucketed severities differently would alert // differently for the same findings. $summary = ScanAggregator::aggregate($results); $passed = $summary['checks_passed']; $warned = $summary['checks_warned']; $failed = $summary['checks_failed']; $skipped = $summary['checks_skipped']; $critical = $summary['critical_issues']; $high = $summary['high_issues']; $medium = $summary['medium_issues']; $low = $summary['low_issues']; $details = $summary['details']; $vulnerabilities = $summary['vulnerabilities']; $repo->completeScan($scanId, [ 'score' => $scoreData['score'], 'checks_passed' => $passed, 'checks_warned' => $warned, 'checks_failed' => $failed, 'checks_skipped' => $skipped, 'critical_issues' => $critical, 'high_issues' => $high, 'medium_issues' => $medium, 'low_issues' => $low, 'details' => $details, 'vulnerabilities' => $vulnerabilities, ]); Logger::info('scanner', "Scan {$type} completed (ID: {$scanId}) — Score: {$scoreData['score']}"); $completedAt = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); $sender = new WebhookSender(); // SENTINELO-NOTE: backend stores 'details' verbatim as SecurityScan::internalChecks // and 'vulnerabilities' as SecurityScan::vulnerabilities. Both are required for the // multi-layer score recomputation — never trim them out of the payload. $sender->send('security.scan_complete', SecurityScanCompleteEvent::toPayload([ 'id' => $scanId, 'type' => $type, 'score' => $scoreData['score'], 'grade' => $scoreData['label'] ?? null, 'checks_passed' => $passed, 'checks_warned' => $warned, 'checks_failed' => $failed, 'checks_skipped' => $skipped, 'critical_issues' => $critical, 'high_issues' => $high, 'medium_issues' => $medium, 'low_issues' => $low, 'vulnerabilities_count' => count($vulnerabilities), 'details' => $details, 'vulnerabilities' => $vulnerabilities, // Same wire convention as /security/report: ISO 8601 with offset, and a // duration that is actually measured rather than a permanent null. 'started_at' => $startedAt->format(Timestamps::WIRE_FORMAT), 'completed_at' => $completedAt->format(Timestamps::WIRE_FORMAT), 'duration_seconds' => $completedAt->getTimestamp() - $startedAt->getTimestamp(), ])); $repo->markWebhookSent($scanId); if (ScanAggregator::warrantsAlert((int) $scoreData['score'], $critical)) { $sender->send('security.alert', SecurityAlertEvent::toPayload([ 'severity' => ScanAggregator::alertSeverity($critical), 'type' => 'scan_low_score', 'message' => "Score de sécurité critique: {$scoreData['score']}/100", 'details' => ['scan_id' => $scanId, 'critical_issues' => $critical], ])); } return $scoreData; } catch (\Throwable $e) { Logger::error('scanner', "Scan {$type} failed (ID: {$scanId}): " . $e->getMessage()); $repo->failScan($scanId, $e->getMessage()); // Fast-path failure notice, symmetric with backup.failed. The backend has // always handled this event; nothing ever emitted it, so a failed scan was // only discovered when the poller happened to come round — or, on the // timeout path, never reported as anything but "expired". (new WebhookSender())->send('security.scan_failed', [ 'scan_id' => $scanId, 'type' => $type, 'status' => 'failed', 'error' => $e->getMessage(), 'error_message' => $e->getMessage(), 'started_at' => $startedAt->format(Timestamps::WIRE_FORMAT), 'completed_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(Timestamps::WIRE_FORMAT), ]); $repo->markWebhookSent($scanId); throw $e; } } }