From 436a583468febdc6ea4d85df6f4bd3673649ecc3 Mon Sep 17 00:00:00 2001
From: Manuel Leduc <manuel.leduc@xwiki.com>
Date: Wed, 19 Jul 2023 14:15:30 +0200
Subject: [PATCH] XWIKI-21030: List core security issues on the security
 vulnerability list

---
 .../ExtensionIndexSolrCoreInitializer.java    |  33 ++++-
 .../index/internal/ExtensionIndexStore.java   |  24 ++-
 .../ExtensionSecurityAnalysisResult.java      |  56 +++++++
 .../SecurityVulnerabilityDescriptor.java      |  63 +++++++-
 .../index/security/review/Review.java         | 134 +++++++++++++++++
 .../index/security/review/ReviewResult.java   |  42 ++++++
 .../index/security/review/ReviewsMap.java     | 102 +++++++++++++
 .../internal/ExtensionSecuritySolrClient.java |  16 +-
 .../internal/SolrToLiveDataEntryMapper.java   |  98 ++++++++++--
 .../resources/ApplicationResources.properties |   2 +
 .../extension/security/liveData/cveID.vm      |  65 ++++++++
 .../ExtensionSecuritySolrClientTest.java      |  57 ++++++-
 .../SolrToLiveDataEntryMapperTest.java        |  23 ++-
 .../internal/ExtensionSecurityJob.java        | 104 ++++++++++---
 .../internal/ExtensionSecurityScheduler.java  |   4 +-
 .../analyzer/VulnerabilityIndexer.java        | 139 +++++++++++++++++-
 .../analyzer/osv/OsvResponseAnalyzer.java     |  14 +-
 .../osv/model/response/VulnObject.java        |   3 +
 .../resources/ApplicationResources.properties |   4 +-
 .../ExtensionSecuritySchedulerTest.java       | 104 +++++++++++++
 .../analyzer/VulnerabilityIndexerTest.java    | 132 +++++++++++++++++
 .../analyzer/osv/OsvResponseAnalyzerTest.java |   2 -
 .../security/test/ui/ExtensionSecurityIT.java |   6 +-
 .../XWiki/Extension/Security/Code/Admin.xml   |  49 +++++-
 24 files changed, 1195 insertions(+), 81 deletions(-)
 create mode 100644 xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/Review.java
 create mode 100644 xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/ReviewResult.java
 create mode 100644 xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/ReviewsMap.java
 create mode 100644 xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/resources/templates/extension/security/liveData/cveID.vm
 create mode 100644 xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/ExtensionSecuritySchedulerTest.java
 create mode 100644 xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/analyzer/VulnerabilityIndexerTest.java

diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/internal/ExtensionIndexSolrCoreInitializer.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/internal/ExtensionIndexSolrCoreInitializer.java
index 4a1a0733d2b..571a5dbea0f 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/internal/ExtensionIndexSolrCoreInitializer.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/internal/ExtensionIndexSolrCoreInitializer.java
@@ -140,6 +140,28 @@ public class ExtensionIndexSolrCoreInitializer extends AbstractSolrCoreInitializ
      */
     public static final String SECURITY_ADVICE = "security_advice";
 
+    /**
+     * When {@code true} the extension is provided by the environemnt (e.g., from the servlet engine), {@code false}
+     * otherwise.
+     */
+    public static final String IS_FROM_ENVIRONMENT = "is_from_environment";
+
+    /**
+     * When {@code true} the extension is installed, otherwise the extension is provided by the core.
+     */
+    public static final String IS_INSTALLED_EXTENSION = "is_installed";
+
+    /**
+     * When {@code true} the extension has been reviewed and is not is considered as safe, {@code false} otherwise.
+     */
+    public static final String IS_REVIEWED_SAFE = "security_is_reviewed_safe";
+
+    /**
+     * Contains the explanations regarding why a given vulnerability can be considered as safe. This field contains an
+     * array of html contents.
+     */
+    public static final String IS_SAFE_EXPLANATIONS = "security_is_safe_explanations";
+
     private static final Pattern COMPONENT_SPECIAL_CHARS = Pattern.compile("[<>,]+");
 
     private static final long SCHEMA_VERSION_12_9 = 120900000;
@@ -150,10 +172,12 @@ public class ExtensionIndexSolrCoreInitializer extends AbstractSolrCoreInitializ
 
     private static final long SCHEMA_VERSION_15_5 = 150500000;
 
+    private static final long SCHEMA_VERSION_15_6 = 150600000;
+
     @Override
     protected long getVersion()
     {
-        return SCHEMA_VERSION_15_5;
+        return SCHEMA_VERSION_15_6;
     }
 
     @Override
@@ -227,6 +251,13 @@ protected void migrateSchema(long cversion) throws SolrException
             setStringField(SECURITY_FIX_VERSION, false, false);
             setStringField(SECURITY_ADVICE, false, false);
         }
+
+        if (cversion < SCHEMA_VERSION_15_6) {
+            setBooleanField(IS_FROM_ENVIRONMENT, false, false);
+            setBooleanField(IS_INSTALLED_EXTENSION, false, false);
+            setBooleanField(IS_REVIEWED_SAFE, true, false);
+            setStringField(IS_SAFE_EXPLANATIONS, true, false);
+        }
     }
 
     /**
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/internal/ExtensionIndexStore.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/internal/ExtensionIndexStore.java
index c8fcb57d5c3..c6d816bc255 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/internal/ExtensionIndexStore.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/internal/ExtensionIndexStore.java
@@ -28,6 +28,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
@@ -82,6 +83,10 @@
 import org.xwiki.search.solr.SolrException;
 import org.xwiki.search.solr.SolrUtils;
 
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_INSTALLED_EXTENSION;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_SAFE_EXPLANATIONS;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_FROM_ENVIRONMENT;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_REVIEWED_SAFE;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_ADVICE;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_CVE_COUNT;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_CVE_CVSS;
@@ -311,7 +316,7 @@ public void update(ExtensionId extensionId, ExtensionSecurityAnalysisResult resu
                 result.getMaxCVSS(), doc);
         } else {
             // Remove the CVSS score if the new list of security vulnerabilities becomes empty.
-            this.utils.setAtomic(SolrUtils.ATOMIC_UPDATE_MODIFIER_REMOVE, SECURITY_MAX_CVSS, 0.0, Double.class, doc);
+            this.utils.setAtomic(SolrUtils.ATOMIC_UPDATE_MODIFIER_SET, SECURITY_MAX_CVSS, null, doc);
         }
         Stream<String> cveIds =
             result.getSecurityVulnerabilities().stream().map(SecurityVulnerabilityDescriptor::getId);
@@ -325,6 +330,7 @@ public void update(ExtensionId extensionId, ExtensionSecurityAnalysisResult resu
                 .map(SecurityVulnerabilityDescriptor::getScore).collect(Collectors.toList()), doc);
         String fixVersion = result.getSecurityVulnerabilities().stream()
             .map(SecurityVulnerabilityDescriptor::getFixVersion)
+            .filter(Objects::nonNull)
             .max(Comparator.naturalOrder())
             .map(Version::getValue)
             .orElse(null);
@@ -332,6 +338,20 @@ public void update(ExtensionId extensionId, ExtensionSecurityAnalysisResult resu
         this.utils.setAtomic(SolrUtils.ATOMIC_UPDATE_MODIFIER_SET, SECURITY_ADVICE, result.getAdvice(), doc);
         this.utils.setAtomic(SolrUtils.ATOMIC_UPDATE_MODIFIER_SET, SECURITY_CVE_COUNT,
             result.getSecurityVulnerabilities().size(), doc);
+        this.utils.setAtomic(SolrUtils.ATOMIC_UPDATE_MODIFIER_SET, IS_FROM_ENVIRONMENT, result.isFromEnvironment(),
+            doc);
+        this.utils.setAtomic(SolrUtils.ATOMIC_UPDATE_MODIFIER_SET, IS_INSTALLED_EXTENSION,
+            result.isInstalledExtension(), doc);
+        List<Boolean> safeMapping = result.getSecurityVulnerabilities()
+            .stream()
+            .map(SecurityVulnerabilityDescriptor::isSafe)
+            .collect(Collectors.toList());
+        this.utils.setAtomic(SolrUtils.ATOMIC_UPDATE_MODIFIER_SET, IS_REVIEWED_SAFE, safeMapping, doc);
+        List<String> reviewExplanations = result.getSecurityVulnerabilities()
+            .stream()
+            .map(SecurityVulnerabilityDescriptor::getReviews)
+            .collect(Collectors.toList());
+        this.utils.setAtomic(SolrUtils.ATOMIC_UPDATE_MODIFIER_SET, IS_SAFE_EXPLANATIONS, reviewExplanations, doc);
 
         add(doc);
         commit();
@@ -450,7 +470,7 @@ private void updateCompatible(SolrInputDocument document, String namespace, Bool
         }
 
         if (incompatible != null) {
-            this.utils.setAtomic(incompatible.booleanValue() ? SolrUtils.ATOMIC_UPDATE_MODIFIER_ADD_DISTINCT 
+            this.utils.setAtomic(incompatible.booleanValue() ? SolrUtils.ATOMIC_UPDATE_MODIFIER_ADD_DISTINCT
                     : SolrUtils.ATOMIC_UPDATE_MODIFIER_REMOVE,
                 ExtensionIndexSolrCoreInitializer.SOLR_FIELD_INCOMPATIBLE_NAMESPACES,
                 this.extensionIndexSolrUtil.toStoredNamespace(namespace), document);
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/ExtensionSecurityAnalysisResult.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/ExtensionSecurityAnalysisResult.java
index 48ceaa3eddb..644b20199bf 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/ExtensionSecurityAnalysisResult.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/ExtensionSecurityAnalysisResult.java
@@ -41,6 +41,10 @@ public class ExtensionSecurityAnalysisResult
 
     private String advice;
 
+    private boolean fromEnvironment;
+
+    private boolean isInstalledExtension;
+
     /**
      * @param securityVulnerabilities the security vulnerabilities associated with the analyzed extension
      * @return the current object
@@ -68,6 +72,32 @@ public String getAdvice()
         return this.advice;
     }
 
+    /**
+     * @return {@code true} when the extension is provided by the environment (e.g., from a servlet engine),
+     *     {@code false} when the extension is from xwiki core, an installable extension, or their transitive
+     *     dependencies
+     * @since 15.6RC1
+     */
+    @Unstable
+    public boolean isFromEnvironment()
+    {
+        return this.fromEnvironment;
+    }
+
+    /**
+     * @param fromEnvironment {@code true} when the extension is provided by the environment (e.g., from a servlet
+     *     engine), {@code false} when the extension is from xwiki core, an installable extension, or their transitive
+     *     dependencies
+     * @return the current object
+     * @since 15.6RC1
+     */
+    @Unstable
+    public ExtensionSecurityAnalysisResult setFromEnvironment(boolean fromEnvironment)
+    {
+        this.fromEnvironment = fromEnvironment;
+        return this;
+    }
+
     /**
      * @param advice the translation key of the advice applicable on the security analysis (e.g., how to upgrade the
      *     extension to fix the identified security vulnerabilities)
@@ -95,6 +125,26 @@ public Double getMaxCVSS()
         }
     }
 
+    /**
+     * @param isInstalledExtension {@code true} when the extension is installed, {@code false} otherwise
+     * @since 15.6RC1
+     */
+    @Unstable
+    public void setInstalledExtension(boolean isInstalledExtension)
+    {
+        this.isInstalledExtension = isInstalledExtension;
+    }
+
+    /**
+     * @return {@code true} when the extension is installed, {@code false} otherwise
+     * @since 15.6RC1
+     */
+    @Unstable
+    public boolean isInstalledExtension()
+    {
+        return this.isInstalledExtension;
+    }
+
     @Override
     public boolean equals(Object o)
     {
@@ -111,6 +161,8 @@ public boolean equals(Object o)
         return new EqualsBuilder()
             .append(this.securityVulnerabilities, that.securityVulnerabilities)
             .append(this.advice, that.advice)
+            .append(this.fromEnvironment, that.fromEnvironment)
+            .append(this.isInstalledExtension, that.isInstalledExtension)
             .isEquals();
     }
 
