<?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;
}
}