• File: VulnerabilityStorageRepository.php
  • Full Path: /home/docteuh/www/Vulnerability/Repositories/VulnerabilityStorageRepository.php
  • Date Modified: 06/18/2026 4:25 PM
  • File size: 16.66 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php

declare(strict_types=1);

namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories;

use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\ComponentVulnerabilitiesDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\HighestSeverityContextDto;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Policies\ConfigurableSeverityPolicy;


/**
 * Persist and read vulnerability data using the WordPress options API.
 *
 * Responsibilities:
 * - Store and retrieve component vulnerability payloads keyed by a storage key
 *   (e.g. "plugin:contact-form-7" or "theme:twentytwenty").
 * - Provide aggregation helpers (highest severity, counts, snapshot).
 *
 * Constraints:
 * - No external I/O (network, files) beyond WordPress options.
 * - No business rules beyond simple aggregation and shaping for consumers.
 *
 * Notes for maintainers:
 * - Stored component format is an array with keys such as: `type`, `slug`,
 *   `name`, `latestVersion`, `hasUpdate`, and `vulnerabilities` \(an array\).
 * - `vulnerabilities` is an array of associative arrays where each entry
 *   typically includes at minimum `severity` and `lookup` (UUID).
 */
final class VulnerabilityStorageRepository
{
    private const OPTION_KEY = 'rss_vulnerabilities';

    /**
     * Store or replace vulnerabilities for a single component.
     *
     * The DTO is converted to an array and stored under the DTO's storage key.
     * This replaces any existing entry for that key.
     *
     * Example storage key: "plugin:contact-form-7"
     *
     * @param ComponentVulnerabilitiesDto $componentVulnerabilitiesDto DTO providing `getStorageKey()` and `toArray()`
     *
     * @return void
     */
    public function saveComponent(ComponentVulnerabilitiesDto $componentVulnerabilitiesDto): void
    {
        $all = $this->getAllRaw();
        $all[$componentVulnerabilitiesDto->getStorageKey()] = $componentVulnerabilitiesDto->toArray();
        update_option(self::OPTION_KEY, $all, false);
    }


    /**
     * Return the raw stored vulnerability map from the WordPress option.
     *
     * Returned shape: array<string, array<string, mixed>>
     * - Key is the storage key (e.g. "plugin:slug")
     * - Value is the component array as stored by `saveComponent`
     *
     * If the option is malformed (not an array) an empty array is returned.
     *
     * @return array<string, array<string, mixed>> Map of storage keys to component arrays
     */
    public function getAllRaw(): array
    {
        $stored = get_option(self::OPTION_KEY, []);
        if (!is_array($stored)) {
            return [];
        }

        return $stored;
    }

    /**
     * Determine the highest severity vulnerability for a component.
     *
     * Behavior:
     * - Looks up the component by its storage key (e.g. "plugin:{slug}").
     * - Returns null when the component does not exist or has no vulnerabilities.
     * - Selects the highest-ranked vulnerability based on severity ranking.
     *
     * The returned DTO contains both component context and the selected
     * vulnerability payload.
     *
     * @param non-empty-string $type Component type (e.g. "plugin", "theme", "core").
     * @param non-empty-string $slug Component slug.
     *
     * @return HighestSeverityContextDto|null
     */
    public function getHighestSeverityForComponent(string $type, string $slug): ?HighestSeverityContextDto
    {
        $all = $this->getAllRaw();
        $key = $this->buildStorageKey($type, $slug);
        if (!isset($all[$key]) || !is_array($all[$key])) {
            return null;
        }

        $component = $all[$key];
        $vulnerabilities = $component['vulnerabilities'] ?? null;

        if (!is_array($vulnerabilities) || $vulnerabilities === []) {
            return null;
        }

        $highestVulnerability = $this->findHighestVulnerability($vulnerabilities);
        if ($highestVulnerability === null) {
            return null;
        }

        return $this->buildHighestSeverityContext($component, $highestVulnerability, $slug, $type);
    }

    /**
     * Determine the highest severity vulnerability for a given plugin slug.
     * Returns a structured array containing the component context and the
     * highest severity vulnerability entry, including the UUID (lookup).
     *
     * @param string $slug
     * @return HighestSeverityContextDto|null
     */
    public function getHighestSeverityForPluginSlug(string $slug): ?HighestSeverityContextDto
    {
        return $this->getHighestSeverityForComponent('plugin', $slug);
    }

    /**
     * Build the storage key for a component based on type and slug.
     *
     * @param string $type
     * @param string $slug
     * @return string
     */
    private function buildStorageKey(string $type, string $slug): string
    {
        $type = strtolower(trim($type));
        $slug = strtolower(trim($slug));

        return $type . ':' . $slug;
    }

    /**
     * Return the numeric rank for a given severity.
     * Unknown severities return 0.
     *
     * @param $severity
     * @return int
     */
    private function severityRank($severity): int
    {
        if (!is_string($severity)) {
            return 0;
        }

        $severity = strtolower(trim($severity));

        return ConfigurableSeverityPolicy::SEVERITY_SCORES[$severity] ?? 0;
    }


