From 51ecb631cd769fc05655a2dde15078cf98eaff1f Mon Sep 17 00:00:00 2001
From: Nate Weller <hello@nateweller.com>
Date: Mon, 6 Jan 2025 13:55:16 -0700
Subject: [PATCH] Combine multiple vulnerabilities for a single extension into
 one vulnerable extension threat

changelog

minor adjustments

changelog

add source to generator

minor adjustments

Add typed params

minor adjustments

use generator for core vulns threat
---
 ...status-combine-vulns-into-extension-threat |  4 +
 .../protect-models/src/class-threat-model.php | 97 +++++++++++++++++++
 .../src/class-vulnerability-model.php         | 94 ++++++++++++++++++
 .../changelog/combine-vulns-into-threat       |  4 +
 .../src/class-protect-status.php              | 45 ++++-----
 5 files changed, 216 insertions(+), 28 deletions(-)
 create mode 100644 projects/packages/protect-models/changelog/protect-status-combine-vulns-into-extension-threat
 create mode 100644 projects/packages/protect-models/src/class-vulnerability-model.php
 create mode 100644 projects/packages/protect-status/changelog/combine-vulns-into-threat

diff --git a/projects/packages/protect-models/changelog/protect-status-combine-vulns-into-extension-threat b/projects/packages/protect-models/changelog/protect-status-combine-vulns-into-extension-threat
new file mode 100644
index 0000000000000..2eee7b4277d3d
--- /dev/null
+++ b/projects/packages/protect-models/changelog/protect-status-combine-vulns-into-extension-threat
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+TBD
diff --git a/projects/packages/protect-models/src/class-threat-model.php b/projects/packages/protect-models/src/class-threat-model.php
index bf8add53d530e..c34d5c1aa67b7 100644
--- a/projects/packages/protect-models/src/class-threat-model.php
+++ b/projects/packages/protect-models/src/class-threat-model.php
@@ -119,6 +119,15 @@ class Threat_Model {
 	 */
 	public $extension;
 
+	/**
+	 * The threat's related vulnerabilities.
+	 *
+	 * @since $$next-version$$
+	 *
+	 * @var null|Vulnerability_Model[]
+	 */
+	public $vulnerabilities;
+
 	/**
 	 * Threat Constructor
 	 *
@@ -139,4 +148,92 @@ public function __construct( $threat ) {
 			}
 		}
 	}
+
+	/**
+	 * Get the ID value of the threat based on its related extension and vulnerabilities.
+	 *
+	 * @param Extension_Model $extension       The extension to get the ID from.
+	 * @param array           $vulnerabilities The vulnerabilities to get the ID from.
+	 *
+	 * @return string
+	 */
+	private static function get_id_from_vulnerable_extension( Extension_Model $extension, array $vulnerabilities ) {
+		// Create a single unique ID for the threat by hashing the extension and vulnerability IDs it contains.
+		return md5(
+			array_reduce(
+				$vulnerabilities,
+				function ( $carry, $vulnerability ) {
+					return $carry . $vulnerability->id;
+				},
+				$extension->slug . $extension->version
+			)
+		);
+	}
+
+	/**
+	 * Get the title from a vulnerable extension.
+	 *
+	 * @param Extension_Model $extension The extension to get the title from.
+	 *
+	 * @return string|null
+	 */
+	private static function get_title_from_vulnerable_extension( Extension_Model $extension ) {
+		$titles = array(
+			'plugins' => sprintf(
+				/* translators: placeholders are the theme name and version number. Example: "Vulnerable theme: Jetpack (version 1.2.3)" */
+				__( 'Vulnerable plugin: %1$s (version %2$s)', 'jetpack-protect-models' ),
+				$extension->name,
+				$extension->version
+			),
+			'themes'  => sprintf(
+				/* translators: placeholders are the theme name and version number. Example: "Vulnerable theme: Jetpack (version 1.2.3)" */
+				__( 'Vulnerable theme: %1$s (version %2$s)', 'jetpack-protect-models' ),
+				$extension->name,
+				$extension->version
+			),
+			'core'    => sprintf(
+				/* translators: placeholder is the version number. Example: "Vulnerable WordPress (version 1.2.3)" */
+				__( 'Vulnerable WordPress (version %s)', 'jetpack-protect-models' ),
+				$extension->version
+			),
+		);
+
+		return $titles[ $extension->type ] ?? null;
+	}
+
+	/**
+	 * Get the description from a vulnerable extension.
+	 *
+	 * @param Extension_Model $extension The extension to get the description from.
+	 * @param array           $vulnerabilities The vulnerabilities to get the description from.
+	 *
+	 * @return string
+	 */
+	private static function get_description_from_vulnerable_extension( Extension_Model $extension, array $vulnerabilities ) {
+		return sprintf(
+				/* translators: placeholders are the theme name and version number. Example: "The installed version of Jetpack (1.2.3) has a known security vulnerability." */
+			_n( 'The installed version of %1$s (%2$s) has a known security vulnerability.', 'The installed version of %1$s (%2$s) has known security vulnerabilities.', count( $vulnerabilities ), 'jetpack-protect-models' ),
+			$extension->name,
+			$extension->version
+		);
+	}
+
+	/**
+	 * Generate a threat from extension vulnerabilities.
+	 *
+	 * @param Extension_Model $extension       The extension to generate the threat for.
+	 * @param array           $vulnerabilities The vulnerabilities to generate the threat from.
+	 *
+	 * @return Threat_Model
+	 */
+	public static function generate_from_extension_vulnerabilities( Extension_Model $extension, array $vulnerabilities ) {
+		return new Threat_Model(
+			array(
+				'id'              => self::get_id_from_vulnerable_extension( $extension, $vulnerabilities ),
+				'title'           => self::get_title_from_vulnerable_extension( $extension ),
+				'description'     => self::get_description_from_vulnerable_extension( $extension, $vulnerabilities ),
+				'vulnerabilities' => $vulnerabilities,
+			)
+		);
+	}
 }