@@ -120,6 +172,8 @@ public int hashCode()
         return new HashCodeBuilder(17, 37)
             .append(this.securityVulnerabilities)
             .append(this.advice)
+            .append(this.fromEnvironment)
+            .append(this.isInstalledExtension)
             .toHashCode();
     }
 
@@ -129,6 +183,8 @@ public String toString()
         return new XWikiToStringBuilder(this)
             .append("securityVulnerabilities", this.securityVulnerabilities)
             .append("advice", this.advice)
+            .append("fromEnvironment", this.fromEnvironment)
+            .append("isInstalledExtension", this.isInstalledExtension)
             .toString();
     }
 }
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/SecurityVulnerabilityDescriptor.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/SecurityVulnerabilityDescriptor.java
index d0ffe3d8d07..6a584ed6b8e 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/SecurityVulnerabilityDescriptor.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/SecurityVulnerabilityDescriptor.java
@@ -19,6 +19,7 @@
  */
 package org.xwiki.extension.index.security;
 
+import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.builder.EqualsBuilder;
 import org.apache.commons.lang3.builder.HashCodeBuilder;
 import org.xwiki.extension.version.Version;
@@ -44,6 +45,10 @@ public class SecurityVulnerabilityDescriptor
 
     private Version fixVersion;
 
+    private boolean safe;
+
+    private String reviews;
+
     /**
      * @param id the security vulnerability id
      * @return the current object
@@ -89,7 +94,9 @@ public String getURL()
      */
     public SecurityVulnerabilityDescriptor setSeverityScore(String vector)
     {
-        this.score = Cvss.fromVector(vector).calculateScore().getBaseScore();
+        if (StringUtils.isNotEmpty(vector)) {
+            this.score = Cvss.fromVector(vector).calculateScore().getBaseScore();
+        }
         return this;
     }
 
@@ -129,6 +136,54 @@ public SecurityVulnerabilityDescriptor setFixVersion(Version fixVersion)
         return this;
     }
 
+    /**
+     * @return {@code true} when the extension has some known vulnerabilities, but all are reviewed as safe,
+     *     {@code false} otherwise
+     * @since 15.6RC1
+     */
+    @Unstable
+    public boolean isSafe()
+    {
+        return this.safe;
+    }
+
+    /**
+     * @param safe {@code true} when the extension has some known vulnerabilities, but all are reviewed as safe,
+     *     {@code false} otherwise
+     * @return the current object
+     * @since 15.6RC1
+     */
+    @Unstable
+    public SecurityVulnerabilityDescriptor setSafe(boolean safe)
+    {
+        this.safe = safe;
+        return this;
+    }
+
+    /**
+     * @return the ignored vulnerabilities description, this is an html content containing all the false-positive
+     *     analysis
+     * @since 15.6RC1
+     */
+    @Unstable
+    public String getReviews()
+    {
+        return this.reviews;
+    }
+
+    /**
+     * @param reviews the ignored vulnerabilities description, this is an html content containing all the
+     *     false-positive analysis
+     * @return the current object
+     * @since 15.6RC1
+     */
+    @Unstable
+    public SecurityVulnerabilityDescriptor setReviews(String reviews)
+    {
+        this.reviews = reviews;
+        return this;
+    }
+
     @Override
     public boolean equals(Object o)
     {
@@ -147,6 +202,8 @@ public boolean equals(Object o)
             .append(this.id, that.id)
             .append(this.url, that.url)
             .append(this.fixVersion, that.fixVersion)
+            .append(this.safe, that.safe)
+            .append(this.reviews, that.reviews)
             .isEquals();
     }
 
@@ -158,6 +215,8 @@ public int hashCode()
             .append(this.url)
             .append(this.score)
             .append(this.fixVersion)
+            .append(this.safe)
+            .append(this.reviews)
             .toHashCode();
     }
 
@@ -169,6 +228,8 @@ public String toString()
             .append("url", this.url)
             .append("score", this.score)
             .append("fixVersion", this.fixVersion)