    /**
     * Determine the highest severity vulnerability for a given theme slug.
     * Returns a structured array containing the component context and the
     * highest severity vulnerability entry, including the UUID (lookup).
     *
     * @param string $slug
     * @return HighestSeverityContextDto|null
     */
    public function getHighestSeverityForThemeSlug(string $slug): ?HighestSeverityContextDto
    {
        return $this->getHighestSeverityForComponent('theme', $slug);
    }

    /**
     * Determine the highest severity vulnerability for a given core slug.
     *
     * @param non-empty-string $slug Core slug (defaults to "wordpress").
     *
     * @return HighestSeverityContextDto|null
     */
    public function getHighestSeverityForCoreSlug(string $slug = 'wordpress'): ?HighestSeverityContextDto
    {
        return $this->getHighestSeverityForComponent('core', $slug);
    }

    /**
     * Remove vulnerabilities for a given component key.
     *
     *  If the key does not exist this method is a no-op.
     *
     * @param string $storageKey For example "plugin:contact-form-7".
     *
     * @return void
     */
    public function deleteComponent(string $storageKey): void
    {
        $all = $this->getAllRaw();

        if (!array_key_exists($storageKey, $all)) {
            return;
        }

        unset($all[$storageKey]);

        update_option(self::OPTION_KEY, $all, false);
    }

    /**
     * Remove all stored component vulnerabilities.
     *
     * This wipes the option to an empty array instead of deleting the option,
     * which keeps the option present but empty.
     *
     * @return void
     */
    public function deleteAllComponents(): void
    {
        update_option(self::OPTION_KEY, [], false);
    }

    /**
     * Find the single highest severity vulnerability from the provided list.
     *
     * Behaviour:
     * - Accepts an array of vulnerability items (associative arrays).
     * - Uses `ConfigurableSeverityPolicy::SEVERITY_SCORES` to rank severities.
     * - Returns the first encountered item of the highest rank.
     * - Short-circuits and returns immediately if a `critical` is found.
     *
     * Expected vulnerability item shape (minimum):
     * [
     *   'severity' => 'high'|'critical'|...,
     *   'lookup' => 'uuid',
     *   ...other fields...
     * ]
     *
     * @param list<mixed> $vulnerabilities List of vulnerability items
     *
     * @return array<string, mixed>|null The selected vulnerability item, or null when none valid
     */
    private function findHighestVulnerability(array $vulnerabilities): ?array
    {
        $highest = null;
        $highestRank = 0;

        foreach ($vulnerabilities as $vulnerability) {
            if (!is_array($vulnerability)) {
                continue;
            }

            $rank = $this->severityRank($vulnerability['severity'] ?? null);

            if ($rank > $highestRank) {
                $highestRank = $rank;
                $highest = $vulnerability;

                if ($highestRank === (ConfigurableSeverityPolicy::SEVERITY_SCORES['critical'] ?? 9)) {
                    return $highest;
                }
            }
        }

        return $highest;
    }

    /**
     * Build a standardized context array for a highest-severity result.
     *
     * @param array<string, mixed> $component Raw stored component array
     * @param array<string, mixed> $highestVulnerability The selected vulnerability item
     * @param non-empty-string $fallbackSlug Fallback slug used if component has no `slug`
     * @param non-empty-string $fallbackType Fallback type used if component has no `type`
     *
     * @return HighestSeverityContextDto The constructed DTO
     */
    private function buildHighestSeverityContext(
        array  $component,
        array  $highestVulnerability,
        string $fallbackSlug,
        string $fallbackType
    ): HighestSeverityContextDto
    {
        $highestStorage = new Storage($highestVulnerability);
        $componentStorage = new Storage($component);

        return new HighestSeverityContextDto(
            strtolower($highestStorage->getString('severity')),
            $highestStorage->getString('lookup'),
            $componentStorage->getString('slug', $fallbackSlug),
            $componentStorage->getString('type', $fallbackType),
            $componentStorage->getString('name'),
            $componentStorage->getString('latestVersion'),
            $componentStorage->getBoolean('hasUpdate'),
            $highestVulnerability,
        );
    }

    /**
     * Return the numeric score for a textual severity.
     *
     * Unknown severities return 0.
     *
     * @param string $severity textual severity (case-insensitive)
     *
     * @return int numeric score from `ConfigurableSeverityPolicy::SEVERITY_SCORES` or 0
     */
    public function getSeverityScore(string $severity): int
    {
        return $this->severityRank($severity);
    }