diff --git a/projects/packages/protect-models/src/class-vulnerability-model.php b/projects/packages/protect-models/src/class-vulnerability-model.php
new file mode 100644
index 0000000000000..210d5dc1aa74e
--- /dev/null
+++ b/projects/packages/protect-models/src/class-vulnerability-model.php
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Model class for vulnerability data.
+ *
+ * @package automattic/jetpack-protect-models
+ */
+
+namespace Automattic\Jetpack\Protect_Models;
+
+use Automattic\Jetpack\Redirect;
+
+/**
+ * Model class for vulnerability data.
+ */
+class Vulnerability_Model {
+	/**
+	 * Threat ID.
+	 *
+	 * @var null|string
+	 */
+	public $id;
+
+	/**
+	 * Threat Title.
+	 *
+	 * @var null|string
+	 */
+	public $title;
+
+	/**
+	 * Threat Description.
+	 *
+	 * @var null|string
+	 */
+	public $description;
+
+	/**
+	 * The version the threat is fixed in.
+	 *
+	 * @var null|string
+	 */
+	public $fixed_in;
+
+	/**
+	 * The version the threat was introduced.
+	 *
+	 * @var null|string
+	 */
+	public $introduced_in;
+
+	/**
+	 * The type of threat.
+	 *
+	 * @var null|string
+	 */
+	public $type;
+
+	/**
+	 * The source URL for the threat.
+	 *
+	 * @var null|string
+	 */
+	public $source;
+
+	/**
+	 * Threat Constructor
+	 *
+	 * @param array|object $threat Threat data to load into the class instance.
+	 */
+	public function __construct( $threat ) {
+		// Initialize the threat data.
+		foreach ( $threat as $property => $value ) {
+			if ( property_exists( $this, $property ) ) {
+				$this->$property = $value;
+			}
+		}
+
+		// Ensure the source URL is set.
+		$this->get_source();
+	}
+
+	/**
+	 * Get the source URL for the threat.
+	 *
+	 * @return string
+	 */
+	public function get_source() {
+		if ( empty( $this->source ) && $this->id ) {
+			$this->source = Redirect::get_url( 'jetpack-protect-vul-info', array( 'path' => $this->id ) );
+		}
+
+		return $this->source;
+	}
+}
diff --git a/projects/packages/protect-status/changelog/combine-vulns-into-threat b/projects/packages/protect-status/changelog/combine-vulns-into-threat
new file mode 100644
index 0000000000000..b35e964631a8c
--- /dev/null
+++ b/projects/packages/protect-status/changelog/combine-vulns-into-threat
@@ -0,0 +1,4 @@
+Significance: minor
+Type: changed
+
+Combine multiple vulnerability results for the same extension into a single vulnerable extension threat result.
diff --git a/projects/packages/protect-status/src/class-protect-status.php b/projects/packages/protect-status/src/class-protect-status.php
index 6a2aa2e0361eb..7de2db3b24d63 100644
--- a/projects/packages/protect-status/src/class-protect-status.php
+++ b/projects/packages/protect-status/src/class-protect-status.php
@@ -15,7 +15,6 @@
 use Automattic\Jetpack\Protect_Models\Extension_Model;
 use Automattic\Jetpack\Protect_Models\Status_Model;
 use Automattic\Jetpack\Protect_Models\Threat_Model;