+            .append("safe", this.safe)
+            .append("reviews", this.reviews)
             .toString();
     }
 }
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/Review.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/Review.java
new file mode 100644
index 00000000000..e85c0d5a0bb
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/Review.java
@@ -0,0 +1,134 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.extension.index.security.review;
+
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.xwiki.stability.Unstable;
+import org.xwiki.text.XWikiToStringBuilder;
+
+/**
+ * Contains the metadata relative to the review of a vulnerability.
+ *
+ * @version $Id$
+ * @since 15.6RC1
+ */
+@Unstable
+public class Review
+{
+    private String emitter;
+
+    private String explanation;
+
+    private ReviewResult result;
+
+    /**
+     * @return the {@code source} of the analysis (e.g., {@code xwiki-platform}, or the name of the extension for which
+     *     the analysis has been done)
+     */
+    public String getEmitter()
+    {
+        return this.emitter;
+    }
+
+    /**
+     * @param emitter the {@code source} of the analysis (e.g., {@code xwiki-platform}, or the name of the extension
+     *     for which the analysis has been done)
+     */
+    public void setEmitter(String emitter)
+    {
+        this.emitter = emitter;
+    }
+
+    /**
+     * @return the textual explanation, detailing why a given CVE should not be considered as a security vulnerability
+     *     in the context of the {@code source}
+     */
+    public String getExplanation()
+    {
+        return this.explanation;
+    }
+
+    /**
+     * @param explanation the textual explanation, detailing why a given CVE should not be considered as a security
+     *     vulnerability in the context of the {@code source}
+     */
+    public void setExplanation(String explanation)
+    {
+        this.explanation = explanation;
+    }
+
+    /**
+     * @return the result of the vulnerability review
+     */
+    public ReviewResult getResult()
+    {
+        return this.result;
+    }
+
+    /**
+     * @param result the result of the vulnerability review
+     */
+    public void setResult(ReviewResult result)
+    {
+        this.result = result;
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) {
+            return true;
+        }
+
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+
+        Review review = (Review) o;
+
+        return new EqualsBuilder()
+            .append(this.emitter, review.emitter)
+            .append(this.explanation, review.explanation)
+            .append(this.result, review.result)
+            .isEquals();
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return new HashCodeBuilder(17, 37)
+            .append(this.emitter)
+            .append(this.explanation)
+            .append(this.result)
+            .toHashCode();
+    }
+
+    @Override
+    public String toString()
+    {
+        return new XWikiToStringBuilder(this)
+            .append("emitter", this.emitter)
+            .append("explanation", this.explanation)
+            .append("result", this.result)
+            .toString();
+    }
+
+}
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/ReviewResult.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/ReviewResult.java
new file mode 100644
index 00000000000..3391980c22d
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/ReviewResult.java
@@ -0,0 +1,42 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.extension.index.security.review;
+
+import org.xwiki.stability.Unstable;
+
+/**
+ * The review result of a vulnerability.
+ *
+ * @version $Id$
+ * @since 15.6RC1
+ */
+@Unstable
+public enum ReviewResult
+{
+    /**
+     * When the conclusion of the analysis is that the vulnerability is safe.
+     */
+    SAFE,
+
+    /**
+     * When the conclusion of the analysis is that the vulnerability can be unsafe.
+     */
+    UNSAFE
+}
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/ReviewsMap.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/ReviewsMap.java
new file mode 100644
index 00000000000..ea7de7ed746
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-index/src/main/java/org/xwiki/extension/index/security/review/ReviewsMap.java
@@ -0,0 +1,102 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.extension.index.security.review;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.xwiki.stability.Unstable;
+import org.xwiki.text.XWikiToStringBuilder;
+
+/**
+ * Contains the maps of all the CVEs with available reviews.
+ *
+ * @version $Id$
+ * @since 15.6RC1
+ */
+@Unstable
+public class ReviewsMap
+{
+    private final Map<String, List<Review>> reviewsMap = new HashMap<>();
+
+    /**
+     * @return the map of CVEs and their associated reviews.
+     */
+    public Map<String, List<Review>> getReviewsMap()
+    {
+        return this.reviewsMap;
+    }
+
+    /**
+     * @param id a CVE id
+     * @return {@code true} if at least a review is available for a given id
+     */
+    public boolean contains(String id)
+    {
+        return this.reviewsMap.containsKey(id);
+    }
+
+    /**
+     * @param id a CVE id
+     * @return the list of reviews if found, {@link Optional#empty()} otherwise
+     */
+    public Optional<List<Review>> getById(String id)
+    {
+        return Optional.ofNullable(this.reviewsMap.get(id));
+    }
+
+    @Override
+    public boolean equals(Object o)
+    {
+        if (this == o) {
+            return true;
+        }
+
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+
+        ReviewsMap that = (ReviewsMap) o;
+
+        return new EqualsBuilder()
+            .append(this.reviewsMap, that.reviewsMap)
+            .isEquals();
+    }
+
+    @Override
+    public int hashCode()
+    {
+        return new HashCodeBuilder(17, 37)
+            .append(this.reviewsMap)
+            .toHashCode();
+    }
+
+    @Override
+    public String toString()
+    {
+        return new XWikiToStringBuilder(this)
+            .append("reviewsMap", this.reviewsMap)
+            .toString();
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/java/org/xwiki/extension/security/internal/ExtensionSecuritySolrClient.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/java/org/xwiki/extension/security/internal/ExtensionSecuritySolrClient.java
index f4161b46e29..973ce8024c7 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/java/org/xwiki/extension/security/internal/ExtensionSecuritySolrClient.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/java/org/xwiki/extension/security/internal/ExtensionSecuritySolrClient.java
@@ -37,6 +37,7 @@
 import org.xwiki.search.solr.SolrUtils;
 
 import static org.xwiki.extension.InstalledExtension.FIELD_INSTALLED_NAMESPACES;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_FROM_ENVIRONMENT;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_FIX_VERSION;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_MAX_CVSS;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SOLR_FIELD_EXTENSIONID;
@@ -67,6 +68,8 @@ public class ExtensionSecuritySolrClient
         WIKIS, FIELD_INSTALLED_NAMESPACES
     );
 
+    private static final String EXACT_MATCH_PATTERN = "%s:%s";
+
     @Inject
     private ExtensionIndexStore extensionIndexStore;
 
@@ -87,6 +90,8 @@ public long getVulnerableExtensionsCount() throws SolrServerException, IOExcepti
         this.extensionIndexStore.createSolrQuery(new ExtensionQuery(), solrQuery);
 
         initFilter(solrQuery);
+        // Exclude the extensions with only safe vulnerabilities.
+        solrQuery.addFilterQuery("security_is_reviewed_safe:false");
         QueryResponse search = this.extensionIndexStore.search(solrQuery);
         return search.getResults().getNumFound();
     }
@@ -137,6 +142,13 @@ private void initFilter(LiveDataQuery liveDataQuery, SolrQuery solrQuery)
                 }
             }
         }
+
+        Map<String, Object> parametersMap = liveDataQuery.getSource().getParameters();
+        String isFromEnvironment = Boolean.TRUE.toString();
+        if (!Objects.equals(parametersMap.get("isFromEnvironment"), Boolean.TRUE.toString())) {
+            isFromEnvironment = Boolean.FALSE.toString();
+        }
+        solrQuery.addFilterQuery(String.format(EXACT_MATCH_PATTERN, IS_FROM_ENVIRONMENT, isFromEnvironment));
     }
 
     private static void initFilter(SolrQuery solrQuery)
@@ -146,8 +158,8 @@ private static void initFilter(SolrQuery solrQuery)
         }
         // Only include extensions with a computed CVSS score, meaning that they have at least one known security
         // vulnerability.
-        solrQuery.addFilterQuery(String.format("%s:[0 TO 10]", SECURITY_MAX_CVSS));
-        solrQuery.addFilterQuery(FIELD_INSTALLED_NAMESPACES + ":[* TO *]");
+        solrQuery.addFilterQuery(String.format("%s:{0 TO 10]", SECURITY_MAX_CVSS));
+        solrQuery.addFilterQuery(String.format("(%s:[* TO *] OR is_installed:false)", FIELD_INSTALLED_NAMESPACES));
     }
 
     private static void initSort(LiveDataQuery liveDataQuery, SolrQuery solrQuery)
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/java/org/xwiki/extension/security/internal/SolrToLiveDataEntryMapper.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/java/org/xwiki/extension/security/internal/SolrToLiveDataEntryMapper.java
index 85c1ce6fb77..9fe531cfcb5 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/java/org/xwiki/extension/security/internal/SolrToLiveDataEntryMapper.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/java/org/xwiki/extension/security/internal/SolrToLiveDataEntryMapper.java
@@ -20,14 +20,18 @@
 package org.xwiki.extension.security.internal;
 
 import java.nio.charset.Charset;
-import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
+import java.util.function.IntPredicate;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
+import javax.script.ScriptContext;
 
 import org.apache.http.client.utils.URLEncodedUtils;
 import org.apache.http.message.BasicNameValuePair;
@@ -39,14 +43,19 @@
 import org.xwiki.extension.index.internal.ExtensionIndexStore;
 import org.xwiki.localization.ContextualLocalizationManager;
 import org.xwiki.model.reference.LocalDocumentReference;
+import org.xwiki.script.ScriptContextManager;
 import org.xwiki.search.solr.SolrUtils;
 import org.xwiki.search.solr.internal.api.FieldUtils;
+import org.xwiki.template.TemplateManager;
 
 import static com.xpn.xwiki.web.ViewAction.VIEW_ACTION;
 import static java.util.Map.entry;
 import static java.util.Map.ofEntries;
 import static java.util.stream.Collectors.joining;
+import static javax.script.ScriptContext.ENGINE_SCOPE;
 import static org.apache.commons.lang.StringEscapeUtils.escapeXml;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_REVIEWED_SAFE;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_SAFE_EXPLANATIONS;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_ADVICE;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_CVE_CVSS;
 import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_CVE_ID;