    /**
     * Determine the highest severity level across all stored components.
     *
     * Behaviour:
     * - Iterates all stored components and their vulnerabilities.
     * - Uses `ConfigurableSeverityPolicy::SEVERITY_SCORES` to rank severities.
     * - Returns the highest severity found as a string (low|medium|high|critical).
     * - If no vulnerabilities exist, returns null.
     *
     * @return string|null Highest severity level found, or null when none exist
     */
    public function getHighestSeverity(): ?string
    {
        $all = $this->getAllRaw();

        if ($all === []) {
            return null;
        }

        $highest = 0;
        $highestSeverity = null;

        foreach ($all as $component) {
            if (!is_array($component)) {
                continue;
            }

            $vulnerabilities = $component['vulnerabilities'] ?? null;
            if (!is_array($vulnerabilities) || $vulnerabilities === []) {
                continue;
            }

            $highestVulnerability = $this->findHighestVulnerability($vulnerabilities);
            if ($highestVulnerability === null) {
                continue;
            }

            $severity = strtolower((string)($highestVulnerability['severity'] ?? ''));
            $rank = $this->severityRank($severity);

            if ($rank > $highest) {
                $highest = $rank;
                $highestSeverity = $severity;

                if ($highest === ConfigurableSeverityPolicy::SEVERITY_SCORES['critical']) {
                    return 'critical';
                }
            }
        }

        return $highestSeverity;
    }

    /**
     * Return snapshot data useful for dashboards or metrics.
     *
     * Returned array shape:
     * - highestSeverity: ?string (highest severity across components or null)
     * - severityScore: int (numeric score for highestSeverity or 0)
     * - vulnerablePlugins: int (count of components with non-empty `vulnerabilities`)
     * - updatableComponents: int (count where `hasUpdate` is truthy)
     *
     * @return array{
     *   highestSeverity: ?string,
     *   severityScore: int,
     *   vulnerablePlugins: int,
     *   updatableComponents: int
     * }
     */
    public function getSnapshotData(): array
    {
        $all = $this->getAllRaw();

        $highestSeverity = $this->getHighestSeverity();
        $severityScore = $highestSeverity ? $this->getSeverityScore($highestSeverity) : 0;

        $vulnerablePlugins = 0;
        $updatableComponents = 0;

        foreach ($all as $component) {
            if (!is_array($component)) {
                continue;
            }

            $vulnerabilities = $component['vulnerabilities'] ?? [];
            if (is_array($vulnerabilities) && $vulnerabilities !== []) {
                $vulnerablePlugins++;
            }

            if (($component['hasUpdate'] ?? false) === true) {
                $updatableComponents++;
            }
        }

        return [
            'highestSeverity' => $highestSeverity,
            'severityScore' => $severityScore,
            'vulnerablePlugins' => $vulnerablePlugins,
            'updatableComponents' => $updatableComponents,
        ];
    }

    /**
     * Count vulnerabilities grouped by textual severity across all components.
     *
     * Returned shape: array<string, int> where key is lowercased severity.
     *
     * Example: ['critical' => 2, 'high' => 5]
     *
     * @return array<string, int>
     */
    public function getVulnerabilityCountPerSeverity(): array
    {
        $components = $this->getAllRaw();
        $counts = [];
        foreach ($components as $component) {
            $vulnerabilities = $component['vulnerabilities'] ?? null;
            if (!is_array($vulnerabilities) || $vulnerabilities === []) {
                continue;
            }

            foreach ($vulnerabilities as $vulnerability) {
                if (!is_array($vulnerability)) {
                    continue;
                }

                $severity = strtolower((string)($vulnerability['severity'] ?? ''));
                if ($severity === '') {
                    continue;
                }

                if (!isset($counts[$severity])) {
                    $counts[$severity] = 0;
                }

                $counts[$severity]++;
            }
        }

        return $counts;
    }

    /**
     * Count components grouped by their highest textual severity.
     *
     * Unlike {@see getVulnerabilityCountPerSeverity()}, this counts each component
     * at most once, based on the single highest-severity vulnerability within that
     * component.
     *
     * Returned shape: array<string, int> where key is lowercased severity.
     *
     * Example: ['critical' => 2, 'high' => 5]
     *
     * @return array<string, int>
     */
    public function getComponentCountPerHighestSeverity(): array
    {
        $components = $this->getAllRaw();
        $counts = [];

        foreach ($components as $component) {
            if (!is_array($component)) {
                continue;
            }

            $vulnerabilities = $component['vulnerabilities'] ?? null;
            if (!is_array($vulnerabilities) || $vulnerabilities === []) {
                continue;
            }

            $highest = $this->findHighestVulnerability($vulnerabilities);
            if ($highest === null) {
                continue;
            }

            $severity = strtolower((string)($highest['severity'] ?? ''));
            if ($severity === '') {
                continue;
            }

            if (!isset($counts[$severity])) {
                $counts[$severity] = 0;
            }

            $counts[$severity]++;
        }

        return $counts;
    }

    /**
     * Fetch all components that have non-empty `vulnerabilities`.
     *
     * Returned map is the subset of `getAllRaw()` where `vulnerabilities` is present.
     *
     * @return array<string, array<string, mixed>> Map of storageKey => component array
     */
    public function getAllVulnerableComponentsRaw(): array
    {
        $components = $this->getAllRaw();
        $vulnerable = [];

        foreach ($components as $key => $component) {
            if (!is_array($component)) {
                continue;
            }

            $vulnerabilities = $component['vulnerabilities'] ?? null;
            if (!is_array($vulnerabilities) || $vulnerabilities === []) {
                continue;
            }

            $vulnerable[$key] = $component;
        }

        return $vulnerable;
    }
}