-use Automattic\Jetpack\Redirect;
 use Automattic\Jetpack\Sync\Functions as Sync_Functions;
 use Jetpack_Options;
 use WP_Error;
@@ -223,21 +222,21 @@ protected static function normalize_extension_data( &$status, $report_data, $ext
 				continue;
 			}
 
-			$extension->checked = true;
+			$extension->checked         = true;
+			$extension_threats[ $slug ] = $extension;
 
-			foreach ( $checked_extension->vulnerabilities as $vulnerability ) {
-				$threat         = new Threat_Model( $vulnerability );
-				$threat->source = isset( $vulnerability->id ) ? Redirect::get_url( 'jetpack-protect-vul-info', array( 'path' => $vulnerability->id ) ) : null;
+			if ( ! empty( $checked_extension->vulnerabilities ) ) {
+				// convert the vulnerability into a threat
+				$threat = Threat_Model::generate_from_extension_vulnerabilities( $extension, $checked_extension->vulnerabilities );
 
-				$threat_extension            = clone $extension;
-				$extension_threat            = clone $threat;
-				$extension_threat->extension = null;
+				$threat_extension = clone $extension;
+				$extension_threat = clone $threat;
 
+				$extension_threat->extension           = null;
 				$extension_threats[ $slug ]->threats[] = $extension_threat;
 
 				$threat->extension = $threat_extension;
 				$status->threats[] = $threat;
-
 			}
 		}
 
@@ -282,27 +281,17 @@ protected static function normalize_core_data( &$status, $report_data ) {
 		// If we've made it this far, the core version has been checked.
 		$core->checked = true;
 
-		// Extract threat data from the report.
-		if ( is_array( $report_data->core->vulnerabilities ) ) {
-			foreach ( $report_data->core->vulnerabilities as $vulnerability ) {
-				$threat = new Threat_Model(
-					array(
-						'id'          => $vulnerability->id,
-						'title'       => $vulnerability->title,
-						'fixed_in'    => $vulnerability->fixed_in,
-						'description' => isset( $vulnerability->description ) ? $vulnerability->description : null,
-						'source'      => isset( $vulnerability->id ) ? Redirect::get_url( 'jetpack-protect-vul-info', array( 'path' => $vulnerability->id ) ) : null,
-					)
-				);
-
-				$threat_extension = clone $core;
-				$extension_threat = clone $threat;
+		// Generate a threat from core vulnerabilities.
+		if ( ! empty( $report_data->core->vulnerabilities ) ) {
+			$threat = Threat_Model::generate_from_extension_vulnerabilities( $core, $report_data->core->vulnerabilities );
 
-				$core->threats[]   = $extension_threat;
-				$threat->extension = $threat_extension;
+			$threat_extension = clone $core;
+			$extension_threat = clone $threat;
 
-				$status->threats[] = $threat;
-			}
+			$core->threats[]   = $extension_threat;
+			$threat->extension = $threat_extension;
+
+			$status->threats[] = $threat;
 		}
 
 		$status->core = $core;