@@ -71,6 +80,8 @@
 @Singleton
 public class SolrToLiveDataEntryMapper
 {
+    private static final String EXTENSION_ID = "extensionId";
+
     @Inject
     private SolrUtils solrUtils;
 
@@ -83,6 +94,12 @@ public class SolrToLiveDataEntryMapper
     @Inject
     private ExtensionIndexStore extensionIndexStore;
 
+    @Inject
+    private ScriptContextManager scriptContextManager;
+
+    @Inject
+    private TemplateManager templateManager;
+
     /**
      * @param doc the document to convert to Live Data entries.
      * @return Converts a {@link SolrDocument} to a {@link Map} of Live Data entries.
@@ -99,28 +116,79 @@ public Map<String, Object> mapDocToEntries(SolrDocument doc)
         );
     }
 
-    private static String buildCVEList(SolrDocument doc)
+    private String buildCVEList(SolrDocument doc)
+    {
+        // The CVEs of the current extension vulnerabilities.
+        ScriptContext currentScriptContext = this.scriptContextManager.getCurrentScriptContext();
+        currentScriptContext.setAttribute("cveIds", mapToStrings(doc, SECURITY_CVE_ID), ENGINE_SCOPE);
+        // The CVE links of the current extension vulnerabilities.
+        currentScriptContext.setAttribute("cveLinks", mapToStrings(doc, SECURITY_CVE_LINK), ENGINE_SCOPE);
+
+        // The CVSS of the current extension vulnerabilities.
+        currentScriptContext.setAttribute("cveCVSS", mapToStrings(doc, SECURITY_CVE_CVSS), ENGINE_SCOPE);
+
+        List<Boolean> safe = getSafe(doc);
+        currentScriptContext.setAttribute("notSafeCVEsIndex", getNotSafeCVEsIndex(doc, safe), ENGINE_SCOPE);
+        // The index of safe CVEs.
+        currentScriptContext.setAttribute("safeCVEsIndex", getSafeCVEsIndex(doc, safe), ENGINE_SCOPE);
+        currentScriptContext.setAttribute(EXTENSION_ID, buildExtensionId(doc), ENGINE_SCOPE);
+        currentScriptContext.setAttribute("messages", mapToStrings(doc, IS_SAFE_EXPLANATIONS), ENGINE_SCOPE);
+
+        return this.templateManager.renderNoException("extension/security/liveData/cveID.vm");
+    }
+
+    private static List<Boolean> getSafe(SolrDocument doc)
+    {
+        // The list of safe CVEs.
+        return Optional.ofNullable(doc.getFieldValues(IS_REVIEWED_SAFE))
+            .map(values -> values.stream()
+                .map(it -> (boolean) it)
+                .collect(Collectors.toList()))
+            .orElse(List.of());
+    }
+
+    private static List<Integer> getNotSafeCVEsIndex(SolrDocument doc, List<Boolean> safe)
     {
-        List<Object> cveIds = new ArrayList<>(doc.getFieldValues(SECURITY_CVE_ID));
-        List<Object> cveLinks = new ArrayList<>(doc.getFieldValues(SECURITY_CVE_LINK));
-        List<Object> cveCVSS = new ArrayList<>(doc.getFieldValues(SECURITY_CVE_CVSS));
-
-        return IntStream.range(0, cveIds.size())
-            .mapToObj(value -> String.format("<a href='%s'>%s</a>&nbsp;(%s)",
-                escapeXml(String.valueOf(cveLinks.get(value))),
-                escapeXml(String.valueOf(cveIds.get(value))),
-                escapeXml(String.valueOf(cveCVSS.get(value)))))
-            .collect(joining("<br/>"));
+        // The index of non-safe CVEs.
+        return IntStream.range(0, mapToStrings(doc, SECURITY_CVE_ID).size())
+            .filter(((IntPredicate) safe::get).negate())
+            .boxed()
+            .collect(Collectors.toList());
+    }
+
+    private static List<Integer> getSafeCVEsIndex(SolrDocument doc, List<Boolean> safe)
+    {
+        return IntStream.range(0, mapToStrings(doc, SECURITY_CVE_ID).size())
+            .filter(safe::get)
+            .boxed()
+            .collect(Collectors.toList());
+    }
+
+    private static List<String> mapToStrings(SolrDocument doc, String name)
+    {
+        Collection<Object> fieldValues = doc.getFieldValues(name);
+        if (fieldValues == null) {
+            return List.of();
+        }
+        return fieldValues.stream().map(String::valueOf).collect(Collectors.toList());
     }
 
     private String buildAdvice(SolrDocument doc)
     {
-        return this.l10n.getTranslationPlain(this.solrUtils.get(SECURITY_ADVICE, doc));
+        String advice = this.l10n.getTranslationPlain(this.solrUtils.get(SECURITY_ADVICE, doc));
+        if (advice == null) {
+            return "";
+        }
+        return advice;
     }
 
     private String buildFixVersion(SolrDocument doc)
     {
-        return this.solrUtils.get(SECURITY_FIX_VERSION, doc);
+        Object fixVersion = doc.get(SECURITY_FIX_VERSION);
+        if (fixVersion == null) {
+            return "";
+        }
+        return String.valueOf(fixVersion);
     }
 
     private Double buildMaxCVSS(SolrDocument doc)
@@ -166,7 +234,7 @@ private static List<BasicNameValuePair> buildExtensionURLParameters(String exten
     {
         return List.of(
             new BasicNameValuePair("section", "XWiki.Extensions"),
-            new BasicNameValuePair("extensionId", extensionId),
+            new BasicNameValuePair(EXTENSION_ID, extensionId),
             new BasicNameValuePair("extensionVersion", extensionVersion)
         );
     }
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/resources/ApplicationResources.properties b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/resources/ApplicationResources.properties
index f7a7cadb374..e71d015a7a0 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/resources/ApplicationResources.properties
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/resources/ApplicationResources.properties
@@ -21,6 +21,8 @@ extension.security.liveData.name=Name
 extension.security.liveData.extensionId=Extension Id
 extension.security.liveData.maxCVSS=Max CVSS
 extension.security.liveData.cveID=CVE IDs
+extension.security.liveData.cveID.modal.openButton=Display reviews for {0}
+extension.security.liveData.cveID.modal.title=Vulnerability {0} of {1}
 extension.security.liveData.fixVersion=Latest Fix Version
 extension.security.liveData.advice=Advice
 extension.security.liveData.wikis=Wikis
\ No newline at end of file
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/resources/templates/extension/security/liveData/cveID.vm b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/resources/templates/extension/security/liveData/cveID.vm
new file mode 100644
index 00000000000..8af889067e0
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/main/resources/templates/extension/security/liveData/cveID.vm
@@ -0,0 +1,65 @@
+## ---------------------------------------------------------------------------
+## See the NOTICE file distributed with this work for additional
+## information regarding copyright ownership.
+##
+## This is free software; you can redistribute it and/or modify it
+## under the terms of the GNU Lesser General Public License as
+## published by the Free Software Foundation; either version 2.1 of
+## the License, or (at your option) any later version.
+##
+## This software is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+## Lesser General Public License for more details.
+##
+## You should have received a copy of the GNU Lesser General Public
+## License along with this software; if not, write to the Free
+## Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+## 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+## ---------------------------------------------------------------------------
+## Since 15.6RC1
+## This template renders the content of the CVEs list of an extension.
+## The CVEs list is enriched with links to the CVEs description page, their corresponding CVSS scores.
+## Additionally, reviewed CVSS have a popup with providing more details explanations.  
+## ---------------------------------------------------------------------------
+#macro (showCVEs $indexes $isSafe)
+  #foreach($index in $indexes)
+    #set ($cveId = $cveIds.get($index))
+    #set ($message = $messages[$index])
+  <a href="$cveLinks[$index]" class="#if ($isSafe)xHint#end">$cveId</a>&nbsp;
+  <span class="#if ($isSafe)xHint#end">($cveCVSS[$index])</span>&nbsp;
+    #if ("$!message" != '')
+    <button type="button" class="btn btn-default btn-xs #if ($isSafe)xHint#end" data-toggle="modal"
+            data-target="#$escapetool.xml($cveId)"
+            aria-controls="$escapetool.xml($cveId)">
+      <span class="sr-only">
+        $escapetool.xml($services.localization.render("extension.security.liveData.cveID.modal.openButton", [$cveId]))
+      </span>
+      $services.icon.renderHTML('file-text')
+    </button>
+    <div class="modal fade security-vulnerability-detail-modal" tabindex="-1" role="dialog"
+         id='$escapetool.xml($cveId)'>
+      <div class="modal-dialog modal-lg" role="document">
+        <div class="modal-content">
+          <div class="modal-header">
+            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+              <span aria-hidden="true">&times;</span>
+            </button>
+            <h2 class="modal-title">
+              $escapetool.xml($services.localization.render(
+                "extension.security.liveData.cveID.modal.title", [$cveId, $extensionId]))
+            </h2>
+          </div>
+          <div class="modal-body">
+            $message
+          </div>
+        </div>
+      </div>
+    </div>
+    #end
+  <br>
+  #end
+#end
+##
+#showCVEs($notSafeCVEsIndex false)
+#showCVEs($safeCVEsIndex true)
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/test/java/org/xwiki/extension/security/internal/ExtensionSecuritySolrClientTest.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/test/java/org/xwiki/extension/security/internal/ExtensionSecuritySolrClientTest.java
index f5d7ca40626..4eb90e0c854 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/test/java/org/xwiki/extension/security/internal/ExtensionSecuritySolrClientTest.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/test/java/org/xwiki/extension/security/internal/ExtensionSecuritySolrClientTest.java
@@ -22,6 +22,7 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
+import java.util.stream.Collectors;
 
 import org.apache.solr.client.solrj.SolrQuery;
 import org.apache.solr.client.solrj.response.QueryResponse;
@@ -37,13 +38,16 @@
 import org.xwiki.test.junit5.mockito.MockComponent;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 import static org.xwiki.extension.InstalledExtension.FIELD_INSTALLED_NAMESPACES;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_FROM_ENVIRONMENT;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.IS_INSTALLED_EXTENSION;
+import static org.xwiki.extension.index.internal.ExtensionIndexSolrCoreInitializer.SECURITY_MAX_CVSS;
 import static org.xwiki.extension.security.internal.livedata.ExtensionSecurityLiveDataConfigurationProvider.FIX_VERSION;
 
 /**
@@ -79,8 +83,9 @@ void getExtensionsCount() throws Exception
         assertEquals(42, this.solrClient.getVulnerableExtensionsCount());
 
         SolrQuery params = new SolrQuery();
-        params.addFilterQuery("security_maxCVSS:[0 TO 10]");
-        params.addFilterQuery(FIELD_INSTALLED_NAMESPACES + ":[* TO *]");
+        params.addFilterQuery(String.format("%s:{0 TO 10]", SECURITY_MAX_CVSS));
+        params.addFilterQuery(String.format("(%s:[* TO *] OR %s:false)", FIELD_INSTALLED_NAMESPACES,
+            IS_INSTALLED_EXTENSION));
         verify(this.extensionIndexStore)
             .search(ArgumentMatchers.<SolrQuery>argThat(
                 t -> Arrays.equals(t.getFilterQueries(), params.getFilterQueries())));
@@ -101,15 +106,55 @@ void solrQuery() throws Exception
         liveDataQuery.setOffset(0L);
         liveDataQuery.setSort(List.of());
         liveDataQuery.setFilters(List.of(new LiveDataQuery.Filter(FIX_VERSION, "match", "15.5")));
+        liveDataQuery.setSource(new LiveDataQuery.Source());
         this.solrClient.solrQuery(liveDataQuery);
 
         SolrQuery params = new SolrQuery();
-        params.addFilterQuery("security_maxCVSS:[0 TO 10]");
-        params.addFilterQuery(FIELD_INSTALLED_NAMESPACES + ":[* TO *]");
+        params.addFilterQuery(SECURITY_MAX_CVSS + ":{0 TO 10]");
+        params.addFilterQuery(IS_FROM_ENVIRONMENT + ":false");
+        params.addFilterQuery(String.format("(%s:[* TO *] OR %s:false)", FIELD_INSTALLED_NAMESPACES,
+            IS_INSTALLED_EXTENSION));
 
         verify(this.extensionIndexStore)
             .search(AdditionalMatchers.<SolrQuery>and(
-                argThat(t -> Arrays.equals(t.getFilterQueries(), params.getFilterQueries())),
+                argThat(t -> Arrays.stream(t.getFilterQueries()).sorted().collect(Collectors.toList())
+                    .equals(Arrays.stream(params.getFilterQueries()).sorted().collect(
+                        Collectors.toList()))),
+                argThat(t -> Objects.equals(t.getSorts(), params.getSorts())))
+            );
+    }
+    
+    @Test
+    void solrQueryIsFromEnvironment() throws Exception
+    {
+        doAnswer(invocationOnMock -> {
+            SolrQuery solrQuery = invocationOnMock.getArgument(1);
+            solrQuery.setFilterQueries("");
+            solrQuery.setSort("fake", SolrQuery.ORDER.asc);
+            return null;
+        }).when(this.extensionIndexStore).createSolrQuery(any(), any());
+
+        LiveDataQuery liveDataQuery = new LiveDataQuery();
+        liveDataQuery.setLimit(10);
+        liveDataQuery.setOffset(0L);
+        liveDataQuery.setSort(List.of());
+        liveDataQuery.setFilters(List.of(new LiveDataQuery.Filter(FIX_VERSION, "match", "15.5")));
+        LiveDataQuery.Source source = new LiveDataQuery.Source();
+        source.getParameters().put("isFromEnvironment", "true");
+        liveDataQuery.setSource(source);
+        this.solrClient.solrQuery(liveDataQuery);
+
+        SolrQuery params = new SolrQuery();
+        params.addFilterQuery(SECURITY_MAX_CVSS + ":{0 TO 10]");
+        params.addFilterQuery(IS_FROM_ENVIRONMENT + ":true");
+        params.addFilterQuery(String.format("(%s:[* TO *] OR %s:false)", FIELD_INSTALLED_NAMESPACES,
+            IS_INSTALLED_EXTENSION));
+
+        verify(this.extensionIndexStore)
+            .search(AdditionalMatchers.<SolrQuery>and(
+                argThat(t -> Arrays.stream(t.getFilterQueries()).sorted().collect(Collectors.toList())
+                    .equals(Arrays.stream(params.getFilterQueries()).sorted().collect(
+                        Collectors.toList()))),
                 argThat(t -> Objects.equals(t.getSorts(), params.getSorts())))
             );
     }
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/test/java/org/xwiki/extension/security/internal/SolrToLiveDataEntryMapperTest.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/test/java/org/xwiki/extension/security/internal/SolrToLiveDataEntryMapperTest.java
index c09a0b03895..349d9ddc2dd 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/test/java/org/xwiki/extension/security/internal/SolrToLiveDataEntryMapperTest.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-api/src/test/java/org/xwiki/extension/security/internal/SolrToLiveDataEntryMapperTest.java
@@ -22,6 +22,9 @@
 import java.util.List;
 import java.util.Map;
 
+import javax.inject.Inject;
+import javax.script.ScriptContext;
+
 import org.apache.solr.common.SolrDocument;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mock;
@@ -29,8 +32,10 @@
 import org.xwiki.extension.ExtensionId;
 import org.xwiki.extension.index.internal.ExtensionIndexStore;
 import org.xwiki.localization.ContextualLocalizationManager;
+import org.xwiki.script.ScriptContextManager;
 import org.xwiki.search.solr.SolrUtils;
 import org.xwiki.search.solr.internal.api.FieldUtils;
+import org.xwiki.template.TemplateManager;
 import org.xwiki.test.junit5.mockito.ComponentTest;
 import org.xwiki.test.junit5.mockito.InjectMockComponents;
 import org.xwiki.test.junit5.mockito.MockComponent;
@@ -66,12 +71,26 @@ class SolrToLiveDataEntryMapperTest
     @MockComponent
     private ExtensionIndexStore extensionIndexStore;
 
+    @MockComponent
+    private ScriptContextManager scriptContextManager;
+
+    @MockComponent
+    private TemplateManager templateManager;
+
+
     @Mock
     private SolrDocument doc;
 
+    @Mock
+    private ScriptContext scriptContext;
+
     @Test
     void mapDocToEntries()
     {
+        when(this.scriptContextManager.getCurrentScriptContext()).thenReturn(this.scriptContext);
+        when(this.templateManager.renderNoException("extension/security/liveData/cveID.vm"))
+            .thenReturn("template content");
+        
         when(this.extensionIndexStore.getExtensionId(this.doc)).thenReturn(new ExtensionId("org.test:ext", "7.5"));
         when(this.doc.get(FieldUtils.NAME)).thenReturn("Ext Name");
         when(this.solrUtils.get(FieldUtils.NAME, this.doc)).thenReturn("Ext Name");
@@ -85,8 +104,8 @@ void mapDocToEntries()
             "wiki:s1"));
         when(this.l10n.getTranslationPlain("translation.key")).thenReturn("Translation Value");
         assertEquals(Map.of(
-            "cveID", "",
-            "fixVersion", "8.4",
+            "cveID", "template content",
+            "fixVersion", "",
             "maxCVSS", 5.0,
             "advice", "Translation Value",
             "name",
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/ExtensionSecurityJob.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/ExtensionSecurityJob.java
index caf71bdaf97..f2ef3dba562 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/ExtensionSecurityJob.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/ExtensionSecurityJob.java
@@ -19,7 +19,14 @@
  */
 package org.xwiki.extension.security.internal;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
 
 import javax.inject.Inject;
 import javax.inject.Named;
@@ -27,8 +34,15 @@
 import org.xwiki.component.annotation.Component;
 import org.xwiki.component.annotation.InstantiationStrategy;
 import org.xwiki.component.descriptor.ComponentInstantiationStrategy;
+import org.xwiki.context.ExecutionContext;
+import org.xwiki.context.ExecutionContextException;
+import org.xwiki.context.ExecutionContextManager;
+import org.xwiki.extension.CoreExtension;
+import org.xwiki.extension.Extension;
 import org.xwiki.extension.InstalledExtension;
 import org.xwiki.extension.index.security.ExtensionSecurityAnalysisResult;
+import org.xwiki.extension.index.security.review.ReviewsMap;
+import org.xwiki.extension.repository.CoreExtensionRepository;
 import org.xwiki.extension.repository.InstalledExtensionRepository;
 import org.xwiki.extension.security.ExtensionSecurityIndexationEndEvent;
 import org.xwiki.extension.security.analyzer.ExtensionSecurityAnalyzer;
@@ -59,6 +73,9 @@ public class ExtensionSecurityJob
     @Inject
     private InstalledExtensionRepository installedExtensionRepository;
 
+    @Inject
+    private CoreExtensionRepository coreExtensionRepository;
+
     @Inject
     @Named(OsvExtensionSecurityAnalyzer.ID)
     private ExtensionSecurityAnalyzer extensionSecurityAnalyzer;
@@ -66,6 +83,9 @@ public class ExtensionSecurityJob
     @Inject
     private VulnerabilityIndexer vulnerabilityIndexer;
 
+    @Inject
+    private ExecutionContextManager executionContextManager;
+
     @Override
     public String getType()
     {
@@ -75,36 +95,74 @@ public String getType()
     @Override
     protected void runInternal()
     {
-        Collection<InstalledExtension> installedExtensions =
-            this.installedExtensionRepository.getInstalledExtensions();
-        this.progressManager.pushLevelProgress(installedExtensions.size(), this);
+        Collection<InstalledExtension> installedExtensions = this.installedExtensionRepository.getInstalledExtensions();
+        Collection<CoreExtension> coreExtensions = this.coreExtensionRepository.getCoreExtensions();
+        this.progressManager.pushLevelProgress(installedExtensions.size() + coreExtensions.size(), this);
+
+        // TODO: Replace with an actual remote reviews fetch. 
+        ReviewsMap reviewsMap = new ReviewsMap();
 
         try {
-            // Note: for now, this step is sequential and each extension is analyzed after the previous one.
-            long newVulnerabilityCount = 0;
+            ExecutorService executorService = Executors.newFixedThreadPool(10);
+
+            List<Future<Boolean>> tasks = new ArrayList<>();
             for (InstalledExtension extension : installedExtensions) {
-                this.progressManager.startStep(this);
-                try {
-                    ExtensionSecurityAnalysisResult analysis = this.extensionSecurityAnalyzer.analyze(extension);
-                    if (analysis != null) {
-                        boolean update = this.vulnerabilityIndexer.update(extension, analysis);
-                        if (update) {
-                            newVulnerabilityCount++;
-                        }
-                    }
-                } catch (ExtensionSecurityException e) {
-                    this.logger.warn("Failed to analyse [{}]. Cause: [{}]", extension.getId().toString(),
-                        getRootCauseMessage(e));
-                } catch (Exception e) {
-                    this.logger.warn("Unexpected error [{}]", getRootCauseMessage(e));
-                }
-                this.progressManager.endStep(this);
+                tasks.add(executorService.submit(() -> handleExtension(extension, reviewsMap)));
+            }
+
+            for (CoreExtension extension : coreExtensions) {
+                tasks.add(executorService.submit(() -> handleExtension(extension, reviewsMap)));
             }
-            
+
+            long newVulnerabilityCount = consumeTasks(tasks);
             this.observationManager.notify(new ExtensionSecurityIndexationEndEvent(), null, newVulnerabilityCount);
-            
+        } catch (InterruptedException e) {
+            this.logger.warn("The job has been interrupted. Cause: [{}]", getRootCauseMessage(e));
+            Thread.currentThread().interrupt();
         } finally {
             this.progressManager.popLevelProgress(this);
         }
     }
+
+    private long consumeTasks(List<Future<Boolean>> tasks) throws InterruptedException
+    {
+        long newVulnerabilityCount = 0;
+        for (Future<Boolean> future : tasks) {
+            try {
+                Boolean updated = future.get();
+                this.progressManager.startStep(this);
+                if (Objects.equals(Boolean.TRUE, updated)) {
+                    newVulnerabilityCount++;
+                }
+            } catch (ExecutionException e) {
+                this.logger.error("Failed to execute an extension analysis.", e);
+            } finally {
+                this.progressManager.endStep(this);
+            }
+        }
+        return newVulnerabilityCount;
+    }
+
+    private boolean handleExtension(Extension extension, ReviewsMap reviewsMap)
+    {
+        boolean hasNew = false;
+        try {
+            this.executionContextManager.initialize(new ExecutionContext());
+            ExtensionSecurityAnalysisResult analysis = this.extensionSecurityAnalyzer.analyze(extension);
+            if (analysis != null) {
+                boolean update = this.vulnerabilityIndexer.update(extension, analysis, reviewsMap);
+                if (update) {
+                    hasNew = true;
+                }
+            }
+        } catch (ExtensionSecurityException e) {
+            this.logger.warn("Failed to analyse [{}]. Cause: [{}]", extension.getId(), getRootCauseMessage(e));
+        } catch (ExecutionContextException e) {
+            this.logger.warn("Failed to initialize the execution context for [{}]. Cause: [{}]", extension,
+                getRootCauseMessage(e));
+        } catch (Exception e) {
+            this.logger.warn("Unexpected error [{}]", getRootCauseMessage(e));
+        }
+        return hasNew;
+    }
 }
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/ExtensionSecurityScheduler.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/ExtensionSecurityScheduler.java
index 20c4c79391f..e15076c1f29 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/ExtensionSecurityScheduler.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/ExtensionSecurityScheduler.java
@@ -118,6 +118,8 @@ public void run()
     @Override
     public void dispose()
     {
-        this.executor.shutdownNow();
+        if (this.executor != null) {
+            this.executor.shutdownNow();
+        }
     }
 }
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/VulnerabilityIndexer.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/VulnerabilityIndexer.java
index 34b3356126b..f504987eb42 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/VulnerabilityIndexer.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/VulnerabilityIndexer.java
@@ -20,19 +20,36 @@
 package org.xwiki.extension.security.internal.analyzer;
 
 import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Path;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
 
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.solr.client.solrj.SolrServerException;
 import org.slf4j.Logger;
 import org.xwiki.component.annotation.Component;
+import org.xwiki.extension.CoreExtension;
+import org.xwiki.extension.CoreExtensionFile;
 import org.xwiki.extension.Extension;
+import org.xwiki.extension.ExtensionFile;
 import org.xwiki.extension.index.internal.ExtensionIndexStore;
 import org.xwiki.extension.index.security.ExtensionSecurityAnalysisResult;
+import org.xwiki.extension.index.security.SecurityVulnerabilityDescriptor;
+import org.xwiki.extension.index.security.review.Review;
+import org.xwiki.extension.index.security.review.ReviewResult;
+import org.xwiki.extension.index.security.review.ReviewsMap;
 
+import static org.apache.commons.lang.StringEscapeUtils.escapeXml;
 import static org.apache.commons.lang3.exception.ExceptionUtils.getRootCauseMessage;
+import static org.xwiki.extension.index.security.review.ReviewResult.UNSAFE;
 
 /**
  * Update an {@link Extension} with the results of its latest {@link ExtensionSecurityAnalysisResult}.
@@ -44,6 +61,21 @@
 @Singleton
 public class VulnerabilityIndexer
 {
+    /**
+     * Shared constant when the suggestion is to upgrade the extension from the Extension Manager.
+     */
+    private static final String UPGRADE_FROM_EM_ADVICE = "extension.security.analysis.advice.upgradeFromEM";
+
+    /**
+     * Shared constant when the suggestion is to upgrade the extension XWiki itself.
+     */
+    private static final String UPGRADE_XWIKI_ADVICE = "extension.security.analysis.advice.upgradeXWiki";
+
+    /**
+     * Shared constant when the suggestion is to upgrade something else from the environment.
+     */
+    private static final String UPGRADE_ENVIRONMENT_ADVICE = "extension.security.analysis.advice.upgradeEnvironment";
+
     @Inject
     private ExtensionIndexStore extensionIndexStore;
 
@@ -55,18 +87,47 @@ public class VulnerabilityIndexer
      *
      * @param extension the extension to update
      * @param analysis the security analysis to update the extension
+     * @param reviewsMap the map of reviewed CVEs
      * @return {@code true} if some new security vulnerabilities are inserted, {@code false} otherwise
      */
-    public boolean update(Extension extension, ExtensionSecurityAnalysisResult analysis)
+    public boolean update(Extension extension, ExtensionSecurityAnalysisResult analysis, ReviewsMap reviewsMap)
     {
         try {
             List<String> cveiDs = this.extensionIndexStore.getCVEIDs(extension.getId());
 
-            boolean hasNew =
-                analysis.getSecurityVulnerabilities().stream()
-                    .anyMatch(vulnerability -> !cveiDs.contains(vulnerability.getId()));
+            List<SecurityVulnerabilityDescriptor> securityVulnerabilities = analysis.getSecurityVulnerabilities();
+            boolean hasNew = securityVulnerabilities.stream()
+                .anyMatch(vulnerability -> {
+                    String vulnerabilityId = vulnerability.getId();
+                    return !cveiDs.contains(vulnerabilityId) && isNotSafe(reviewsMap, vulnerabilityId);
+                });
+
+            boolean fromEnvironment = isFromEnvironment(extension);
+            if (!securityVulnerabilities.isEmpty()) {
+                if (fromEnvironment) {
+                    analysis.setAdvice(UPGRADE_ENVIRONMENT_ADVICE);
+                    analysis.setInstalledExtension(false);
+                } else if (extension instanceof CoreExtension) {
+                    analysis.setAdvice(UPGRADE_XWIKI_ADVICE);
+                    analysis.setInstalledExtension(false);
+                } else {
+                    analysis.setAdvice(UPGRADE_FROM_EM_ADVICE);
+                    analysis.setInstalledExtension(true);
+                }
+            }
 
-            this.extensionIndexStore.update(extension.getId(), analysis);
+            securityVulnerabilities.forEach(securityVulnerabilityDescriptor -> {
+                Optional<List<Review>> byId = reviewsMap.getById(securityVulnerabilityDescriptor.getId());
+                if (byId.isPresent()) {
+                    securityVulnerabilityDescriptor.setReviews(formatReviews(byId.get()));
+                } else {
+                    securityVulnerabilityDescriptor.setReviews("");
+                }
+                securityVulnerabilityDescriptor
+                    .setSafe(!isNotSafe(reviewsMap, securityVulnerabilityDescriptor.getId()));
+            });
+
+            this.extensionIndexStore.update(extension.getId(), analysis.setFromEnvironment(fromEnvironment));
 
             return hasNew;
         } catch (SolrServerException | IOException e) {
@@ -76,4 +137,72 @@ public boolean update(Extension extension, ExtensionSecurityAnalysisResult analy
             return false;
         }
     }
+
+    private static String formatReviews(List<Review> reviews)
+    {
+        String reviewsFormatted = reviews.stream()
+            .filter(it -> it.getResult() == ReviewResult.SAFE)
+            .map(VulnerabilityIndexer::formatReview).collect(Collectors.joining());
+        boolean hasUnsafe = reviews.stream().anyMatch(review -> review.getResult() == UNSAFE);
+        if (hasUnsafe) {
+            String unsafeReviews = reviews
+                .stream()
+                .filter(review -> review.getResult() == UNSAFE)
+                .map(VulnerabilityIndexer::formatReview)
+                .collect(Collectors.joining());
+            reviewsFormatted =
+                String.format("<div class='box errormessage'>%s</div>%s", unsafeReviews, reviewsFormatted);
+        }
+        return reviewsFormatted;
+    }
+
+    private static boolean isNotSafe(ReviewsMap reviewsMap, String vulnerabilityId)
+    {
+        boolean isNotSafe;
+        Map<String, List<Review>> map = reviewsMap.getReviewsMap();
+        if (map.containsKey(vulnerabilityId)) {
+            isNotSafe = map.get(vulnerabilityId)
+                .stream()
+                .anyMatch(review -> review.getResult() == UNSAFE);
+        } else {
+            isNotSafe = true;
+        }
+        return isNotSafe;
+    }
+
+    private boolean isFromEnvironment(Extension extension)
+    {
+        ExtensionFile file = extension.getFile();
+        boolean isFromEnvironment;
+        if (file instanceof CoreExtensionFile) {
+            Path currentPathAbsolute = Path.of(".").toAbsolutePath();
+            CoreExtensionFile coreExtensionFile = (CoreExtensionFile) file;
+            if (coreExtensionFile.getURL() == null) {
+                return false;
+            }
+            String withoutInternalReference = StringUtils.substringBefore(coreExtensionFile.getURL().toString(), "!")
+                .replaceFirst("^jar:", "");
+            try {
+                Path jarPathAbsolute = FileUtils.toFile(new URL(withoutInternalReference)).toPath();
+                isFromEnvironment = !currentPathAbsolute.relativize(jarPathAbsolute).startsWith(Path.of("webapps"));
+            } catch (MalformedURLException e) {
+                this.logger.warn("Failed to resolve the URL for [{}]. Cause: [{}]", coreExtensionFile,
+                    getRootCauseMessage(e));
+                isFromEnvironment = false;
+            }
+        } else {
+            isFromEnvironment = false;
+        }
+        return isFromEnvironment;
+    }
+
+    private static String formatReview(Review review)
+    {
+        return String.format("<dl>\n"
+                + "  <dt>%s</dt>\n"
+                + "  <dd>%s</dd>\n"
+                + "</dl>",
+            escapeXml(review.getEmitter()),
+            escapeXml(review.getExplanation()));
+    }
 }
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/osv/OsvResponseAnalyzer.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/osv/OsvResponseAnalyzer.java
index e43a3c7f2cb..2a754f06297 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/osv/OsvResponseAnalyzer.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/osv/OsvResponseAnalyzer.java
@@ -55,10 +55,6 @@
 @Singleton
 public class OsvResponseAnalyzer
 {
-    /**
-     * Shared constant when the suggestion is to upgrade the extension from the Extension Manager.
-     */
-    private static final String UPGRADE_FROM_EM_ADVICE = "extension.security.analysis.advice.upgradeFromEM";
 
     @Inject
     private Logger logger;
@@ -81,14 +77,10 @@ public ExtensionSecurityAnalysisResult analyzeOsvResponse(String extensionId, St
                     vulnerability)
                     .ifPresent(matchingVulns::add));
         }
-        Version currentVersion = new DefaultVersion(version);
-        ExtensionSecurityAnalysisResult extensionSecurityAnalysisResult = new ExtensionSecurityAnalysisResult()
-            .setResults(matchingVulns.stream().map(vulnObject -> convert(vulnObject, currentVersion))
+
+        return new ExtensionSecurityAnalysisResult()
+            .setResults(matchingVulns.stream().map(vulnObject -> convert(vulnObject, new DefaultVersion(version)))
                 .collect(Collectors.toList()));
-        if (!extensionSecurityAnalysisResult.getSecurityVulnerabilities().isEmpty()) {
-            extensionSecurityAnalysisResult.setAdvice(UPGRADE_FROM_EM_ADVICE);
-        }
-        return extensionSecurityAnalysisResult;
     }
 
     private SecurityVulnerabilityDescriptor convert(VulnObject vulnObject, Version currentVersion)
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/osv/model/response/VulnObject.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/osv/model/response/VulnObject.java
index 306a00fcad1..e0a12d8d2fe 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/osv/model/response/VulnObject.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/java/org/xwiki/extension/security/internal/analyzer/osv/model/response/VulnObject.java
@@ -128,6 +128,9 @@ public String getMainURL()
      */
     public String getSeverityCCSV3()
     {
+        if (this.severity == null) {
+            return "";
+        }
         return this.severity.stream()
             .filter(s -> Objects.equals("CVSS_V3", s.getType()))
             .map(SeverityObject::getScore)
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/resources/ApplicationResources.properties b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/resources/ApplicationResources.properties
index f739a57b251..f182b5a4b9b 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/resources/ApplicationResources.properties
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/main/resources/ApplicationResources.properties
@@ -18,4 +18,6 @@
 # 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 # ---------------------------------------------------------------------------
 
-extension.security.analysis.advice.upgradeFromEM=This extension can be upgraded from the extension manager.
\ No newline at end of file
+extension.security.analysis.advice.upgradeFromEM=This extension can be upgraded from the extension manager.
+extension.security.analysis.advice.upgradeXWiki=Please upgrade XWiki.
+extension.security.analysis.advice.upgradeEnvironment=Please upgrade your servlet engine.
\ No newline at end of file
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/ExtensionSecuritySchedulerTest.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/ExtensionSecuritySchedulerTest.java
new file mode 100644
index 00000000000..275df2a0bc6
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/ExtensionSecuritySchedulerTest.java
@@ -0,0 +1,104 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.extension.security.internal;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.Mock;
+import org.xwiki.extension.security.ExtensionSecurityConfiguration;
+import org.xwiki.job.Job;
+import org.xwiki.job.JobExecutor;
+import org.xwiki.test.LogLevel;
+import org.xwiki.test.junit5.LogCaptureExtension;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test of {@link ExtensionSecurityScheduler}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class ExtensionSecuritySchedulerTest
+{
+    @InjectMockComponents
+    private ExtensionSecurityScheduler scheduler;
+
+    @MockComponent
+    private JobExecutor jobExecutor;
+
+    @MockComponent
+    private ExtensionSecurityConfiguration extensionSecurityConfiguration;
+
+    @RegisterExtension
+    private LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.INFO);
+
+    @Mock
+    private Job mock;
+
+    @BeforeEach
+    void setUp() throws Exception
+    {
+        when(this.extensionSecurityConfiguration.getScanDelay()).thenReturn(1);
+        when(this.jobExecutor.execute(anyString(), any())).thenReturn(this.mock);
+    }
+
+    @Test
+    void start()
+    {
+        this.scheduler.start();
+        verify(this.extensionSecurityConfiguration, timeout(1000)).isSecurityScanEnabled();
+        this.scheduler.start();
+        verify(this.extensionSecurityConfiguration).isSecurityScanEnabled();
+        assertEquals("Extension security scan disabled.", this.logCapture.getMessage(0));
+    }
+
+    @Test
+    void startEnabled() throws Exception
+    {
+        when(this.extensionSecurityConfiguration.isSecurityScanEnabled()).thenReturn(true);
+        this.scheduler.start();
+        verify(this.extensionSecurityConfiguration, timeout(1000)).isSecurityScanEnabled();
+        verify(this.jobExecutor, timeout(1000)).execute(ExtensionSecurityJob.JOBTYPE, new ExtensionSecurityRequest());
+        this.scheduler.start();
+        verify(this.extensionSecurityConfiguration).isSecurityScanEnabled();
+        verify(this.jobExecutor).execute(ExtensionSecurityJob.JOBTYPE, new ExtensionSecurityRequest());
+    }
+
+    @Test
+    void restart()
+    {
+        this.scheduler.start();
+        verify(this.extensionSecurityConfiguration, timeout(1000)).isSecurityScanEnabled();
+        this.scheduler.restart();
+        verify(this.extensionSecurityConfiguration, timeout(1000).times(2)).isSecurityScanEnabled();
+        assertEquals("Extension security scan disabled.", this.logCapture.getMessage(0));
+        assertEquals("Extension security scan disabled.", this.logCapture.getMessage(1));
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/analyzer/VulnerabilityIndexerTest.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/analyzer/VulnerabilityIndexerTest.java
new file mode 100644
index 00000000000..b7156633ac6
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/analyzer/VulnerabilityIndexerTest.java
@@ -0,0 +1,132 @@
+/*
+ * See the NOTICE file distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as
+ * published by the Free Software Foundation; either version 2.1 of
+ * the License, or (at your option) any later version.
+ *
+ * This software is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this software; if not, write to the Free
+ * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+ * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
+ */
+package org.xwiki.extension.security.internal.analyzer;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.xwiki.extension.ExtensionId;
+import org.xwiki.extension.index.internal.ExtensionIndexStore;
+import org.xwiki.extension.index.security.review.ReviewsMap;
+import org.xwiki.extension.index.security.ExtensionSecurityAnalysisResult;
+import org.xwiki.extension.index.security.SecurityVulnerabilityDescriptor;
+import org.xwiki.extension.repository.internal.core.DefaultCoreExtension;
+import org.xwiki.extension.repository.internal.core.DefaultCoreExtensionRepository;
+import org.xwiki.extension.version.internal.DefaultVersion;
+import org.xwiki.test.LogLevel;
+import org.xwiki.test.junit5.LogCaptureExtension;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Test of {@link VulnerabilityIndexer}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class VulnerabilityIndexerTest
+{
+    @InjectMockComponents
+    private VulnerabilityIndexer indexer;
+
+    @MockComponent
+    private ExtensionIndexStore extensionIndexStore;
+
+    @RegisterExtension
+    private LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.WARN);
+
+    @Test
+    void update() throws Exception
+    {
+        ExtensionId extensionId = new ExtensionId("org.xwiki:ext", "12.10");
+        DefaultCoreExtension extension =
+            new DefaultCoreExtension(mock(DefaultCoreExtensionRepository.class), new URL("file:///path/ext.jar"),
+                extensionId, "exttype");
+        ExtensionSecurityAnalysisResult inputAnalysis = new ExtensionSecurityAnalysisResult()
+            .setResults(List.of());
+        boolean update = this.indexer.update(extension, inputAnalysis, new ReviewsMap());
+
+        assertFalse(update);
+
+        verify(this.extensionIndexStore).update(extensionId, new ExtensionSecurityAnalysisResult()
+            .setFromEnvironment(true)
+            .setResults(List.of()));
+    }
+
+    @Test
+    void updateWithVulnerabilities() throws Exception
+    {
+        ExtensionId extensionId = new ExtensionId("org.xwiki:ext", "12.10");
+        DefaultCoreExtension extension =
+            new DefaultCoreExtension(mock(DefaultCoreExtensionRepository.class), new URL("file:///path/ext.jar"),
+                extensionId, "exttype");
+        SecurityVulnerabilityDescriptor securityVulnerabilityDescriptor = new SecurityVulnerabilityDescriptor()
+            .setScore(1.3)
+            .setId("org.xwiki.ext/12.10")
+            .setURL("https://ext.dev/path")
+            .setFixVersion(new DefaultVersion("15.2"));
+        ExtensionSecurityAnalysisResult inputAnalysis = new ExtensionSecurityAnalysisResult()
+            .setResults(List.of(securityVulnerabilityDescriptor));
+
+        boolean update = this.indexer.update(extension, inputAnalysis, new ReviewsMap());
+
+        assertTrue(update);
+
+        verify(this.extensionIndexStore).update(extensionId, new ExtensionSecurityAnalysisResult()
+            .setFromEnvironment(true)
+            .setAdvice("extension.security.analysis.advice.upgradeEnvironment")
+            .setResults(List.of(securityVulnerabilityDescriptor)));
+    }
+
+    @Test
+    void updateIsNotServlet() throws Exception
+    {
+        ExtensionId extensionId = new ExtensionId("org.xwiki:ext", "12.10");
+
+        DefaultCoreExtension extension =
+            new DefaultCoreExtension(mock(DefaultCoreExtensionRepository.class), getWebjarExtensionURL(),
+                extensionId, "exttype");
+        ExtensionSecurityAnalysisResult inputAnalysis = new ExtensionSecurityAnalysisResult()
+            .setResults(List.of());
+        boolean update = this.indexer.update(extension, inputAnalysis, new ReviewsMap());
+
+        assertFalse(update);
+
+        verify(this.extensionIndexStore).update(extensionId, new ExtensionSecurityAnalysisResult()
+            .setFromEnvironment(false)
+            .setResults(List.of()));
+    }
+
+    private static URL getWebjarExtensionURL() throws MalformedURLException
+    {
+        Path root = Path.of(".").toAbsolutePath();
+        return root.resolve(Path.of("webapps", "ext.jar")).toUri().toURL();
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/analyzer/osv/OsvResponseAnalyzerTest.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/analyzer/osv/OsvResponseAnalyzerTest.java
index 97ac857cb2c..de6a8892f6c 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/analyzer/osv/OsvResponseAnalyzerTest.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-index/src/test/java/org/xwiki/extension/security/internal/analyzer/osv/OsvResponseAnalyzerTest.java
@@ -86,7 +86,6 @@ void analyzeOsvResponseOrgXWikiPlatform()
             securityVulnerabilityDescriptor1,
             securityVulnerabilityDescriptor2
         ));
-        expected.setAdvice("extension.security.analysis.advice.upgradeFromEM");
 
         assertEquals(expected,
             this.analyzer.analyzeOsvResponse("org.xwiki.platform:xwiki-platform-administration-ui", "13.10",
@@ -105,7 +104,6 @@ void analyzeOsvResponse()
         securityVulnerabilityDescriptor.setScore(7.5);
         securityVulnerabilityDescriptor.setFixVersion(new DefaultVersion("15.7"));
         expected.setResults(List.of(securityVulnerabilityDescriptor));
-        expected.setAdvice("extension.security.analysis.advice.upgradeFromEM");
 
         assertEquals(expected,
             this.analyzer.analyzeOsvResponse("org.test:my-ext", "7.5", osvResponse));
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-test/xwiki-platform-extension-security-test-docker/src/test/it/org/xwiki/extension/security/test/ui/ExtensionSecurityIT.java b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-test/xwiki-platform-extension-security-test-docker/src/test/it/org/xwiki/extension/security/test/ui/ExtensionSecurityIT.java
index 411c2bd3d49..3b86542c696 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-test/xwiki-platform-extension-security-test-docker/src/test/it/org/xwiki/extension/security/test/ui/ExtensionSecurityIT.java
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-test/xwiki-platform-extension-security-test-docker/src/test/it/org/xwiki/extension/security/test/ui/ExtensionSecurityIT.java
@@ -75,9 +75,9 @@ void extensionVulnerabilitiesAdmin(TestUtils setup, TestReference testReference)
                 + "org.xwiki.platform:xwiki-platform-administration-ui/")));
         tableLayout.assertRow("Wikis", "xwiki");
         tableLayout.assertRow("Max CVSS", "9.9");
-        tableLayout.assertRow("CVE IDs", "GHSA-4v38-964c-xjmw (9.9)\n"
-            + "GHSA-9j36-3cp4-rh4j (9.9)\n"
-            + "GHSA-mgjw-2wrp-r535 (8.8)");
+        tableLayout.assertRow("CVE IDs", "GHSA-4v38-964c-xjmw  (9.9) \n"
+            + "GHSA-9j36-3cp4-rh4j  (9.9) \n"
+            + "GHSA-mgjw-2wrp-r535  (8.8) ");
         tableLayout.assertRow("Latest Fix Version", "140.10.2");
     }
 
diff --git a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-ui/src/main/resources/XWiki/Extension/Security/Code/Admin.xml b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-ui/src/main/resources/XWiki/Extension/Security/Code/Admin.xml
index 773bf731fda..d93182bf2eb 100644
--- a/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-ui/src/main/resources/XWiki/Extension/Security/Code/Admin.xml
+++ b/xwiki-platform-core/xwiki-platform-extension/xwiki-platform-extension-security/xwiki-platform-extension-security-ui/src/main/resources/XWiki/Extension/Security/Code/Admin.xml
@@ -41,18 +41,48 @@
 {{velocity}}
 #if($services.security.authorization.hasAccess('admin') &amp;&amp; $services.extension.index.security.isSecurityScanEnabled())
 #set($discard = $xwiki.ssx.use("XWiki.Extension.Security.Code.Admin"))
-{{warning}}
-{{translation key="extension.security.admin.generalWarning"/}}
-{{/warning}}
+
+{{html clean='false' wiki='true'}}
+&lt;ul class="nav nav-tabs" role="tablist"&gt;
+  &lt;li role="presentation" class="active"&gt;&lt;a href="#extension-vulnerabilities" aria-controls="extension-vulnerabilities" role="tab" data-toggle="tab"&gt;Extension Vulnerabilities&lt;/a&gt;&lt;/li&gt;
+  &lt;li role="presentation"&gt;&lt;a href="#environment-vulnerabilities" aria-controls="environment-vulnerabilities" 
+  role="tab" data-toggle="tab"&gt;Environment Vulnerabilities&lt;/a&gt;&lt;/li&gt;
+&lt;/ul&gt;
+
+
+&lt;div class="tab-content"&gt;
+  &lt;div role="tabpanel" class="tab-pane active" id="extension-vulnerabilities"&gt;
 
 {{liveData
   id="extension-vulnerabilities-list"
   source="extensionSecurity"
+  sourceParameters="isFromEnvironment=false"
   properties="name,wikis,maxCVSS,cveID,fixVersion,advice"
   showPageSizeDropdown="false"}}
 {{/liveData}}
 
-{{html}}
+&lt;/div&gt;
+
+&lt;div role="tabpanel" class="tab-pane" id="environment-vulnerabilities"&gt;
+
+(((&amp;nbsp;)))
+{{warning}}
+Extensions listed in this section have known vulnerabilities, but are outside of the scope of XWiki.
+This means that the extensions are provided by the environment running XWiki (e.g., its servlet engine), and can't be
+ fixed by upgrading XWiki or one of its extension.
+{{/warning}}
+
+{{liveData
+  id="environment-vulnerabilities-list"
+  source="extensionSecurity"
+  sourceParameters="isFromEnvironment=true"
+  properties="name,wikis,maxCVSS,cveID,fixVersion"
+  showPageSizeDropdown="false"}}
+{{/liveData}}
+
+  &lt;/div&gt;
+&lt;/div&gt;
+
 #set ($indexJobStatus = $services.job.getJobStatus(['extension_security']))
 #if ($indexJobStatus)
   &lt;div class="box infomessage"&gt;
@@ -73,7 +103,8 @@
     #else
       &lt;div class="box warningmessage"&gt;$escapetool.xml($services.localization.render('extension.security.indexed.nojob'))&lt;/div&gt;
     #end
-{{/html}}
+  &lt;/div&gt;
+  {{/html}}
 #end
 {{/velocity}}
 
@@ -443,7 +474,8 @@
       <cache>long</cache>
     </property>
     <property>
-      <code>#extension-vulnerabilities-list {
+      <code>#extension-vulnerabilities-list,
+#environment-vulnerabilities-list {
   td,
   th {
     inline-size: min-content;
@@ -464,6 +496,11 @@
     word-wrap: break-word;
     white-space: break-spaces;
   }
+
+  .security-vulnerability-detail-modal {
+    white-space: normal;
+    word-wrap: normal;
+  }
 }
 </code>
     </property>
-- 
GitLab